Skip to content

Commit 75d0acc

Browse files
log info/error to file
1 parent 2ce11ae commit 75d0acc

File tree

17 files changed

+275
-40
lines changed

17 files changed

+275
-40
lines changed

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
3232
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering
3333
- `ui/` - Bubble Tea views for interactive output
3434
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction
35+
- `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that)
36+
37+
# Logging
38+
39+
lstk always writes diagnostic logs to `$CONFIG_DIR/lstk.log` (appends across runs, truncated at 1 MB). Two log levels: `Info` and `Error`.
40+
41+
- `log.Logger` is injected as a dependency (via `StartOptions` or constructor params). Use `log.Nop()` in tests.
42+
- This is separate from `output.Sink` — the logger is for internal diagnostics, the sink is for user-facing output.
3543

3644
# Configuration
3745

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ DEBUG = "1"
131131
lstk --non-interactive
132132
```
133133

134+
## Logging
135+
136+
`lstk` writes diagnostic logs to `$CONFIG_DIR/lstk.log`. The log file appends across runs and is automatically truncated when it exceeds 1 MB. To find the log file location, check the directory returned by `lstk config path`.
137+
134138
## Environment Variables
135139

136140
| Variable | Description |

cmd/help_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import (
77
"testing"
88

99
"github.com/localstack/lstk/internal/env"
10+
"github.com/localstack/lstk/internal/log"
1011
"github.com/localstack/lstk/internal/telemetry"
1112
)
1213

1314
func executeWithArgs(t *testing.T, args ...string) (string, error) {
1415
t.Helper()
1516
buf := new(bytes.Buffer)
16-
cmd := NewRootCmd(&env.Env{}, telemetry.New("", true))
17+
cmd := NewRootCmd(&env.Env{}, telemetry.New("", true), log.Nop())
1718
cmd.SetOut(buf)
1819
cmd.SetErr(buf)
1920
cmd.SetArgs(args)

cmd/login.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import (
55

66
"github.com/localstack/lstk/internal/api"
77
"github.com/localstack/lstk/internal/env"
8+
"github.com/localstack/lstk/internal/log"
89
"github.com/localstack/lstk/internal/ui"
910
"github.com/localstack/lstk/internal/version"
1011
"github.com/spf13/cobra"
1112
)
1213

13-
func newLoginCmd(cfg *env.Env) *cobra.Command {
14+
func newLoginCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
1415
return &cobra.Command{
1516
Use: "login",
1617
Short: "Manage login",
@@ -21,7 +22,7 @@ func newLoginCmd(cfg *env.Env) *cobra.Command {
2122
return fmt.Errorf("login requires an interactive terminal")
2223
}
2324
platformClient := api.NewPlatformClient(cfg.APIEndpoint)
24-
return ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL)
25+
return ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger)
2526
},
2627
}
2728
}

cmd/logout.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import (
1010
"github.com/localstack/lstk/internal/config"
1111
"github.com/localstack/lstk/internal/container"
1212
"github.com/localstack/lstk/internal/env"
13+
"github.com/localstack/lstk/internal/log"
1314
"github.com/localstack/lstk/internal/output"
1415
"github.com/localstack/lstk/internal/runtime"
1516
"github.com/localstack/lstk/internal/ui"
1617
"github.com/spf13/cobra"
1718
)
1819

19-
func newLogoutCmd(cfg *env.Env) *cobra.Command {
20+
func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
2021
return &cobra.Command{
2122
Use: "logout",
2223
Short: "Remove stored authentication credentials",
@@ -31,12 +32,13 @@ func newLogoutCmd(cfg *env.Env) *cobra.Command {
3132
if dockerRuntime, err := runtime.NewDockerRuntime(); err == nil {
3233
rt = dockerRuntime
3334
}
35+
3436
if isInteractiveMode(cfg) {
35-
return ui.RunLogout(cmd.Context(), rt, platformClient, cfg.AuthToken, cfg.ForceFileKeyring, appConfig.Containers)
37+
return ui.RunLogout(cmd.Context(), rt, platformClient, cfg.AuthToken, cfg.ForceFileKeyring, appConfig.Containers, logger)
3638
}
3739

3840
sink := output.NewPlainSink(os.Stdout)
39-
tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring)
41+
tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger)
4042
if err != nil {
4143
return fmt.Errorf("failed to initialize token storage: %w", err)
4244
}

cmd/non_interactive_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import (
44
"testing"
55

66
"github.com/localstack/lstk/internal/env"
7+
"github.com/localstack/lstk/internal/log"
78
"github.com/localstack/lstk/internal/telemetry"
89
)
910

1011
func TestNonInteractiveFlagIsRegistered(t *testing.T) {
11-
root := NewRootCmd(&env.Env{}, telemetry.New("", true))
12+
root := NewRootCmd(&env.Env{}, telemetry.New("", true), log.Nop())
1213

1314
flag := root.PersistentFlags().Lookup("non-interactive")
1415
if flag == nil {
@@ -46,7 +47,7 @@ func TestNonInteractiveFlagAppearsInStopHelp(t *testing.T) {
4647

4748
func TestNonInteractiveFlagBindsToCfg(t *testing.T) {
4849
cfg := &env.Env{}
49-
root := NewRootCmd(cfg, telemetry.New("", true))
50+
root := NewRootCmd(cfg, telemetry.New("", true), log.Nop())
5051
root.SetArgs([]string{"--non-interactive", "version"})
5152
_ = root.Execute()
5253

@@ -57,7 +58,7 @@ func TestNonInteractiveFlagBindsToCfg(t *testing.T) {
5758

5859
func TestNonInteractiveFlagDefaultIsOff(t *testing.T) {
5960
cfg := &env.Env{}
60-
root := NewRootCmd(cfg, telemetry.New("", true))
61+
root := NewRootCmd(cfg, telemetry.New("", true), log.Nop())
6162
root.SetArgs([]string{"version"})
6263
_ = root.Execute()
6364

cmd/root.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"path/filepath"
78

89
"github.com/localstack/lstk/internal/api"
910
"github.com/localstack/lstk/internal/config"
1011
"github.com/localstack/lstk/internal/container"
1112
"github.com/localstack/lstk/internal/env"
13+
"github.com/localstack/lstk/internal/log"
1214
"github.com/localstack/lstk/internal/output"
1315
"github.com/localstack/lstk/internal/runtime"
1416
"github.com/localstack/lstk/internal/telemetry"
@@ -17,7 +19,7 @@ import (
1719
"github.com/spf13/cobra"
1820
)
1921

20-
func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
22+
func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
2123
root := &cobra.Command{
2224
Use: "lstk",
2325
Short: "LocalStack CLI",
@@ -28,7 +30,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
2830
if err != nil {
2931
return err
3032
}
31-
return runStart(cmd.Context(), rt, cfg, tel)
33+
return runStart(cmd.Context(), rt, cfg, tel, logger)
3234
},
3335
}
3436

@@ -46,10 +48,10 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
4648
root.Flags().Lookup("version").Usage = "Show version"
4749

4850
root.AddCommand(
49-
newStartCmd(cfg, tel),
51+
newStartCmd(cfg, tel, logger),
5052
newStopCmd(cfg),
51-
newLoginCmd(cfg),
52-
newLogoutCmd(cfg),
53+
newLoginCmd(cfg, logger),
54+
newLogoutCmd(cfg, logger),
5355
newLogsCmd(),
5456
newConfigCmd(),
5557
newVersionCmd(),
@@ -64,7 +66,10 @@ func Execute(ctx context.Context) error {
6466
tel := telemetry.New(cfg.AnalyticsEndpoint, cfg.DisableEvents)
6567
defer tel.Close()
6668

67-
root := NewRootCmd(cfg, tel)
69+
logger, cleanup := newLogger()
70+
defer cleanup()
71+
72+
root := NewRootCmd(cfg, tel, logger)
6873
root.SilenceErrors = true
6974
root.SilenceUsage = true
7075

@@ -77,7 +82,7 @@ func Execute(ctx context.Context) error {
7782
return nil
7883
}
7984

80-
func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client) error {
85+
func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {
8186
// TODO: replace map with a typed payload struct once event schema is finalised
8287
tel.Emit(ctx, "cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})
8388

@@ -94,7 +99,9 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
9499
LocalStackHost: cfg.LocalStackHost,
95100
Containers: appConfig.Containers,
96101
Env: appConfig.Env,
102+
Logger: logger,
97103
}
104+
98105
if isInteractiveMode(cfg) {
99106
return ui.Run(ctx, rt, version.Version(), opts)
100107
}
@@ -105,6 +112,24 @@ func isInteractiveMode(cfg *env.Env) bool {
105112
return !cfg.NonInteractive && ui.IsInteractive()
106113
}
107114

115+
const maxLogSize = 1 << 20 // 1 MB
116+
117+
func newLogger() (log.Logger, func()) {
118+
configDir, err := config.ConfigDir()
119+
if err != nil {
120+
return log.Nop(), func() {}
121+
}
122+
path := filepath.Join(configDir, "lstk.log")
123+
if info, err := os.Stat(path); err == nil && info.Size() > maxLogSize {
124+
_ = os.Truncate(path, 0)
125+
}
126+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
127+
if err != nil {
128+
return log.Nop(), func() {}
129+
}
130+
return log.New(f), func() { _ = f.Close() }
131+
}
132+
108133
func initConfig(cmd *cobra.Command, _ []string) error {
109134
path, err := cmd.Flags().GetString("config")
110135
if err != nil {

cmd/start.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ package cmd
22

33
import (
44
"github.com/localstack/lstk/internal/env"
5+
"github.com/localstack/lstk/internal/log"
56
"github.com/localstack/lstk/internal/runtime"
67
"github.com/localstack/lstk/internal/telemetry"
78
"github.com/spf13/cobra"
89
)
910

10-
func newStartCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
11+
func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
1112
return &cobra.Command{
1213
Use: "start",
1314
Short: "Start emulator",
@@ -18,7 +19,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1819
if err != nil {
1920
return err
2021
}
21-
return runStart(cmd.Context(), rt, cfg, tel)
22+
return runStart(cmd.Context(), rt, cfg, tel, logger)
2223
},
2324
}
2425
}

internal/api/client.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8+
"io"
89
"log"
910
"net/http"
1011
"time"
@@ -62,6 +63,18 @@ type MachineInfo struct {
6263
PlatformRelease string `json:"platform_release,omitempty"`
6364
}
6465

66+
// LicenseError is returned when license validation fails.
67+
// Message is user-friendly; Detail contains the raw server response for debugging.
68+
type LicenseError struct {
69+
Status int
70+
Message string
71+
Detail string
72+
}
73+
74+
func (e *LicenseError) Error() string {
75+
return fmt.Sprintf("license validation failed: %s", e.Message)
76+
}
77+
6578
type PlatformClient struct {
6679
baseURL string
6780
httpClient *http.Client
@@ -221,20 +234,39 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest)
221234
if err != nil {
222235
return fmt.Errorf("failed to request license: %w", err)
223236
}
224-
defer func() {
225-
if err := resp.Body.Close(); err != nil {
226-
log.Printf("failed to close response body: %v", err)
227-
}
228-
}()
229237

230-
switch resp.StatusCode {
231-
case http.StatusOK:
238+
statusCode := resp.StatusCode
239+
var detail string
240+
if statusCode != http.StatusOK {
241+
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
242+
detail = string(bytes.TrimSpace(respBody))
243+
}
244+
if err := resp.Body.Close(); err != nil {
245+
log.Printf("failed to close response body: %v", err)
246+
}
247+
248+
if statusCode == http.StatusOK {
232249
return nil
250+
}
251+
252+
switch statusCode {
233253
case http.StatusBadRequest:
234-
return fmt.Errorf("license validation failed: invalid token format, missing license assignment, or missing subscription")
254+
return &LicenseError{
255+
Status: statusCode,
256+
Message: "invalid token format, missing license assignment, or missing subscription",
257+
Detail: detail,
258+
}
235259
case http.StatusForbidden:
236-
return fmt.Errorf("license validation failed: invalid, inactive, or expired authentication token or subscription")
260+
return &LicenseError{
261+
Status: statusCode,
262+
Message: "invalid, inactive, or expired authentication token or subscription",
263+
Detail: detail,
264+
}
237265
default:
238-
return fmt.Errorf("license request failed with status %d", resp.StatusCode)
266+
return &LicenseError{
267+
Status: statusCode,
268+
Message: fmt.Sprintf("unexpected status %d", statusCode),
269+
Detail: detail,
270+
}
239271
}
240272
}

internal/auth/token_storage.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/99designs/keyring"
1212
"github.com/localstack/lstk/internal/config"
13+
"github.com/localstack/lstk/internal/log"
1314
)
1415

1516
const (
@@ -32,7 +33,7 @@ type authTokenStorage struct {
3233
ring keyring.Keyring
3334
}
3435

35-
func NewTokenStorage(forceFileKeyring bool) (AuthTokenStorage, error) {
36+
func NewTokenStorage(forceFileKeyring bool, logger log.Logger) (AuthTokenStorage, error) {
3637
configDir, err := config.ConfigDir()
3738
if err != nil {
3839
return nil, err
@@ -53,6 +54,7 @@ func NewTokenStorage(forceFileKeyring bool) (AuthTokenStorage, error) {
5354

5455
ring, err := keyring.Open(keyringConfig)
5556
if err != nil {
57+
logger.Info("system keyring unavailable (%v), falling back to file-based storage", err)
5658
keyringConfig.AllowedBackends = []keyring.BackendType{keyring.FileBackend}
5759
ring, err = keyring.Open(keyringConfig)
5860
if err != nil {

0 commit comments

Comments
 (0)