diff --git a/Makefile b/Makefile index 24b023a2..b1e8c45d 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,9 @@ test: test-integration: $(BUILD_DIR)/$(BINARY_NAME) @JUNIT=""; [ -n "$$CREATE_JUNIT_REPORT" ] && JUNIT="--junitfile ../../test-integration-results.xml"; \ if [ "$$(uname)" = "Darwin" ]; then \ - cd test/integration && LSTK_KEYRING=file go run gotest.tools/gotestsum@latest --format testname $$JUNIT -- -count=1 $(if $(RUN),-run $(RUN)) ./...; \ + cd test/integration && LSTK_KEYRING=file go run gotest.tools/gotestsum@latest --format testname $$JUNIT -- -count=1 -timeout 15m $(if $(RUN),-run $(RUN)) ./...; \ else \ - cd test/integration && go run gotest.tools/gotestsum@latest --format testname $$JUNIT -- -count=1 $(if $(RUN),-run $(RUN)) ./...; \ + cd test/integration && go run gotest.tools/gotestsum@latest --format testname $$JUNIT -- -count=1 -timeout 15m $(if $(RUN),-run $(RUN)) ./...; \ fi otel: diff --git a/cmd/setup.go b/cmd/setup.go index 5463f994..8757c09b 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -14,7 +14,7 @@ func newSetupCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { cmd := &cobra.Command{ Use: "setup", Short: "Set up emulator CLI integration", - Long: "Set up emulator CLI integration (e.g., AWS, Snowflake, Azure). Currently only AWS is supported.", + Long: "Set up emulator CLI integration. Currently only AWS is supported.", } cmd.AddCommand(newSetupAWSCmd(cfg, tel)) return cmd diff --git a/internal/config/containers.go b/internal/config/containers.go index 896cc4a5..2fe6a322 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -26,11 +26,13 @@ var emulatorDisplayNames = map[EmulatorType]string{ } var emulatorImages = map[EmulatorType]string{ - EmulatorAWS: "localstack-pro", + EmulatorAWS: "localstack-pro", + EmulatorSnowflake: "snowflake", } var emulatorHealthPaths = map[EmulatorType]string{ - EmulatorAWS: "/_localstack/health", + EmulatorAWS: "/_localstack/health", + EmulatorSnowflake: "/_localstack/health", } type ContainerConfig struct { @@ -117,7 +119,7 @@ func (c *ContainerConfig) HealthPath() (string, error) { func (c *ContainerConfig) ContainerPort() (string, error) { switch c.Type { - case EmulatorAWS: + case EmulatorAWS, EmulatorSnowflake: return DefaultAWSPort + "/tcp", nil default: return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) @@ -138,4 +140,5 @@ func (c *ContainerConfig) ProductName() (string, error) { return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type) } return productName, nil -} \ No newline at end of file +} + diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index 45acb3bb..dbcaa9c0 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -2,9 +2,11 @@ # Run 'lstk config path' to see where this file lives. # Each [[containers]] block defines an emulator instance. -# You can define multiple to run them side by side. +# Currently, running AWS and Snowflake at the same time is not fully supported — +# enable only one [[containers]] block at a time. + [[containers]] -type = "aws" # Emulator type. Currently supported: "aws" +type = "aws" # Emulator type. Currently supported: "aws", "snowflake" tag = "latest" # Docker image tag, e.g. "latest", "2026.03" port = "4566" # Host port the emulator will be accessible on # volume = "" # Host directory for persistent state (default: OS cache dir) diff --git a/internal/container/label.go b/internal/container/label.go index 9e7f29fc..924551ff 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -31,6 +31,9 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container tag := c.Tag if tag == "" || tag == "latest" { + if c.Type == config.EmulatorSnowflake { + return "LocalStack", false + } apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) v, err := client.GetLatestCatalogVersion(apiCtx, string(c.Type)) cancel() diff --git a/internal/container/start.go b/internal/container/start.go index e3b84b1e..b23ddafe 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -149,17 +149,17 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) containers[i] = runtime.ContainerConfig{ - Image: image, - Name: containerName, - EmulatorType: string(c.Type), - Port: c.Port, - ContainerPort: containerPort, - HealthPath: healthPath, - Env: env, - Tag: c.Tag, - ProductName: productName, - Binds: binds, - ExtraPorts: servicePortRange(), + Image: image, + Name: containerName, + EmulatorType: string(c.Type), + Port: c.Port, + ContainerPort: containerPort, + HealthPath: healthPath, + Env: env, + Tag: c.Tag, + ProductName: productName, + Binds: binds, + ExtraPorts: servicePortRange(), } } @@ -235,22 +235,24 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf if err := setup(ctx, sink, interactive, resolvedHost); err != nil { return err } - emitPostStartPointers(sink, resolvedHost, webAppURL) + emitPostStartPointers(sink, resolvedHost, webAppURL, true) } } return nil } -func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string) { +func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string, showTip bool) { output.EmitSecondary(sink, fmt.Sprintf("• Endpoint: %s", resolvedHost)) if webAppURL != "" { output.EmitSecondary(sink, fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))) } - tips := []string{ - "> Tip: View emulator logs: lstk logs --follow", - "> Tip: View deployed resources: lstk status", + if showTip { + tips := []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: View deployed resources: lstk status", + } + output.EmitSecondary(sink, tips[rand.IntN(len(tips))]) } - output.EmitSecondary(sink, tips[rand.IntN(len(tips))]) } func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig) (map[string]bool, error) { @@ -291,6 +293,10 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel * func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts StartOptions, tel *telemetry.Client, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) { var needsPostPull []runtime.ContainerConfig for _, c := range containers { + if c.EmulatorType == string(config.EmulatorSnowflake) { + continue + } + if c.Tag != "" && c.Tag != "latest" { if err := validateLicense(ctx, sink, opts, tel, c, token, licenseFilePath); err != nil { return nil, err @@ -319,6 +325,10 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta // Fallback path: inspects each pulled image for its version, then validates the license. func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, tel *telemetry.Client, containers []runtime.ContainerConfig, token, licenseFilePath string) error { for _, c := range containers { + if c.EmulatorType == string(config.EmulatorSnowflake) { + continue + } + v, err := rt.GetImageVersion(ctx, c.Image) if err != nil { return fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) @@ -369,7 +379,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu if !dnsOK { output.EmitNote(sink, endpoint.DNSRebindNote) } - emitPostStartPointers(sink, resolvedHost, webAppURL) + emitPostStartPointers(sink, resolvedHost, webAppURL, c.EmulatorType == string(config.EmulatorAWS)) continue } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 27b672ef..8b7d52c7 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -36,7 +36,7 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/") + emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", true) got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") @@ -48,7 +48,7 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, "127.0.0.1:4566", "") + emitPostStartPointers(sink, "127.0.0.1:4566", "", true) got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n") @@ -138,6 +138,18 @@ func TestSelectContainersToStart_QueuesContainerWhenNoneRunningOnPort(t *testing assert.Equal(t, []runtime.ContainerConfig{c}, result, "container should be queued for start") } +func TestEmitPostStartPointers_NoTip(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + + got := out.String() + assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") + assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") + assert.NotContains(t, got, "> Tip:") +} + func TestServicePortRange_ReturnsExpectedPorts(t *testing.T) { ports := servicePortRange() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index e74336a0..424b0eb5 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -25,9 +25,9 @@ type PortMapping struct { type ContainerConfig struct { Image string Name string - EmulatorType string // e.g., "aws", "snowflake" — used for telemetry + EmulatorType string // e.g., "aws", "snowflake" — used for telemetry Port string - ContainerPort string // internal port the emulator listens on inside the container (e.g. "4566/tcp") + ContainerPort string // internal port the emulator listens on inside the container (e.g. "4566/tcp") HealthPath string Env []string // e.g., ["KEY=value", "FOO=bar"] Tag string diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 2c5e0717..3d7f90ba 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -230,6 +230,23 @@ func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, ho }) } +func startTestSnowflakeContainer(t *testing.T, ctx context.Context) { + t.Helper() + + reader, err := dockerClient.ImagePull(ctx, testImage, image.PullOptions{}) + require.NoError(t, err, "failed to pull test image") + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + + resp, err := dockerClient.ContainerCreate(ctx, &container.Config{ + Image: testImage, + Cmd: []string{"sleep", "infinity"}, + }, nil, nil, nil, snowflakeContainerName) + require.NoError(t, err, "failed to create snowflake test container") + err = dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}) + require.NoError(t, err, "failed to start snowflake test container") +} + func testContext(t *testing.T) context.Context { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 5638963c..d6769282 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/require" ) +const snowflakeContainerName = "localstack-snowflake" + func TestStartCommandSucceedsWithValidToken(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) @@ -441,3 +443,73 @@ func cleanup() { _ = dockerClient.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true}) _ = DeleteAuthTokenFromKeyring() } + +func cleanupSnowflake() { + ctx := context.Background() + _ = dockerClient.ContainerStop(ctx, snowflakeContainerName, container.StopOptions{}) + _ = dockerClient.ContainerRemove(ctx, snowflakeContainerName, container.RemoveOptions{Force: true}) +} + +func writeSnowflakeConfig(t *testing.T, hostPort string) string { + t.Helper() + content := fmt.Sprintf(` +[[containers]] +type = "snowflake" +tag = "latest" +port = %q +`, hostPort) + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(content), 0644)) + return configFile +} + +func TestStartCommandForSnowflakeSkipsLicenseValidation(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + cleanupSnowflake() + t.Cleanup(cleanup) + t.Cleanup(cleanupSnowflake) + + // Mock server that rejects all license requests — this would cause lstk start to fail for AWS. + mockServer := createMockLicenseServer(false) + defer mockServer.Close() + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", writeSnowflakeConfig(t, "4566"), "start") + require.NoError(t, err, "lstk start should succeed for snowflake even when the license server rejects the request: %s", stderr) + requireExitCode(t, 0, err) +} + +func TestStartCommandSucceedsForSnowflake(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) + + inspect, err := dockerClient.ContainerInspect(ctx, snowflakeContainerName) + require.NoError(t, err, "failed to inspect snowflake container") + require.True(t, inspect.State.Running, "snowflake container should be running") + assert.Contains(t, inspect.Config.Image, "localstack/snowflake", + "expected localstack/snowflake image, got %s", inspect.Config.Image) + + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/_localstack/health", hostPort)) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/test/integration/status_test.go b/test/integration/status_test.go index c86f08d3..69f4b868 100644 --- a/test/integration/status_test.go +++ b/test/integration/status_test.go @@ -155,6 +155,25 @@ func TestStatusCommandWorksWithExternalContainer(t *testing.T) { assert.Contains(t, stdout, "3.5.0") } +func TestStatusCommandForSnowflakeShowsNoResources(t *testing.T) { + requireDocker(t) + cleanupSnowflake() + t.Cleanup(cleanupSnowflake) + + ctx := testContext(t) + startTestSnowflakeContainer(t, ctx) + + stdout, stderr, err := runLstk(t, ctx, "", nil, "--config", writeSnowflakeConfig(t, "4566"), "status") + require.NoError(t, err, "lstk status failed for snowflake: %s", stderr) + requireExitCode(t, 0, err) + + assert.Contains(t, stdout, "Snowflake") + assert.Contains(t, stdout, "running") + // Snowflake does not expose AWS resources — no resource table or empty-state message. + assert.NotContains(t, stdout, "SERVICE") + assert.NotContains(t, stdout, "No resources deployed") +} + func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) { requireDocker(t) cleanup()