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
3 changes: 3 additions & 0 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
}
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return dockerNotAvailableError(err)
}
if err := checkRuntimeHealth(cmd.Context(), rt); err != nil {
return err
}
appConfig, err := config.Get()
Expand Down
27 changes: 26 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
return dockerNotAvailableError(err)
}
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
},
Expand Down Expand Up @@ -208,6 +208,31 @@ func isInteractiveMode(cfg *env.Env) bool {
return !cfg.NonInteractive && ui.IsInteractive()
}

// dockerNotAvailableError emits a styled Docker-not-available error and
// returns a SilentError to suppress duplicate error printing.
func dockerNotAvailableError(err error) error {
output.EmitError(output.NewColorSink(os.Stdout), output.ErrorEvent{
Title: "Docker is not available",
Summary: err.Error(),
Actions: []output.ErrorAction{
{Label: "See help:", Value: "lstk -h"},
{Label: "Install Docker:", Value: "https://docs.docker.com/get-docker/"},
},
})
return output.NewSilentError(err)
}

// checkRuntimeHealth checks if the runtime is healthy and emits an error
// through the sink if not in interactive mode. Returns a SilentError to
// suppress duplicate error printing.
func checkRuntimeHealth(ctx context.Context, rt runtime.Runtime) error {
if err := rt.IsHealthy(ctx); err != nil {
rt.EmitUnhealthyError(output.NewColorSink(os.Stdout), err)
return output.NewSilentError(err)
}
return nil
}

Comment thread
gtsiolis marked this conversation as resolved.
const maxLogSize = 1 << 20 // 1 MB

func newLogger() (log.Logger, func(), error) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return err
return dockerNotAvailableError(err)
}
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
},
Expand Down
4 changes: 3 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ func newStatusCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
RunE: commandWithTelemetry("status", tel, func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return dockerNotAvailableError(err)
}
Comment thread
gtsiolis marked this conversation as resolved.
if err := checkRuntimeHealth(cmd.Context(), rt); err != nil {
return err
}
Comment thread
gtsiolis marked this conversation as resolved.

appCfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
Expand Down
3 changes: 3 additions & 0 deletions cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
startTime := time.Now()
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
if err != nil {
return dockerNotAvailableError(err)
}
if err := checkRuntimeHealth(cmd.Context(), rt); err != nil {
return err
}
Comment thread
gtsiolis marked this conversation as resolved.
Comment thread
gtsiolis marked this conversation as resolved.
appConfig, err := config.Get()
Expand Down
51 changes: 51 additions & 0 deletions internal/output/color_format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package output

import (
"strings"

"github.com/charmbracelet/lipgloss"
)

var (
colorErrorTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#C33820"))
colorErrorSecondary = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
colorErrorDetail = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
colorErrorAction = lipgloss.NewStyle().Foreground(lipgloss.Color("69"))
colorErrorMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
)

// FormatColorEventLine is like FormatEventLine but renders ErrorEvent with ANSI color.
// All other event types delegate to FormatEventLine.
func FormatColorEventLine(event any) (string, bool) {
if e, ok := event.(ErrorEvent); ok {
return formatColorErrorEvent(e), true
}
return FormatEventLine(event)
}

func formatColorErrorEvent(e ErrorEvent) string {
var sb strings.Builder
sb.WriteString(colorErrorTitle.Render("✗ " + e.Title))
if e.Summary != "" {
sb.WriteString("\n")
sb.WriteString(colorErrorSecondary.Render("> "))
sb.WriteString(e.Summary)
}
if e.Detail != "" {
sb.WriteString("\n ")
sb.WriteString(colorErrorDetail.Render(e.Detail))
}
if len(e.Actions) > 0 {
sb.WriteString("\n")
for i, action := range e.Actions {
sb.WriteString("\n")
if i > 0 {
sb.WriteString(colorErrorMuted.Render(ErrorActionPrefix + action.Label + " " + action.Value))
} else {
sb.WriteString(colorErrorAction.Render(ErrorActionPrefix+action.Label+" "))
sb.WriteString(lipgloss.NewStyle().Bold(true).Render(action.Value))
}
}
}
return sb.String()
}
65 changes: 65 additions & 0 deletions internal/output/color_sink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package output

import (
"fmt"
"io"
"os"

"golang.org/x/term"
)

// ColorSink emits events like PlainSink but renders ErrorEvent with ANSI color
// when the output is a TTY and NO_COLOR is not set.
type ColorSink struct {
out io.Writer
colored bool
err error
}

// NewColorSink returns a ColorSink. Color is enabled only when out is a real
// terminal and the NO_COLOR environment variable is unset.
func NewColorSink(out io.Writer) *ColorSink {
if out == nil {
out = os.Stdout
}
return &ColorSink{out: out, colored: isColorTerminal(out)}
}

func (s *ColorSink) Err() error {
return s.err
}

func (s *ColorSink) setErr(err error) {
if s.err == nil && err != nil {
s.err = err
}
}

func (s *ColorSink) emit(event any) {
var (
line string
ok bool
)
if s.colored {
line, ok = FormatColorEventLine(event)
} else {
line, ok = FormatEventLine(event)
}
if !ok {
return
}
_, err := fmt.Fprintln(s.out, line)
s.setErr(err)
}

// isColorTerminal reports whether w is a TTY and NO_COLOR is unset.
func isColorTerminal(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
71 changes: 71 additions & 0 deletions internal/output/color_sink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package output

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
)

func TestColorSink_NoColor_FallsBackToPlain(t *testing.T) {
t.Setenv("NO_COLOR", "1")

var out bytes.Buffer
sink := NewColorSink(&out)
assert.False(t, sink.colored)

Emit(sink, ErrorEvent{
Title: "Connection failed",
Summary: "Cannot connect to Docker",
Actions: []ErrorAction{{Label: "Start Docker:", Value: "open -a Docker"}},
})

expected := "Error: Connection failed\n Cannot connect to Docker\n ==> Start Docker: open -a Docker\n"
assert.Equal(t, expected, out.String())
}

func TestColorSink_NonTTY_FallsBackToPlain(t *testing.T) {
// bytes.Buffer is not an *os.File, so isColorTerminal returns false.
var out bytes.Buffer
sink := NewColorSink(&out)
assert.False(t, sink.colored)

Emit(sink, ErrorEvent{Title: "Something broke"})
assert.Equal(t, "Error: Something broke\n", out.String())
}

func TestColorSink_ColoredMode_RendersErrorWithMarkers(t *testing.T) {
// Force colored mode directly to test rendering without a real TTY.
var out bytes.Buffer
sink := &ColorSink{out: &out, colored: true}

Emit(sink, ErrorEvent{
Title: "Docker is not available",
Summary: "cannot connect to daemon",
Actions: []ErrorAction{
{Label: "Start Docker:", Value: "open -a Docker"},
{Label: "Install Docker:", Value: "https://docs.docker.com/get-docker/"},
},
})

got := out.String()
assert.Contains(t, got, "Docker is not available")
assert.Contains(t, got, "cannot connect to daemon")
assert.Contains(t, got, "Start Docker:")
assert.Contains(t, got, "open -a Docker")
assert.Contains(t, got, "Install Docker:")
}

func TestColorSink_NonErrorEvents_DelegateToPlain(t *testing.T) {
var out bytes.Buffer
sink := &ColorSink{out: &out, colored: true}

EmitInfo(sink, "hello world")
assert.Equal(t, "hello world\n", out.String())
}

func TestIsColorTerminal_NoColorEnv(t *testing.T) {
t.Setenv("NO_COLOR", "1")
var out bytes.Buffer
assert.False(t, isColorTerminal(&out))
}
64 changes: 64 additions & 0 deletions test/integration/docker_unhealthy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package integration_test

import (
"runtime"
"testing"

"github.com/localstack/lstk/test/integration/env"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// unhealthyDockerEnv returns an environment with DOCKER_HOST pointing to a
// non-existent Unix socket so that the Docker health check fails.
func unhealthyDockerEnv() env.Environ {
return env.With(env.Key("DOCKER_HOST"), "unix:///var/run/docker-does-not-exist.sock")
}

func TestStartShowsDockerErrorWhenUnhealthy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket test")
}

stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "start")
require.Error(t, err)
requireExitCode(t, 1, err)
assert.Contains(t, stdout, "Docker is not available")
assert.Contains(t, stdout, "Install Docker:")
}

func TestStopShowsDockerErrorWhenUnhealthy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket test")
}

stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "stop")
require.Error(t, err)
requireExitCode(t, 1, err)
assert.Contains(t, stdout, "Docker is not available")
assert.Contains(t, stdout, "Install Docker:")
}

func TestStatusShowsDockerErrorWhenUnhealthy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket test")
}

stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "status")
require.Error(t, err)
requireExitCode(t, 1, err)
assert.Contains(t, stdout, "Docker is not available")
assert.Contains(t, stdout, "Install Docker:")
}

func TestLogsShowsDockerErrorWhenUnhealthy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket test")
}

stdout, _, err := runLstk(t, testContext(t), "", unhealthyDockerEnv(), "logs")
require.Error(t, err)
requireExitCode(t, 1, err)
assert.Contains(t, stdout, "Docker is not available")
assert.Contains(t, stdout, "Install Docker:")
}
Loading