From 3da8e1db3e9127cdb5e44fba78e4544af49d02e2 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Wed, 6 May 2026 14:48:28 +0200 Subject: [PATCH] add snowflake version in status output --- cmd/status.go | 11 ++-- internal/container/status.go | 9 ++-- internal/emulator/snowflake/client.go | 62 ++++++++++++++++++++++ internal/emulator/snowflake/client_test.go | 51 ++++++++++++++++++ internal/ui/run_status.go | 4 +- test/integration/status_test.go | 47 ++++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 internal/emulator/snowflake/client.go create mode 100644 internal/emulator/snowflake/client_test.go diff --git a/cmd/status.go b/cmd/status.go index ba2877b2..75091b11 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -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" @@ -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)) }, } } diff --git a/internal/container/status.go b/internal/container/status.go index bc31ba8b..6e18696c 100644 --- a/internal/container/status.go +++ b/internal/container/status.go @@ -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)) @@ -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 diff --git a/internal/emulator/snowflake/client.go b/internal/emulator/snowflake/client.go new file mode 100644 index 00000000..c90c8ace --- /dev/null +++ b/internal/emulator/snowflake/client.go @@ -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 +} diff --git a/internal/emulator/snowflake/client_test.go b/internal/emulator/snowflake/client_test.go new file mode 100644 index 00000000..6366b4bc --- /dev/null +++ b/internal/emulator/snowflake/client_test.go @@ -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) +} diff --git a/internal/ui/run_status.go b/internal/ui/run_status.go index d5255919..afa75feb 100644 --- a/internal/ui/run_status.go +++ b/internal/ui/run_status.go @@ -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() @@ -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 { diff --git a/test/integration/status_test.go b/test/integration/status_test.go index b3df9130..b9d1e595 100644 --- a/test/integration/status_test.go +++ b/test/integration/status_test.go @@ -2,7 +2,9 @@ package integration_test import ( "context" + "encoding/json" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -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() @@ -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 +}