diff --git a/internal/container/start.go b/internal/container/start.go index 6dc6d613..3360bdb5 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -19,6 +19,7 @@ import ( "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/awsconfig" "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/emulator/snowflake" "github.com/localstack/lstk/internal/endpoint" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/output" @@ -202,16 +203,17 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf } } for _, t := range uniqueEmulatorTypes { + c := firstByType[t] + resolvedHost, dnsOK := endpoint.ResolveHost(c.Port, localStackHost) + if !dnsOK { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) + } if setup, ok := setups[t]; ok { - resolvedHost, dnsOK := endpoint.ResolveHost(firstByType[t].Port, localStackHost) - if !dnsOK { - sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) - } if err := setup(ctx, sink, interactive, resolvedHost); err != nil { return err } - emitPostStartPointers(sink, resolvedHost, webAppURL, true) } + emitPostStartPointers(sink, t, resolvedHost, webAppURL) } return nil } @@ -222,21 +224,37 @@ func emitAlreadyRunning(sink output.Sink, c runtime.ContainerConfig, localStackH if !dnsOK { sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) } - emitPostStartPointers(sink, resolvedHost, webAppURL, c.EmulatorType == config.EmulatorAWS) + emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL) } -func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string, showTip bool) { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)}) +func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, resolvedHost, webAppURL string) { + if sfHost := snowflake.Endpoint(resolvedHost); emulatorType == config.EmulatorSnowflake && sfHost != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Snowflake endpoint: %s", sfHost)}) + } else { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)}) + } if webAppURL != "" { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))}) } - if showTip { - tips := []string{ + if tips := tipsForType(emulatorType); len(tips) > 0 { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]}) + } +} + +func tipsForType(t config.EmulatorType) []string { + switch t { + case config.EmulatorAWS: + return []string{ "> Tip: View emulator logs: lstk logs --follow", "> Tip: View deployed resources: lstk status", } - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]}) + case config.EmulatorSnowflake: + return []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: Check emulator status: lstk status", + } } + return nil } func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig) (map[string]bool, error) { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index a628cf0f..07373a28 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -38,25 +38,54 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", true) + emitPostStartPointers(sink, config.EmulatorAWS, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/") 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.Contains(t, got, "> Tip:") + assert.NotContains(t, got, "• Snowflake endpoint:", + "AWS path must not show the snowflake-prefixed endpoint") } func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, "127.0.0.1:4566", "", true) + emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "") got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n") assert.Contains(t, got, "> Tip:") } +func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/") + + got := out.String() + assert.Contains(t, got, "• Snowflake endpoint: http://snowflake.localhost.localstack.cloud:4566\n") + assert.NotContains(t, got, "• Endpoint: localhost.localstack.cloud:4566", + "Snowflake should not show the bare endpoint — clients connect via the snowflake-prefixed host") + assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") + assert.Contains(t, got, "> Tip:") +} + +func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, config.EmulatorSnowflake, "127.0.0.1:4566", "") + + got := out.String() + assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n", + "falls back to bare endpoint when snowflake. would be invalid") + assert.NotContains(t, got, "• Snowflake endpoint:") + assert.Contains(t, got, "> Tip:") +} + func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) @@ -175,11 +204,11 @@ func TestSelectContainersToStart_ErrorsOnEmulatorTypeMismatch(t *testing.T) { assert.Contains(t, got, "docker stop localstack-aws") } -func TestEmitPostStartPointers_NoTip(t *testing.T) { +func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") diff --git a/internal/emulator/snowflake/endpoint.go b/internal/emulator/snowflake/endpoint.go new file mode 100644 index 00000000..3da43c4b --- /dev/null +++ b/internal/emulator/snowflake/endpoint.go @@ -0,0 +1,15 @@ +package snowflake + +import "net" + +func Endpoint(resolvedHost string) string { + host, _, err := net.SplitHostPort(resolvedHost) + if err != nil { + return "" + } + if net.ParseIP(host) != nil { + // Returns "" when resolvedHost is an IP, since prepending a subdomain to an IP is invalid. + return "" + } + return "http://snowflake." + resolvedHost +} diff --git a/internal/emulator/snowflake/endpoint_test.go b/internal/emulator/snowflake/endpoint_test.go new file mode 100644 index 00000000..d32da3dd --- /dev/null +++ b/internal/emulator/snowflake/endpoint_test.go @@ -0,0 +1,25 @@ +package snowflake + +import "testing" + +func TestEndpoint(t *testing.T) { + tests := []struct { + name string + resolvedHost string + want string + }{ + {"hostname with port", "localhost.localstack.cloud:4566", "http://snowflake.localhost.localstack.cloud:4566"}, + {"custom port", "localhost.localstack.cloud:4567", "http://snowflake.localhost.localstack.cloud:4567"}, + {"ipv4 host", "127.0.0.1:4566", ""}, + {"ipv6 host", "[::1]:4566", ""}, + {"missing port", "localhost.localstack.cloud", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Endpoint(tt.resolvedHost); got != tt.want { + t.Errorf("Endpoint(%q) = %q, want %q", tt.resolvedHost, got, tt.want) + } + }) + } +} diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 41e08d4a..c0f83113 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -621,7 +621,7 @@ func TestStartCommandSucceedsForSnowflake(t *testing.T) { configFile := writeSnowflakeConfig(t, hostPort) ctx := testContext(t) - _, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") + stdout, 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) @@ -635,4 +635,11 @@ func TestStartCommandSucceedsForSnowflake(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Contains(t, stdout, "• Snowflake endpoint: http://snowflake.", + "snowflake start should print the snowflake-prefixed endpoint hint") + assert.NotContains(t, stdout, "• Endpoint: localhost.localstack.cloud", + "snowflake start should not print the bare AWS-style endpoint line") + assert.Contains(t, stdout, "> Tip:", + "snowflake start should print a tip line like AWS does") }