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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}

6 changes: 4 additions & 2 deletions internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thanks for updating the config! 😁

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)
Expand Down
3 changes: 3 additions & 0 deletions internal/container/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
46 changes: 28 additions & 18 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
16 changes: 14 additions & 2 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
19 changes: 19 additions & 0 deletions test/integration/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down