-
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
New status command #100
Changes from all commits
191ca3e
e88bce7
40a4944
62db43c
12815b2
2fe1ff6
5e14fb3
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,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)) | ||
| }, | ||
| } | ||
| } |
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,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
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. Balance spinner start/stop per container iteration.
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 |
||
|
|
||
| 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 | ||
| } | ||
| 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)) | ||
| } |
| 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) | ||
carole-lavillonniere marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.