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
11 changes: 8 additions & 3 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/emulator"
"github.com/localstack/lstk/internal/emulator/aws"
"github.com/localstack/lstk/internal/emulator/snowflake"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
Expand All @@ -30,12 +32,15 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
return fmt.Errorf("failed to get config: %w", err)
}

awsClient := aws.NewClient()
clients := map[config.EmulatorType]emulator.Client{
config.EmulatorAWS: aws.NewClient(),
config.EmulatorSnowflake: snowflake.NewClient(),
}

if isInteractiveMode(cfg) {
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient)
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, clients)
}
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient, output.NewPlainSink(os.Stdout))
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, clients, output.NewPlainSink(os.Stdout))
},
}
}
9 changes: 4 additions & 5 deletions internal/container/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (

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 {
func Status(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, clients map[config.EmulatorType]emulator.Client, sink output.Sink) error {
if err := rt.IsHealthy(ctx); err != nil {
rt.EmitUnhealthyError(sink, err)
return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err))
Expand Down Expand Up @@ -63,17 +63,16 @@ func Status(ctx context.Context, rt runtime.Runtime, containers []config.Contain

var version string
var rows []emulator.Resource
switch c.Type {
case config.EmulatorAWS:
if client, ok := clients[c.Type]; ok {
sink.Emit(output.SpinnerStart("Fetching LocalStack status"))
if v, err := emulatorClient.FetchVersion(ctx, host); err != nil {
if v, err := client.FetchVersion(ctx, host); err != nil {
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Could not fetch version: %v", err)})
} else {
version = v
}

var fetchErr error
rows, fetchErr = emulatorClient.FetchResources(ctx, host)
rows, fetchErr = client.FetchResources(ctx, host)
sink.Emit(output.SpinnerStop())
if fetchErr != nil {
return fetchErr
Expand Down
62 changes: 62 additions & 0 deletions internal/emulator/snowflake/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package snowflake

import (
"context"
"encoding/json"
"fmt"
"net/http"

"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

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

type Client struct {
http *http.Client
}

func NewClient() *Client {
return &Client{
http: &http.Client{
Transport: otelhttp.NewTransport(
http.DefaultTransport,
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
return "snowflake " + r.Method + " " + r.URL.Path
}),
),
},
}
}

type healthResponse struct {
Version string `json:"version"`
}

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
}

// FetchResources is a no-op for Snowflake — the emulator does not expose AWS-style resources.
func (c *Client) FetchResources(_ context.Context, _ string) ([]emulator.Resource, error) {
return nil, nil
}
51 changes: 51 additions & 0 deletions internal/emulator/snowflake/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package snowflake

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": "2026.5.0"}`)
}))
defer server.Close()

c := NewClient()
version, err := c.FetchVersion(context.Background(), server.Listener.Addr().String())
require.NoError(t, err)
assert.Equal(t, "2026.5.0", 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()
_, err := c.FetchVersion(context.Background(), server.Listener.Addr().String())
require.Error(t, err)
})
}

func TestFetchResources_AlwaysEmpty(t *testing.T) {
t.Parallel()
c := NewClient()
rows, err := c.FetchResources(context.Background(), "unused")
require.NoError(t, err)
assert.Empty(t, rows)
}
4 changes: 2 additions & 2 deletions internal/ui/run_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/localstack/lstk/internal/runtime"
)

func RunStatus(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, emulatorClient emulator.Client) error {
func RunStatus(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, clients map[config.EmulatorType]emulator.Client) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()

Expand All @@ -22,7 +22,7 @@ func RunStatus(parentCtx context.Context, rt runtime.Runtime, containers []confi
runErrCh := make(chan error, 1)

go func() {
err := container.Status(ctx, rt, containers, localStackHost, emulatorClient, output.NewTUISink(programSender{p: p}))
err := container.Status(ctx, rt, containers, localStackHost, clients, output.NewTUISink(programSender{p: p}))
if err != nil && !errors.Is(err, context.Canceled) {
p.Send(runErrMsg{err: err})
} else {
Expand Down
47 changes: 47 additions & 0 deletions test/integration/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package integration_test

import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -176,6 +178,36 @@ func TestStatusCommandForSnowflakeShowsNoResources(t *testing.T) {
assert.NotContains(t, stdout, "No resources deployed")
}

func TestStatusCommandForSnowflakeShowsVersion(t *testing.T) {
requireDocker(t)
_ = env.Require(t, env.AuthToken)

cleanup()
cleanupSnowflake()
t.Cleanup(cleanup)
t.Cleanup(cleanupSnowflake)

mockServer := createMockLicenseServer(true)
defer mockServer.Close()

const hostPort = "4566"
configFile := writeSnowflakeConfig(t, hostPort)

ctx := testContext(t)
_, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start")
require.NoError(t, err, "lstk start failed: %s", stderr)
requireExitCode(t, 0, err)

expectedVersion := fetchSnowflakeVersion(t, hostPort)

stdout, stderr, err := runLstk(t, ctx, "", testEnvWithHome(t.TempDir(), ""), "--config", configFile, "status")
require.NoError(t, err, "lstk status failed: %s", stderr)
requireExitCode(t, 0, err)

assert.Contains(t, stdout, "VERSION: "+expectedVersion,
"snowflake status should display the version reported by /_localstack/health")
}

func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) {
requireDocker(t)
cleanup()
Expand Down Expand Up @@ -204,3 +236,18 @@ func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) {
requireExitCode(t, 0, err)
assert.Contains(t, stdout, "No resources deployed")
}

func fetchSnowflakeVersion(t *testing.T, hostPort string) string {
t.Helper()
resp, err := http.Get(fmt.Sprintf("http://localhost:%s/_localstack/health", hostPort))
require.NoError(t, err, "failed to fetch snowflake health")
t.Cleanup(func() { _ = resp.Body.Close() })
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "failed to read snowflake health body")
var h struct {
Version string `json:"version"`
}
require.NoError(t, json.Unmarshal(body, &h), "failed to decode snowflake health: %s", body)
require.NotEmpty(t, h.Version, "snowflake health response missing version field")
return h.Version
}
Loading