Skip to content
Open
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
16 changes: 13 additions & 3 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,10 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou
func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error {
for _, c := range containers {
startTime := time.Now()
sink.Emit(output.ContainerStatusEvent{Phase: "starting", Container: c.Name})
sink.Emit(output.SpinnerStart("Starting LocalStack"))
containerID, err := rt.Start(ctx, c)
if err != nil {
sink.Emit(output.SpinnerStop())
tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{
EventType: telemetry.LifecycleStartError,
Emulator: c.EmulatorType,
Expand All @@ -379,9 +380,9 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
return fmt.Errorf("failed to start LocalStack: %w", err)
}

sink.Emit(output.ContainerStatusEvent{Phase: "waiting", Container: c.Name})
healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath)
if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil {
sink.Emit(output.SpinnerStop())
errCode := telemetry.ErrCodeStartFailed
var licErr *licenseNotCoveredError
if errors.As(err, &licErr) && c.EmulatorType == config.EmulatorSnowflake {
Expand All @@ -404,6 +405,7 @@ func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink,
})
return err
}
sink.Emit(output.SpinnerStop())

sink.Emit(output.ContainerStatusEvent{Phase: "ready", Container: c.Name, Detail: fmt.Sprintf("containerId: %s", containerID[:12])})

Expand Down Expand Up @@ -549,7 +551,7 @@ func emitPortInUseError(sink output.Sink, port string) {

func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, containerConfig runtime.ContainerConfig, token, licenseFilePath string) error {
version := containerConfig.Tag
sink.Emit(output.ContainerStatusEvent{Phase: "validating license", Container: containerConfig.Name})
sink.Emit(output.SpinnerStart("Checking license"))

hostname, _ := os.Hostname()
licenseReq := &api.LicenseRequest{
Expand All @@ -569,6 +571,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c

licenseResp, err := opts.PlatformClient.GetLicense(ctx, licenseReq)
if err != nil {
sink.Emit(output.SpinnerStop())
var licErr *api.LicenseError
if errors.As(err, &licErr) && licErr.Detail != "" {
opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail)
Expand All @@ -582,6 +585,13 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c
})
return fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err)
}
sink.Emit(output.SpinnerStop())

validMsg := "Valid license"
if plan := licenseResp.PlanDisplayName(); plan != "" {
validMsg = fmt.Sprintf("Valid license (%s)", plan)
}
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: validMsg})

if licenseResp != nil && len(licenseResp.RawBytes) > 0 {
if err := os.MkdirAll(filepath.Dir(licenseFilePath), 0755); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ func formatStatusLine(e ContainerStatusEvent) (string, bool) {
return "Waiting for LocalStack to be ready...", true
case "ready":
if e.Detail != "" {
return fmt.Sprintf("%s LocalStack ready (%s)", SuccessMarker(), e.Detail), true
return fmt.Sprintf("%s LocalStack is running (%s)", SuccessMarker(), e.Detail), true
}
return SuccessMarker() + " LocalStack ready", true
return SuccessMarker() + " LocalStack is running", true
default:
if e.Detail != "" {
return fmt.Sprintf("LocalStack: %s (%s)", e.Phase, e.Detail), true
Expand Down
2 changes: 1 addition & 1 deletion internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestFormatEventLine(t *testing.T) {
{
name: "status ready with detail",
event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws", Detail: "abc123"},
want: SuccessMarker() + " LocalStack ready (abc123)",
want: SuccessMarker() + " LocalStack is running (abc123)",
wantOK: true,
},
{
Expand Down
4 changes: 2 additions & 2 deletions internal/output/plain_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ func TestPlainSink_EmitsStatusEvent(t *testing.T) {
{
name: "ready phase with detail",
event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws", Detail: "abc123"},
expected: fmt.Sprintf("%s LocalStack ready (abc123)\n", SuccessMarker()),
expected: fmt.Sprintf("%s LocalStack is running (abc123)\n", SuccessMarker()),
},
{
name: "ready phase without detail",
event: ContainerStatusEvent{Phase: "ready", Container: "localstack-aws"},
expected: fmt.Sprintf("%s LocalStack ready\n", SuccessMarker()),
expected: fmt.Sprintf("%s LocalStack is running\n", SuccessMarker()),
},
{
name: "unknown phase with detail",
Expand Down
8 changes: 6 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.spinner = a.spinner.Start(msg.Text, msg.MinDuration)
return a, a.spinner.Tick()
}
if a.pullProgress.Visible() {
a.pullProgress = a.pullProgress.Hide()
}
var cmd tea.Cmd
a.spinner, cmd = a.spinner.Stop()
if !a.spinner.PendingStop() {
Expand All @@ -209,7 +212,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.headerLoading = false
}
a.errorDisplay = a.errorDisplay.Show(msg)
a.spinner, _ = a.spinner.Stop()
a.spinner = a.spinner.ForceStop()
a.flushBufferedLines()
return a, nil
case output.MessageEvent:
msgCopy := msg
Expand Down Expand Up @@ -267,7 +271,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
case runErrMsg:
a.err = msg.err
a.spinner, _ = a.spinner.Stop()
a.spinner = a.spinner.ForceStop()
a.flushBufferedLines()
if !output.IsSilent(msg.err) {
a.errorDisplay = a.errorDisplay.Show(output.ErrorEvent{Title: msg.err.Error()})
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/components/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ func (s Spinner) Stop() (Spinner, tea.Cmd) {
})
}

// ForceStop clears the spinner immediately, ignoring the min-duration smoothing
// that Stop applies. Use this on error or terminal paths where a soft stop would
// leave a stale frame on the final render.
func (s Spinner) ForceStop() Spinner {
s.visible = false
s.pendingStop = false
return s
}

func (s Spinner) PendingStop() bool {
return s.pendingStop
}
Expand Down
2 changes: 1 addition & 1 deletion test/integration/awsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestStartSkipsAWSProfilePromptWhenAlreadyConfigured(t *testing.T) {
// Wait until the container is ready — that's the point at which post-start setup
// runs, so if the prompt were going to appear it would already be in the output.
require.Eventually(t, func() bool {
return bytes.Contains(out.Bytes(), []byte(" ready"))
return bytes.Contains(out.Bytes(), []byte("LocalStack is running"))
}, 2*time.Minute, 200*time.Millisecond, "container should become ready")

_ = cmd.Process.Kill()
Expand Down
4 changes: 2 additions & 2 deletions test/integration/version_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ func TestVersionResolvedViaCatalog(t *testing.T) {
"license request should carry the version returned by the catalog API")
assert.NotEqual(t, "latest", *capturedVersion,
"license request should not use the unresolved 'latest' tag")
assert.Contains(t, stdout, "LocalStack: validating license")
assert.NotContains(t, stdout, "validating license (4.14.0)")
assert.Contains(t, stdout, "Checking license")
assert.NotContains(t, stdout, "(4.14.0)")
}

// Verifies that when the catalog endpoint is unavailable, the version is resolved
Expand Down
Loading