diff --git a/cmd/root.go b/cmd/root.go index 12dfbabd..2848b072 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,6 +63,10 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C // ` falls through to extension dispatch. Built-in commands are still // matched by Cobra's command resolution first, so they always win. Args: cobra.ArbitraryArgs, + // JSON-capable because the bare invocation runs start. Extension + // dispatch is exempt via isExtensionDispatch, so `lstk --json ` + // still forwards rather than wrapping the extension's own output. + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { // A non-empty arg here means the first positional was not a built-in // command (Cobra would have routed those to their own command), so it @@ -74,10 +78,11 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C _, endpointURL, _ := endpoint.ResolvedSource(cmd) return dispatchExtension(cmd.Context(), cfg, tel, logger, args, endpointURL) } + sink := jsonAwareSink(cmd, cfg, os.Stdout) // The bare root command starts the emulator via the same // startEmulator path as `lstk start` below, so it rejects // --endpoint-url the same way. - if err := rejectEndpointURL(cmd, output.NewPlainSink(os.Stdout), "start"); err != nil { + if err := rejectEndpointURL(cmd, sink, "start"); err != nil { return err } rt, err := runtime.NewDockerRuntime(cfg.DockerHost) @@ -101,7 +106,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if err := applyTimeoutFlag(cmd, cfg); err != nil { return err } - return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) + return startEmulator(cmd.Context(), rt, cfg, tel, logger, sink, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } @@ -335,7 +340,10 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger } } -func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool, emulatorType config.EmulatorType) error { +// startEmulator is shared by `lstk start` and the bare root command. The caller +// supplies sink (via jsonAwareSink) so that under --json every message on this +// path lands in the envelope instead of printing alongside it. +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger, sink output.Sink, persist bool, firstRun bool, snapshotFlag string, noSnapshot bool, emulatorType config.EmulatorType) error { appConfig, err := config.Get() if err != nil { return fmt.Errorf("failed to get config: %w", err) @@ -347,11 +355,11 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t } // Apply the --type flag before resolving snapshot and start options so - // everything downstream reflects the selected emulator. Messages go to a plain - // sink even in interactive mode because the config mutation has to happen before + // everything downstream reflects the selected emulator. Messages bypass the + // TUI even in interactive mode because the config mutation has to happen before // the TUI starts (the auto-load loader and start options are built from it). if emulatorType != "" { - newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, output.NewPlainSink(os.Stdout), emulatorType, appConfig.Containers, firstRun, configPath) + newContainers, applyErr := container.ApplyEmulatorType(ctx, rt, sink, emulatorType, appConfig.Containers, firstRun, configPath) if applyErr != nil { return applyErr } @@ -361,14 +369,29 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t firstRun = false } + // Classified here because this is the innermost scope with a sink. Only + // --json needs the ErrorEvent; in plain mode these stay bare errors so they + // keep reaching stderr as "Error: " rather than styled stdout. + failWithCode := func(err error, code output.ErrorCode) error { + if !cfg.JSON { + return err + } + sink.Emit(output.ErrorEvent{Title: err.Error(), Code: code}) + return output.NewSilentError(err) + } + ref, err := resolveStartSnapshotRef(appConfig, snapshotFlag, noSnapshot) if err != nil { - return err + return failWithCode(err, output.ErrValidationError) } // Parse the REF eagerly so an invalid snapshot fails before the emulator starts. autoLoad, err := newSnapshotAutoLoader(cfg, rt, appConfig, ref) if err != nil { - return err + if errors.Is(err, errSnapshotAutoLoadNotAWS) { + return failWithCode(err, output.ErrEmulatorNotConfigured) + } + // Anything else here is a REF that failed to parse. + return failWithCode(err, output.ErrSnapshotInvalidRef) } opts := buildStartOptions(cfg, appConfig, logger, tel, persist) @@ -393,7 +416,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t }) } - sink := output.NewPlainSink(os.Stdout) if firstRun && len(appConfig.Containers) > 0 { emName := appConfig.Containers[0].Type.ShortName() sink.Emit(output.MessageEvent{ diff --git a/cmd/snapshot.go b/cmd/snapshot.go index c44b3a65..8fba0c60 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -145,12 +145,19 @@ func snapshotFlags(cmd *cobra.Command) (snapshotFlag string, noSnapshot bool, er return snapshotFlag, noSnapshot, nil } +// Sentinels so the start path can classify these into error codes without +// re-matching the message text. +var ( + errSnapshotFlagConflict = errors.New("--snapshot and --no-snapshot cannot be used together") + errSnapshotAutoLoadNotAWS = errors.New("snapshot auto-load is only supported for the AWS emulator") +) + // resolveStartSnapshotRef resolves the snapshot REF to auto-load on start. // Precedence: --no-snapshot disables it; otherwise --snapshot wins over the // AWS container's configured snapshot. Returns "" when nothing should be loaded. func resolveStartSnapshotRef(appConfig *config.Config, snapshotFlag string, noSnapshot bool) (string, error) { if noSnapshot && snapshotFlag != "" { - return "", errors.New("--snapshot and --no-snapshot cannot be used together") + return "", errSnapshotFlagConflict } if noSnapshot { return "", nil @@ -185,7 +192,7 @@ func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.C } } if !found { - return nil, fmt.Errorf("snapshot auto-load is only supported for the AWS emulator") + return nil, errSnapshotAutoLoadNotAWS } home, _ := os.UserHomeDir() diff --git a/cmd/start.go b/cmd/start.go index bd898c75..0349f90b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -6,7 +6,6 @@ import ( "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/log" - "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/localstack/lstk/internal/telemetry" "github.com/spf13/cobra" @@ -30,9 +29,11 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai } return nil }, - PreRunE: initConfigDeferCreate(&firstRun), + PreRunE: initConfigDeferCreate(&firstRun), + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, RunE: func(c *cobra.Command, args []string) error { - if err := rejectEndpointURL(c, output.NewPlainSink(os.Stdout), "start"); err != nil { + sink := jsonAwareSink(c, cfg, os.Stdout) + if err := rejectEndpointURL(c, sink, "start"); err != nil { return err } @@ -55,7 +56,7 @@ If a snapshot is configured for the AWS emulator (the snapshot field in [[contai if err := applyTimeoutFlag(c, cfg); err != nil { return err } - return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) + return startEmulator(c.Context(), rt, cfg, tel, logger, sink, persist, firstRun, snapshotFlag, noSnapshot, emulatorType) }, } cmd.Flags().Bool("persist", false, "Persist emulator state across restarts") diff --git a/docs/structured-output.md b/docs/structured-output.md index 4030305c..bec8f5d7 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -8,7 +8,7 @@ The contract described here (a shared envelope shape, a shared error-code vocabu `--json` support is being rolled out per command, not all at once. The [Command Catalog](#command-catalog) below is split into two parts: -- **[Implemented in this PR](#implemented-in-this-pr)** — `stop`, `reset`, `update`. These accept `--json` today and produce exactly the shapes documented below. +- **[Implemented](#implemented)** — `start` (and the bare `lstk` invocation), `stop`, `reset`, `update`. These accept `--json` today and produce exactly the shapes documented below. - **[Proposed for future work](#proposed-for-future-work-draft)** — every other built-in command. Attempting `--json` on any of these today is rejected with `NOT_JSON_CAPABLE`. This part is a **first-draft proposal only** — see the warning at the top of that section before relying on any of it. ## The envelope @@ -187,11 +187,34 @@ A `USAGE_ERROR` that *was* successfully rendered as an envelope (because `--json ## Command Catalog -There are many commands supported by `lstk`, but they'll be addressed in phases. Initially we've focused on `stop`, `reset`, and `update` commands, simply to test the generation of JSON output. The remaining commands will follow in later work, where their specific JSON schema will be considered in more depth (for now, they're simply a rough proposal) +There are many commands supported by `lstk`, but they'll be addressed in phases. `stop`, `reset`, and `update` came first, simply to test the generation of JSON output; `start` (and the bare `lstk` invocation, which shares its behavior) followed. The remaining commands will arrive in later work, where their specific JSON schema will be considered in more depth (for now, they're simply a rough proposal). -### Implemented in this PR +### Implemented -These three ship in this PR with `--json` support. The shapes below are real — they match what the code actually produces, not a proposal. +The shapes below are real — they match what the code actually produces, not a proposal. + +**`lstk start`** — one emulator entry per configured container, plus whether a configured snapshot was auto-loaded. The bare `lstk --json` runs the same behavior and reports `"command": "start"`. +```json +{ + "schemaVersion": 1, + "command": "start", + "status": "ok", + "data": { + "emulators": [ + {"type": "aws", "name": "localstack-aws", "host": "localhost.localstack.cloud:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false} + ], + "snapshotLoaded": null + }, + "warnings": [], + "error": null +} +``` + +`emulators` is an array for consistency with `stop`/`restart`/`status`, but it holds exactly one entry today: `start` rejects a config with more than one enabled `[[containers]]` block up front (`CONFIG_INVALID`), since running two emulators together is unsupported. `alreadyRunning` is `true` when the emulator was already up — including when it is one lstk did not start (host-network mode, docker compose, a foreign container), which is still a success. `version` is empty only when it could not be determined: a pinned tag that never went through the platform license flow, or an already-running emulator whose `/_localstack/info` did not answer. `snapshotLoaded` is always present, `null` unless a configured snapshot was auto-loaded, otherwise `{"source": "...", "services": [...]}`. + +Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED` (no token resolvable and no interactive terminal for the device flow — exit code `4`), `LICENSE_INVALID`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED` (startup timeout, a container that exited, or a port/name conflict), `EMULATOR_WRONG_TYPE`, `EMULATOR_ALREADY_RUNNING` (running on a different port than configured), `CONFIG_INVALID` (more than one enabled `[[containers]]` block), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`), `SNAPSHOT_INVALID_REF` (malformed `--snapshot` REF), `EMULATOR_NOT_CONFIGURED` (`--snapshot` with no AWS container configured), `CONFIG_NOT_FOUND` (bad or missing `--config` path). + +`LICENSE_UNSUPPORTED_TAG` is deliberately **not** in that list, though earlier drafts included it: an unsupported tag is a non-fatal degradation on this path (the container validates its own bundled license) and surfaces as a warning, never as a failure. **`lstk stop`** — which configured emulators were actually running and got stopped. ```json @@ -264,24 +287,6 @@ Codes: `NETWORK_ERROR` (GitHub API unreachable), `INTERNAL_ERROR` (archive downl #### Emulator lifecycle -**`lstk start`** — one emulator entry per configured container, plus whether a configured snapshot was auto-loaded. -```json -{ - "schemaVersion": 1, - "command": "start", - "status": "ok", - "data": { - "emulators": [ - {"type": "aws", "name": "localstack-aws", "host": "localhost:4566", "version": "3.9.0", "alreadyRunning": false, "persist": false} - ], - "snapshotLoaded": null - }, - "warnings": [], - "error": null -} -``` -Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `LICENSE_UNSUPPORTED_TAG`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED`, `SNAPSHOT_NOT_FOUND` (bad `--snapshot`), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`). - **`lstk restart`** — the stop result and the start result, reusing both shapes above. ```json { @@ -592,3 +597,44 @@ Codes: `AUTH_REQUIRED`, `SNAPSHOT_NOT_FOUND`, `SNAPSHOT_INVALID_REF`, `CONFIRMAT - **`login`** — requires an interactive terminal unconditionally (browser-based OAuth) and has no defined non-interactive behavior at all today, so there's no output to render as JSON. - **`-v`/`--version`** — Cobra's built-in version flag is handled before any of lstk's own command dispatch runs at all (`Command.execute()` checks it before `PreRunE`/`RunE`), so there is no hook to intercept it without dropping Cobra's own version mechanism — which would newly couple `--version` to config-file loading, breaking the property (shared with `git --version`/`docker --version`) that a version check should work even against a broken environment. This is a deliberate, permanent limitation, not a gap waiting on a future PR. - **Proxy commands** (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough) and **extension dispatch** — both already have a settled, separate `--json` contract: `--json` before the proxy command's name is rejected the same as any unsupported command, while `--json` from the command name onward is forwarded to the wrapped tool untouched (Terraform, for instance, has its own real `-json` flag). Extensions receive the resolved `--json` value in their runtime context and decide for themselves. + +## Adding `--json` support to a command + +Six steps. `start` ([cmd/start.go](../cmd/start.go), [internal/container/start.go](../internal/container/start.go)) is the most complete worked example; `stop` is the simplest. + +**1. Opt the command in.** Add the annotation to the `cobra.Command`: + +```go +Annotations: map[string]string{jsonSupportedAnnotation: "true"}, +``` + +Without it, `requireJSONSupport` (`cmd/root.go`) rejects `--json` with `NOT_JSON_CAPABLE`. There is no central registry — the annotation is the whole opt-in. + +**2. Build the sink at the command boundary.** As the first statement in `RunE`: + +```go +sink := jsonAwareSink(cmd, cfg, os.Stdout) +``` + +This returns an `EnvelopeSink` under `--json` and a `PlainSink` otherwise, and registers the envelope sink on the command's context so the wrapper can finalize it after `RunE` returns. Pass this one sink everywhere — **every** helper on the command's path must use it. A `output.NewPlainSink(os.Stdout)` constructed anywhere downstream prints human text to stdout *alongside* the envelope and breaks the "exactly one JSON object" guarantee. This is the single most common mistake; `start` had four such sites. + +**3. Skip the TUI.** Guard the Bubble Tea branch with `isInteractiveMode(cfg)`, which is already false whenever `cfg.JSON` is set. Anything that waits on `UserInputRequestEvent.ResponseCh` must be guarded too — a `PlainSink`/`EnvelopeSink` never answers a prompt, so an unguarded one hangs until the context is cancelled. + +**4. Emit a result-bearing event.** Domain code must not know about JSON. Add a typed event to `internal/output/events.go` carrying domain facts (not pre-rendered strings), a `data` struct to `envelope_data.go`, a case in `EnvelopeSink.Emit` mapping one to the other, and a formatter in `plain_format.go`. Never add an event whose formatter returns `("", false)` while a neighbouring `MessageEvent` renders the same fact — that dead-formatter pattern was removed once already; the result event should own the line. + +**5. Classify every error the command can produce.** An `ErrorEvent` without a `Code` becomes `INTERNAL_ERROR`, which tells a script nothing. Set `Code` at each existing emission site: + +```go +sink.Emit(output.ErrorEvent{Code: output.ErrEmulatorStartFailed, Title: ..., Summary: ...}) +return output.NewSilentError(err) +``` + +`Title` is the headline (it becomes `error.message`), `Summary`/`Detail` become `error.details`, and `Actions` become machine-usable `error.actions`. `CONFIRMATION_REQUIRED` and `AUTH_REQUIRED` carry reserved exit codes (`3`, `4`), so classifying them correctly is what makes those exit codes work. + +Watch for paths that return a bare `error` with no `ErrorEvent` at all — they fall back to `INTERNAL_ERROR`. Where the error originates in a shared package whose other callers must keep their existing plain-text output, export a sentinel and classify it at the point where a sink exists (`auth.ErrAuthenticationRequired` is the precedent), rather than emitting from inside the shared package. + +Adding a *new* code means updating `allErrorCodes` and `categoryByCode` in `internal/output/error_code.go` — completeness tests fail otherwise. + +**6. Test it, and keep plain text byte-identical.** Write the integration test first. `decodeEnvelope` in `test/integration/json_envelope_test.go` already enforces the single-object guarantee; assert `error.code`, `error.category`, `error.retryable`, and the exit code for every documented failure, not just the happy path. Most error paths need no Docker — flag validation, config errors, and `DOCKER_HOST=tcp://localhost:1` (`unreachableDockerHost`) all work on Windows CI. Then run the full integration suite: JSON support usually means moving output between sinks, and the existing plain-text assertions are what catch an accidental change in what non-JSON users see. + +Finally, move the command's entry from the draft section of the Command Catalog above into [Implemented](#implemented), correcting anything the draft got wrong. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dcc50104..44d04a57 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,6 +12,12 @@ import ( var ErrNotLoggedIn = errors.New("not logged in") +// ErrAuthenticationRequired reports that no token could be resolved and the +// caller is non-interactive, so the device flow is unavailable. A sentinel so +// callers can classify it (e.g. output.ErrAuthRequired); emitting an ErrorEvent +// here instead would change plain-text output for every other caller. +var ErrAuthenticationRequired = errors.New("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") + type Auth struct { tokenStorage AuthTokenStorage login LoginProvider @@ -71,7 +77,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { } if !a.allowLogin { - return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") + return "", ErrAuthenticationRequired } return a.loginAndStore(ctx) @@ -83,7 +89,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { // `lstk logout` manually before retrying. func (a *Auth) Relogin(ctx context.Context) (string, error) { if !a.allowLogin { - return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") + return "", ErrAuthenticationRequired } if err := a.tokenStorage.DeleteAuthToken(); err != nil && !errors.Is(err, ErrTokenNotFound) { diff --git a/internal/container/start.go b/internal/container/start.go index 7fe13736..3bd8d76c 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -57,6 +57,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start // on container-name conflicts or shared port collisions. if err := checkSingleContainer(opts.Containers); err != nil { sink.Emit(output.ErrorEvent{ + Code: output.ErrConfigInvalid, Title: "Unsupported configuration", Summary: err.Error(), Actions: []output.ErrorAction{{Label: "Edit your config file so only one [[containers]] block is enabled:", Value: "lstk config path"}}, @@ -82,6 +83,20 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start token, err := a.GetToken(ctx) if err != nil { + // Classified so JSON callers get AUTH_REQUIRED and its reserved exit + // code. Title is the sentinel's message verbatim, keeping the line + // plain-text output already showed. + if errors.Is(err, auth.ErrAuthenticationRequired) { + sink.Emit(output.ErrorEvent{ + Code: output.ErrAuthRequired, + Title: err.Error(), + Actions: []output.ErrorAction{ + {Label: "Provide a token via the environment variable:", Value: "LOCALSTACK_AUTH_TOKEN"}, + {Label: "Or log in from an interactive terminal:", Value: "lstk login"}, + }, + }) + return "", output.NewSilentError(err) + } return "", err } @@ -120,6 +135,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start // re-login came back rejected too. func renderLicenseRejection(sink output.Sink, rejErr *licenseRejectedError, err error) error { sink.Emit(output.ErrorEvent{ + Code: output.ErrLicenseInvalid, Title: fmt.Sprintf("License validation failed for %s:%s: %s", rejErr.productName, rejErr.version, rejErr.licErr.Message), Actions: []output.ErrorAction{ {Label: "Log in again to refresh your credentials:", Value: "lstk logout && lstk login"}, @@ -331,7 +347,7 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S setups := map[config.EmulatorType]postStartSetupFunc{ config.EmulatorAWS: awsconfig.EnsureProfile, } - return resolvedVersion, runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups) + return resolvedVersion, runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, resolvedVersion, setups) } func resolvedPinnedVersion(containers []runtime.ContainerConfig) string { @@ -343,7 +359,7 @@ func resolvedPinnedVersion(containers []runtime.ContainerConfig) string { return "" } -func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, setups map[config.EmulatorType]postStartSetupFunc) error { +func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL, resolvedVersion string, setups map[config.EmulatorType]postStartSetupFunc) error { // build ordered list of unique types, keeping the first container config for each firstByType := map[config.EmulatorType]config.ContainerConfig{} var uniqueEmulatorTypes []config.EmulatorType @@ -364,17 +380,24 @@ func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sin return err } } - emitPostStartPointers(sink, t, resolvedHost, webAppURL, isPersistenceEnabled(ctx, rt, c.Name())) + emitPostStartPointers(sink, startedEmulator{ + emulatorType: t, + containerName: c.Name(), + resolvedHost: resolvedHost, + version: resolvedVersion, + persist: isPersistenceEnabled(ctx, rt, c.Name()), + }, webAppURL) } return nil } func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.ContainerConfig, localStackHost, webAppURL string, persist bool) { name := c.EmulatorType.DisplayName() + var version string if info, err := fetchLocalStackInfo(ctx, c.Port); err == nil && info.Version != "" { // /_localstack/info may report a build suffix (e.g. "2026.5.3:04ddfd3a0"); // keep only the version number. - version, _, _ := strings.Cut(info.Version, ":") + version, _, _ = strings.Cut(info.Version, ":") name = fmt.Sprintf("%s %s", name, version) } sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: fmt.Sprintf("%s is already running", name)}) @@ -382,7 +405,25 @@ func emitAlreadyRunning(ctx context.Context, sink output.Sink, c runtime.Contain if !dnsOK { sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: endpoint.DNSRebindNote}) } - emitPostStartPointers(sink, c.EmulatorType, resolvedHost, webAppURL, persist) + emitPostStartPointers(sink, startedEmulator{ + emulatorType: c.EmulatorType, + containerName: c.Name, + resolvedHost: resolvedHost, + version: version, + alreadyRunning: true, + persist: persist, + }, webAppURL) +} + +// startedEmulator lets the fresh-start and already-running paths converge on +// one emitPostStartPointers call instead of parallel parameter lists. +type startedEmulator struct { + emulatorType config.EmulatorType + containerName string + resolvedHost string + version string + alreadyRunning bool + persist bool } func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName string) bool { @@ -393,19 +434,34 @@ func isPersistenceEnabled(ctx context.Context, rt runtime.Runtime, containerName return slices.Contains(env, envPersistenceEnabled) } -func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, resolvedHost, webAppURL string, persist bool) { - if sfHost := snowflake.Hostname(resolvedHost); emulatorType == config.EmulatorSnowflake && sfHost != "" { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Snowflake endpoint: http://%s", sfHost)}) - } else { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Endpoint: %s", resolvedHost)}) +func startedEmulatorEvent(e startedEmulator) output.EmulatorStartedEvent { + var sfHost string + if h := snowflake.Hostname(e.resolvedHost); e.emulatorType == config.EmulatorSnowflake && h != "" { + sfHost = h + } + return output.EmulatorStartedEvent{ + Type: string(e.emulatorType), + Name: e.containerName, + DisplayName: e.emulatorType.DisplayName(), + Host: e.resolvedHost, + Version: e.version, + AlreadyRunning: e.alreadyRunning, + Persist: e.persist, + SnowflakeHost: sfHost, } - if persist && emulatorType == config.EmulatorAWS { +} + +func emitPostStartPointers(sink output.Sink, e startedEmulator, webAppURL string) { + // The endpoint line rides on EmulatorStartedEvent, not a MessageEvent, so + // JSON output gets it structured. Its formatter reproduces the line. + sink.Emit(startedEmulatorEvent(e)) + if e.persist && e.emulatorType == config.EmulatorAWS { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: "• Persistence: Enabled"}) } if webAppURL != "" { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))}) } - if tips := tipsForType(emulatorType); len(tips) > 0 { + if tips := tipsForType(e.emulatorType); len(tips) > 0 { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]}) } } @@ -526,6 +582,7 @@ func pullImage(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *t return true, nil } sink.Emit(output.ErrorEvent{ + Code: output.ErrImagePullFailed, Title: fmt.Sprintf("Failed to pull %s", c.Image), Summary: err.Error(), }) @@ -761,6 +818,7 @@ func (m *startupMonitor) handleFailure(ctx context.Context, c runtime.ContainerC case c.EmulatorType.SelfValidatesLicense() && strings.Contains(logs, "not covered by your license"): errCode = telemetry.ErrCodeLicenseInvalid m.sink.Emit(output.ErrorEvent{ + Code: output.ErrLicenseInvalid, Title: fmt.Sprintf("Your license does not include the %s emulator.", c.EmulatorType.ShortName()), Actions: []output.ErrorAction{ {Label: "Sign up for a free trial:", Value: "https://app.localstack.cloud/sign-up"}, @@ -790,6 +848,7 @@ func (m *startupMonitor) handleFailure(ctx context.Context, c runtime.ContainerC summary += "\nLast container output:\n" + tail } m.sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: err.Error(), Summary: summary, Actions: actions, @@ -802,6 +861,7 @@ func (m *startupMonitor) handleFailure(ctx context.Context, c runtime.ContainerC summary = "Last container output:\n" + tail } m.sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: err.Error(), Summary: summary, Actions: []output.ErrorAction{ @@ -850,6 +910,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu foundType := config.EmulatorTypeForImage(found.Image) if foundType != "" && foundType != c.EmulatorType { sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorWrongType, Title: fmt.Sprintf("%s is running on port %s", foundType.DisplayName(), found.BoundPort), Summary: fmt.Sprintf("Your config specifies the %s. Only one emulator can run on a port at a time.", c.EmulatorType.DisplayName()), Actions: []output.ErrorAction{ @@ -867,6 +928,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu } if found.BoundPort != c.Port { sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorAlreadyRunning, Title: fmt.Sprintf("%s is already running on port %s", c.EmulatorType.DisplayName(), found.BoundPort), Summary: fmt.Sprintf("Config expects port %s. Only one instance can run at a time.", c.Port), Actions: []output.ErrorAction{ @@ -889,6 +951,19 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu if _, err := ports.CheckAvailable(c.Port); err != nil { if info, infoErr := fetchLocalStackInfo(ctx, c.Port); infoErr == nil { emitLocalStackAlreadyRunningWarning(sink, c.Port, info.Version, c.Tag) + // A LocalStack lstk did not start (host network, compose, a + // foreign container) still counts as a result: without this, + // --json reports "ok" with no emulators entry at all. + runningVersion, _, _ := strings.Cut(info.Version, ":") + resolvedHost, _ := endpoint.ResolveHost(ctx, c.Port, localStackHost) + sink.Emit(startedEmulatorEvent(startedEmulator{ + emulatorType: c.EmulatorType, + containerName: c.Name, + resolvedHost: resolvedHost, + version: runningVersion, + alreadyRunning: true, + persist: isPersistenceEnabled(ctx, rt, c.Name), + })) continue } emitPortInUseError(sink, c.Port) @@ -910,6 +985,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu // a busy one is dropped with a warning instead of blocking the start. if conflictPort, err := ports.CheckAvailable(requiredHostPorts(c.ExtraPorts)...); err != nil { sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: fmt.Sprintf("Port %s is already in use", conflictPort), Summary: "LocalStack requires this port. Free it before starting.", Actions: portConflictActions(rt.Flavor(), runtime.DetectInstalledFlavor(), conflictPort), @@ -975,6 +1051,7 @@ func healLeftoverContainer(ctx context.Context, rt runtime.Runtime, sink output. removable := brief.Managed || (brief.AutoRemove && !brief.Created) if !removable { sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: fmt.Sprintf("Container name %q is already taken", c.Name), Summary: fmt.Sprintf("An existing container (image %s) uses this name but was not created by lstk, so lstk will not remove it.", brief.Image), Actions: []output.ErrorAction{{Label: "Remove or rename that container, e.g.:", Value: "docker rm " + c.Name}}, @@ -984,6 +1061,7 @@ func healLeftoverContainer(ctx context.Context, rt runtime.Runtime, sink output. if err := rt.Remove(ctx, c.Name); err != nil { sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: fmt.Sprintf("Cannot remove leftover container %q", c.Name), Summary: fmt.Sprintf("A previous start left this container behind and removing it failed: %v", err), Actions: []output.ErrorAction{{Label: "Remove it manually, then retry:", Value: "docker rm -f " + c.Name}}, @@ -1204,6 +1282,7 @@ func emitPortInUseError(sink output.Sink, port string) { actions = append(actions, output.ErrorAction{Label: "Use another port in the configuration:", Value: configPath}) } sink.Emit(output.ErrorEvent{ + Code: output.ErrEmulatorStartFailed, Title: fmt.Sprintf("Port %s already in use", port), Summary: "Free the port or configure a different one.", Actions: actions, diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 9d91fd96..2a9887c8 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -116,7 +116,7 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorAWS, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorAWS, resolvedHost: "localhost.localstack.cloud:4566", persist: false}, "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") @@ -132,7 +132,7 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorAWS, resolvedHost: "127.0.0.1:4566", persist: false}, "") got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n") @@ -143,7 +143,7 @@ func TestEmitPostStartPointers_WithPersist(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorAWS, "127.0.0.1:4566", "https://app.localstack.cloud/", true) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorAWS, resolvedHost: "127.0.0.1:4566", persist: true}, "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n• Persistence: Enabled\n• Web app: https://app.localstack.cloud\n", @@ -160,7 +160,7 @@ func TestRunPostStartSetups_EmitsPersistenceFromContainerEnv(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", nil) + err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", "", nil) require.NoError(t, err) assert.Contains(t, out.String(), "• Persistence: Enabled", @@ -177,7 +177,7 @@ func TestRunPostStartSetups_OmitsPersistenceWhenContainerEnvLacksFlag(t *testing var out bytes.Buffer sink := output.NewPlainSink(&out) - err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", nil) + err := runPostStartSetups(context.Background(), mockRT, sink, []config.ContainerConfig{cfg}, false, "", "", "", nil) require.NoError(t, err) assert.NotContains(t, out.String(), "• Persistence:") @@ -228,7 +228,7 @@ func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorSnowflake, resolvedHost: "localhost.localstack.cloud:4566", persist: false}, "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Snowflake endpoint: http://snowflake.localhost.localstack.cloud:4566\n") @@ -242,7 +242,7 @@ func TestEmitPostStartPointers_Snowflake_OmitsPersistenceBullet(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorSnowflake, "localhost.localstack.cloud:4566", "", true) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorSnowflake, resolvedHost: "localhost.localstack.cloud:4566", persist: true}, "") got := out.String() assert.NotContains(t, got, "• Persistence:", @@ -253,7 +253,7 @@ func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *tes var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorSnowflake, "127.0.0.1:4566", "", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorSnowflake, resolvedHost: "127.0.0.1:4566", persist: false}, "") got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n", @@ -387,7 +387,7 @@ func TestEmitPostStartPointers_Azure(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorAzure, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorAzure, resolvedHost: "localhost.localstack.cloud:4566", persist: false}, "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") @@ -401,7 +401,7 @@ func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) { var out bytes.Buffer sink := output.NewPlainSink(&out) - emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) + emitPostStartPointers(sink, startedEmulator{emulatorType: config.EmulatorType("other"), resolvedHost: "localhost.localstack.cloud:4566", persist: false}, "https://app.localstack.cloud/") got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") diff --git a/internal/output/__snapshots__/envelope_sink_test.snap b/internal/output/__snapshots__/envelope_sink_test.snap index d8305bcf..fc491e59 100644 --- a/internal/output/__snapshots__/envelope_sink_test.snap +++ b/internal/output/__snapshots__/envelope_sink_test.snap @@ -1,6 +1,29 @@ Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites this file. +[TestEnvelopeSink_StartEnvelopeJSON_1] +{ + "command": "start", + "data": { + "emulators": [ + { + "alreadyRunning": true, + "host": "localhost:4566", + "name": "localstack-aws", + "persist": false, + "type": "aws", + "version": "3.9.0" + } + ], + "snapshotLoaded": null + }, + "error": null, + "schemaVersion": 1, + "status": "ok", + "warnings": [] +} +--- + [TestEnvelopeSink_UpdateCheckedEnvelopeJSON_1] { "command": "update", diff --git a/internal/output/envelope_data.go b/internal/output/envelope_data.go index f7780825..0366c564 100644 --- a/internal/output/envelope_data.go +++ b/internal/output/envelope_data.go @@ -26,3 +26,21 @@ type JsonStoppedEmulator struct { } func (JsonStoppedEmulator) sealedEmulatorEntry() {} + +// JsonStartedEmulator is the per-emulator entry in `start`'s data.emulators. +type JsonStartedEmulator struct { + JsonEmulatorRef + Host string `json:"host"` + Version string `json:"version"` + AlreadyRunning bool `json:"alreadyRunning"` + Persist bool `json:"persist"` +} + +func (JsonStartedEmulator) sealedEmulatorEntry() {} + +// JsonSnapshotLoaded is `start`'s data.snapshotLoaded. The key is always +// present, null when nothing was auto-loaded. +type JsonSnapshotLoaded struct { + Source string `json:"source"` + Services []string `json:"services"` +} diff --git a/internal/output/envelope_sink.go b/internal/output/envelope_sink.go index fabff449..9474f7b6 100644 --- a/internal/output/envelope_sink.go +++ b/internal/output/envelope_sink.go @@ -40,6 +40,23 @@ func (s *EnvelopeSink) Emit(event Event) { JsonEmulatorRef: JsonEmulatorRef{Type: e.Type, Name: e.Name}, WasRunning: e.WasRunning, }) + case EmulatorStartedEvent: + s.appendEmulator(JsonStartedEmulator{ + JsonEmulatorRef: JsonEmulatorRef{Type: e.Type, Name: e.Name}, + Host: e.Host, + Version: e.Version, + AlreadyRunning: e.AlreadyRunning, + Persist: e.Persist, + }) + // Seeded here, not in Result, which every command shares. + if _, ok := s.data["snapshotLoaded"]; !ok { + s.data["snapshotLoaded"] = nil + } + case SnapshotLoadedEvent: + // This nesting is start's. `snapshot load` spreads source/services at + // the top level, so adding --json there needs a per-command shape or a + // distinct event — this type switch is shared by every command. + s.data["snapshotLoaded"] = JsonSnapshotLoaded(e) case EmulatorResetEvent: s.data["emulator"] = JsonEmulatorRef(e) s.data["reset"] = true diff --git a/internal/output/envelope_sink_test.go b/internal/output/envelope_sink_test.go index 6f52c17b..1eda1204 100644 --- a/internal/output/envelope_sink_test.go +++ b/internal/output/envelope_sink_test.go @@ -53,6 +53,86 @@ func TestEnvelopeSink_EmulatorStoppedEventAccumulates(t *testing.T) { }, entries) } +func TestEnvelopeSink_EmulatorStartedEventAccumulates(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorStartedEvent{ + Type: "aws", Name: "localstack-aws", DisplayName: "LocalStack AWS Emulator", + Host: "localhost:4566", Version: "3.9.0", Persist: true, + // SnowflakeHost is presentation-only and must not leak into the payload. + SnowflakeHost: "snowflake.localhost.localstack.cloud:4566", + }) + + envelope := sink.Result("start", nil) + data, ok := envelope.Data.(map[string]any) + require.True(t, ok) + entries, ok := data["emulators"].([]JsonEmulatorEntry) + require.True(t, ok) + require.Equal(t, []JsonEmulatorEntry{ + JsonStartedEmulator{ + JsonEmulatorRef: JsonEmulatorRef{Type: "aws", Name: "localstack-aws"}, + Host: "localhost:4566", + Version: "3.9.0", + AlreadyRunning: false, + Persist: true, + }, + }, entries) +} + +// The key is always present, null when nothing was auto-loaded. +func TestEnvelopeSink_EmulatorStartedEventSeedsNullSnapshotLoaded(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorStartedEvent{Type: "aws", Name: "localstack-aws", Host: "localhost:4566"}) + + data, ok := sink.Result("start", nil).Data.(map[string]any) + require.True(t, ok) + value, present := data["snapshotLoaded"] + require.True(t, present, "snapshotLoaded must be present, not omitted") + require.Nil(t, value) +} + +func TestEnvelopeSink_SnapshotLoadedEventOverridesSeededNull(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorStartedEvent{Type: "aws", Name: "localstack-aws", Host: "localhost:4566"}) + sink.Emit(SnapshotLoadedEvent{Source: "pod:my-baseline", Services: []string{"s3", "sqs"}}) + + data, ok := sink.Result("start", nil).Data.(map[string]any) + require.True(t, ok) + require.Equal(t, JsonSnapshotLoaded{Source: "pod:my-baseline", Services: []string{"s3", "sqs"}}, data["snapshotLoaded"]) +} + +// Seeding must not clobber an already-recorded snapshot if the order changes. +func TestEnvelopeSink_SnapshotLoadedSurvivesLaterStartedEvent(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(SnapshotLoadedEvent{Source: "pod:my-baseline", Services: []string{"s3"}}) + sink.Emit(EmulatorStartedEvent{Type: "aws", Name: "localstack-aws", Host: "localhost:4566"}) + + data, ok := sink.Result("start", nil).Data.(map[string]any) + require.True(t, ok) + require.Equal(t, JsonSnapshotLoaded{Source: "pod:my-baseline", Services: []string{"s3"}}, data["snapshotLoaded"]) +} + +func TestEnvelopeSink_StartEnvelopeJSON(t *testing.T) { + t.Parallel() + + sink := NewEnvelopeSink(FormatJSON) + sink.Emit(EmulatorStartedEvent{ + Type: "aws", Name: "localstack-aws", DisplayName: "LocalStack AWS Emulator", + Host: "localhost:4566", Version: "3.9.0", AlreadyRunning: true, + }) + + raw, err := json.Marshal(sink.Result("start", nil)) + require.NoError(t, err) + snap.MatchJSON(t, raw) +} + func TestEnvelopeSink_EmulatorResetEvent(t *testing.T) { t.Parallel() diff --git a/internal/output/events.go b/internal/output/events.go index 65f91653..9a6ed23b 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -162,6 +162,22 @@ type EmulatorStoppedEvent struct { WasRunning bool } +// EmulatorStartedEvent reports that an emulator is up, whether this run started +// it or found it already running. Host is resolved (post DNS-rebind check), not +// the configured port; Version is empty when it could not be determined. +type EmulatorStartedEvent struct { + Type string + Name string + DisplayName string + Host string + Version string + AlreadyRunning bool + Persist bool + // Shown instead of Host when set. Derived by the caller, so internal/output + // holds no per-emulator hostname rules. + SnowflakeHost string +} + // EmulatorResetEvent reports that the named emulator's in-memory state was reset. type EmulatorResetEvent struct { Type string @@ -224,6 +240,7 @@ func (SnapshotDiffEvent) sealedEvent() {} func (PodSnapshotRemovedEvent) sealedEvent() {} func (SnapshotShownEvent) sealedEvent() {} func (EmulatorStoppedEvent) sealedEvent() {} +func (EmulatorStartedEvent) sealedEvent() {} func (EmulatorResetEvent) sealedEvent() {} func (UpdateCheckedEvent) sealedEvent() {} func (UpdateAppliedEvent) sealedEvent() {} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 1e28ac31..d010a5ce 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -68,6 +68,8 @@ func FormatEventLine(event Event) (string, bool) { return "", false case EmulatorStoppedEvent: return formatEmulatorStopped(e), true + case EmulatorStartedEvent: + return formatEmulatorStarted(e), true case EmulatorResetEvent: return formatEmulatorReset(e), true case UpdateCheckedEvent: @@ -202,6 +204,13 @@ func formatEmulatorStopped(e EmulatorStoppedEvent) string { return SuccessMarker() + " " + fmt.Sprintf("%s stopped", e.DisplayName) } +func formatEmulatorStarted(e EmulatorStartedEvent) string { + if e.SnowflakeHost != "" { + return fmt.Sprintf("• Snowflake endpoint: http://%s", e.SnowflakeHost) + } + return fmt.Sprintf("• Endpoint: %s", e.Host) +} + func formatEmulatorReset(e EmulatorResetEvent) string { return SuccessMarker() + " Emulator state reset" } diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index f2aea950..d20f1161 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -187,6 +187,25 @@ func TestFormatEventLine(t *testing.T) { want: SuccessMarker() + " LocalStack AWS Emulator stopped", wantOK: true, }, + { + // Moved off a SeveritySecondary MessageEvent, so: no marker, no prefix. + name: "emulator started event", + event: EmulatorStartedEvent{Type: "aws", Name: "localstack-aws", DisplayName: "LocalStack AWS Emulator", Host: "localhost.localstack.cloud:4566", Version: "3.9.0"}, + want: "• Endpoint: localhost.localstack.cloud:4566", + wantOK: true, + }, + { + name: "emulator started event already running renders the same endpoint line", + event: EmulatorStartedEvent{Type: "aws", Name: "localstack-aws", Host: "127.0.0.1:4566", AlreadyRunning: true}, + want: "• Endpoint: 127.0.0.1:4566", + wantOK: true, + }, + { + name: "emulator started event with snowflake host", + event: EmulatorStartedEvent{Type: "snowflake", Name: "localstack-snowflake", Host: "localhost.localstack.cloud:4566", SnowflakeHost: "snowflake.localhost.localstack.cloud:4566"}, + want: "• Snowflake endpoint: http://snowflake.localhost.localstack.cloud:4566", + wantOK: true, + }, { name: "emulator reset event", event: EmulatorResetEvent{Type: "aws", Name: "localstack-aws"}, diff --git a/openspec/changes/json-output-schema/design.md b/openspec/changes/json-output-schema/design.md index 4cd3fa3e..fa57f82c 100644 --- a/openspec/changes/json-output-schema/design.md +++ b/openspec/changes/json-output-schema/design.md @@ -182,13 +182,23 @@ Cobra's built-in version flag (`root.InitDefaultVersionFlag()`) is handled insid **Decision**: accept the gap. `-v`/`--version` never gets `--json` support, documented as a permanent limitation alongside `login` and `config profile` (see Command Catalog and Non-Goals) rather than solved by inventing a command around it. +### Decision: `start --json` is a single final envelope, not streamed NDJSON progress + +Settled for `start` specifically when task 5.1 was implemented, resolving that half of the Open Question below. `lstk start --json` stays silent for the duration of the start (image pull, container boot, readiness wait) and then writes exactly one envelope, matching `stop`/`reset`/`update` and what plain-text non-interactive mode already does with that progress. + +Three reasons it went this way rather than NDJSON-with-trailing-result. It keeps the shipped contract intact — `output-envelope`'s "exactly one JSON object on stdout" guarantee is what the integration harness's `decodeEnvelope` asserts, and every JSON test in the suite depends on it, so introducing a third wire shape means revisiting that guarantee for one command's benefit. The concrete consumer driving this work (the LocalStack Toolkit for VS Code, DEVX-1052) needs the final result to drive its UI, not a progress feed; it has `lstk logs` if it wants live output. And the specific failure the open question worried about — a CI log that looks hung — is a presentation problem with a cheaper fix than a new wire format, since `start` already fails on a bounded timeout (60s non-interactive by default) rather than hanging indefinitely. + +**Alternative considered**: emit `ContainerStatusEvent`/`ProgressEvent` as NDJSON progress lines with the envelope as the final line. Declined for now, not rejected on principle: it remains the natural design if a real case appears, and `start` is still the right pilot for it. Deferring costs nothing, because adding progress lines later is additive for a consumer that reads only the last line — whereas shipping them now would commit every consumer to line-splitting from day one. + +This decision covers `start` only. `restart`, `snapshot load`, and `snapshot save` are untouched by it and remain open. + ## Command Catalog This is the artifact meant for human review: every built-in command, whether it gets `--json` support in this proposal, its `data` shape inside the envelope, and the `error.code`s it can realistically produce. Field names are `camelCase` JSON; durations are seconds (`uptimeSeconds`), timestamps are RFC 3339 strings, byte counts are `sizeBytes`. Example `name` values below (e.g. `"localstack-aws"`) are the real default container name for the AWS emulator — `fmt.Sprintf("localstack-%s", c.Type)` in `internal/config/containers.go`'s `ContainerConfig.defaultName()`, not a placeholder — so this is what a reader sees unless the `[[containers]]` block sets a custom `name`, which `ContainerConfig.Name()` returns in preference. It names the **container**, not the underlying Docker **image** (`localstack/localstack:latest`, resolved separately by `ContainerConfig.Image()`); the two are easy to conflate but the `name` field here is always the former, matching `InstanceInfoEvent.ContainerName`. Commands and flags not listed (`aws`, `terraform`, `cdk`, `sam`, `az` passthrough, extension dispatch, `docs`, `completion`, `help`, `login`, `config profile`, and the `-v`/`--version` flag) are explicitly out of scope — see Non-Goals. ### Emulator lifecycle -**`lstk start`** — `data`: an emulator entry per configured container, plus whether a configured snapshot was auto-loaded. +**`lstk start`** — `data`: an emulator entry per configured container, plus whether a configured snapshot was auto-loaded. Implemented in task 5.1; see docs/structured-output.md for the shipped contract. ```json { "emulators": [ @@ -197,7 +207,9 @@ This is the artifact meant for human review: every built-in command, whether it "snapshotLoaded": null } ``` -Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `LICENSE_UNSUPPORTED_TAG`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED`, `SNAPSHOT_NOT_FOUND` (bad `--snapshot`), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`). +Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID`, `IMAGE_PULL_FAILED`, `EMULATOR_START_FAILED`, `EMULATOR_WRONG_TYPE`, `EMULATOR_ALREADY_RUNNING`, `CONFIG_INVALID` (more than one enabled `[[containers]]` block), `VALIDATION_ERROR` (`--snapshot` with `--no-snapshot`), `SNAPSHOT_INVALID_REF` (malformed `--snapshot` REF), `EMULATOR_NOT_CONFIGURED` (`--snapshot` with no AWS container configured). + +`emulators` stays an array for consistency with `stop`/`restart`/`status`, but holds exactly one entry: `checkSingleContainer` rejects a multi-container config before anything else runs (task 5.5). `LICENSE_UNSUPPORTED_TAG` was dropped from this list as unreachable, and `SNAPSHOT_NOT_FOUND` became `SNAPSHOT_INVALID_REF` — a REF that fails to parse is malformed, not missing (tasks 5.6, 5.1). **`lstk stop`** — `data`: which configured emulators were actually running and got stopped. ```json @@ -357,7 +369,7 @@ No migration for existing users — `--json` remains rejected for any command no ## Open Questions -- **Should `start`, `restart`, `snapshot load`, and `snapshot save` stream NDJSON progress instead of staying silent until the final envelope?** This is the biggest open fork from the Prior Art review: Terraform's `apply -json` and Pulumi's engine events emit live `pulling`/`applying`/`provisioning`-style lines throughout a slow operation, ending in a final result line; today's design (see Decisions) discards that progress entirely under `--json`, matching what plain-text non-interactive mode already does but potentially leaving a script watching a slow image pull with no output for the whole duration. Deferred rather than decided here because it would introduce a third wire shape (NDJSON-with-trailing-result, distinct from both the single envelope and the pure `logs --follow` stream) and should be scoped as its own decision once there's a concrete case (e.g. CI logs showing an `lstk start --json` step that looks hung) rather than spec'd speculatively. If pursued, `start`/`restart` are the natural pilot given they already have the richest interactive-mode progress today (`ContainerStatusEvent`, `ProgressEvent`) that JSON mode currently drops on the floor. +- **Should `restart`, `snapshot load`, and `snapshot save` stream NDJSON progress instead of staying silent until the final envelope?** ~~`start`~~ — answered for `start` when 5.1 landed: single final envelope, see the Decision above. Still open for the other three. This is the biggest open fork from the Prior Art review: Terraform's `apply -json` and Pulumi's engine events emit live `pulling`/`applying`/`provisioning`-style lines throughout a slow operation, ending in a final result line; today's design (see Decisions) discards that progress entirely under `--json`, matching what plain-text non-interactive mode already does but potentially leaving a script watching a slow image pull with no output for the whole duration. Deferred rather than decided here because it would introduce a third wire shape (NDJSON-with-trailing-result, distinct from both the single envelope and the pure `logs --follow` stream) and should be scoped as its own decision once there's a concrete case (e.g. CI logs showing an `lstk start --json` step that looks hung) rather than spec'd speculatively. If pursued, `start`/`restart` are the natural pilot given they already have the richest interactive-mode progress today (`ContainerStatusEvent`, `ProgressEvent`) that JSON mode currently drops on the floor. - Should `warnings` also surface non-fatal issues from *plain-text* mode today (e.g. the "multiple emulators of the same type" message in `start`), or only ones specifically worth a script's attention? Leaning toward: any `MessageEvent{Severity: SeverityWarning}` reachable from a JSON-capable command's path qualifies, decided per command during its implementation task rather than up front. - Should `snapshot list`/`show` cache platform responses to reduce load when scripts poll `--json` in a loop? Out of scope here; revisit if it becomes a real usage pattern. - Whether `az start-interception`/`stop-interception` belong in the first implementation wave given they mutate global `~/.azure` state — flagged in tasks.md as a candidate for a later phase rather than blocking the rest. diff --git a/openspec/changes/json-output-schema/specs/json-command-output/spec.md b/openspec/changes/json-output-schema/specs/json-command-output/spec.md index 413f03cc..419e1788 100644 --- a/openspec/changes/json-output-schema/specs/json-command-output/spec.md +++ b/openspec/changes/json-output-schema/specs/json-command-output/spec.md @@ -27,14 +27,32 @@ A command SHALL only accept `--json` if it carries the JSON-capable annotation. ### Requirement: Emulator lifecycle commands report per-emulator results `start`, `stop`, `restart`, and `status` SHALL report results as an array with one entry per emulator type configured (`internal/config.ContainerConfig`), reflecting that lstk can run more than one emulator type concurrently. `status` SHALL include every configured emulator in its array regardless of whether it is running, rather than stopping at the first one found not running. +`start` SHALL use the same array shape, but its array SHALL always hold exactly one entry: starting is guarded by `checkSingleContainer`, which rejects a config with more than one enabled `[[containers]]` block before any other work, so a multi-emulator start is an error rather than a multi-entry result. The array shape is retained for consistency with the other lifecycle commands and to stay correct if concurrent multi-emulator starts are ever supported. + #### Scenario: status reports all configured emulators, running or not - **WHEN** `lstk status --json` is run with an AWS emulator configured and stopped, and a Snowflake emulator configured and running - **THEN** the envelope's `data.emulators` array contains one entry for AWS with `"running": false` and one entry for Snowflake with `"running": true` and its full detail fields - **AND** the command does not exit with an error solely because the AWS emulator is not running -#### Scenario: start reports one entry per configured emulator -- **WHEN** `lstk start --json` is run with two emulator types configured -- **THEN** the envelope's `data.emulators` array contains one entry per configured emulator type +#### Scenario: start reports the single configured emulator +- **WHEN** `lstk start --json` is run with one emulator type configured +- **THEN** the envelope's `data.emulators` array contains exactly one entry for that emulator type +- **AND** it carries `type`, `name`, `host`, `version`, `alreadyRunning`, and `persist` + +#### Scenario: start rejects more than one enabled containers block +- **WHEN** `lstk start --json` is run with two `[[containers]]` blocks enabled +- **THEN** the envelope has `"status": "error"` and `"error": {"code": "CONFIG_INVALID", ...}` +- **AND** no emulator is started + +#### Scenario: start reports an emulator it did not start itself +- **WHEN** `lstk start --json` is run and a LocalStack that lstk did not start is already answering on the configured port +- **THEN** the envelope has `"status": "ok"` +- **AND** its `data.emulators` array still contains an entry for that emulator, with `"alreadyRunning": true` + +#### Scenario: start always reports whether a snapshot was auto-loaded +- **WHEN** `lstk start --json` completes successfully +- **THEN** the envelope's `data.snapshotLoaded` key is present +- **AND** it is `null` when no configured snapshot was auto-loaded, or an object carrying `source` and `services` when one was ### Requirement: Snapshot commands report the documented payload shapes `snapshot save`, `snapshot load`, `snapshot list`, `snapshot show`, and `snapshot remove` SHALL each report the payload shape documented in design.md's Command Catalog for their respective destination kind (`local`, `pod`, or `s3` where applicable). diff --git a/openspec/changes/json-output-schema/tasks.md b/openspec/changes/json-output-schema/tasks.md index 40f1ba4d..0f0adb46 100644 --- a/openspec/changes/json-output-schema/tasks.md +++ b/openspec/changes/json-output-schema/tasks.md @@ -39,10 +39,20 @@ Implemented first, ahead of every other command: each depends only on the shared ## 5. Remaining emulator lifecycle -- [ ] 5.1 `start` (both `cmd/start.go` and the root command's bare invocation): add annotation to both, `{"emulators": [...], "snapshotLoaded": ...}` data shape, classify `RUNTIME_UNAVAILABLE`/`AUTH_REQUIRED`/`LICENSE_INVALID`/`LICENSE_UNSUPPORTED_TAG`/`IMAGE_PULL_FAILED`/`EMULATOR_START_FAILED`/`SNAPSHOT_NOT_FOUND`/`VALIDATION_ERROR` at their existing call sites. +- [x] 5.1 `start` (both `cmd/start.go` and the root command's bare invocation): add annotation to both, `{"emulators": [...], "snapshotLoaded": ...}` data shape, classify `RUNTIME_UNAVAILABLE`/`AUTH_REQUIRED`/`LICENSE_INVALID`/`LICENSE_UNSUPPORTED_TAG`/`IMAGE_PULL_FAILED`/`EMULATOR_START_FAILED`/`SNAPSHOT_NOT_FOUND`/`VALIDATION_ERROR` at their existing call sites. - [ ] 5.2 `restart`: add annotation, `{"stopped": [...], "started": [...]}` data shape, reusing 3.1 (`stop`)'s and 5.1 (`start`)'s classification. - [ ] 5.3 `volume clear`: add annotation, `{"cleared": [...]}` data shape, `EMULATOR_NOT_CONFIGURED`/`CONFIRMATION_REQUIRED` codes. -- [ ] 5.4 Integration tests for each command in this wave, including the `status`-style "report all configured emulators" scenario for `start`. +- [ ] 5.4 Integration tests for each command in this wave, including the `status`-style "report all configured emulators" scenario for `start`. (`start`'s share done in 5.1; see 5.5 for why the "report all configured emulators" scenario was replaced rather than written.) + +Discoveries from 5.1, appended rather than edited into the items above (the 3.6/3.7 convention): + +- [x] 5.5 The `json-command-output` scenario "start reports one entry per configured emulator … with two emulator types configured" is unreachable: `container.Start` calls `checkSingleContainer` as its very first action, before the runtime health check, and hard-fails on more than one enabled `[[containers]]` block — so `data.emulators` can only ever hold one entry. Keep the array shape (consistency with `stop`/`restart`/`status`, and correct if multi-emulator support ever lands) and replace the scenario with one asserting the real behavior: two enabled blocks produce `status: "error"` with `error.code: "CONFIG_INVALID"`. `CONFIG_INVALID` was therefore added to `start`'s documented code list. +- [x] 5.6 `LICENSE_UNSUPPORTED_TAG` is unreachable from `start` and was removed from its documented codes: an unsupported tag is a deliberate non-fatal degradation (the container validates its own bundled license) that emits a `MessageEvent` note, never a failure. +- [x] 5.7 `AUTH_REQUIRED` had no `ErrorEvent` at all — `auth.GetToken` returned a bare `fmt.Errorf`, so it would have surfaced as `INTERNAL_ERROR` with exit 1 instead of exit 4. Exported `auth.ErrAuthenticationRequired` as a sentinel and classified it in `container.Start` rather than emitting from inside `internal/auth`, whose other callers must keep their current plain-text output. The `ErrorEvent.Title` reuses the sentinel's message verbatim so the existing plain-text assertions still match. +- [x] 5.8 The start path had four hard-coded `output.NewPlainSink(os.Stdout)` sites (`cmd/root.go`'s bare-root `rejectEndpointURL` and `ApplyEmulatorType`, `cmd/start.go`'s `rejectEndpointURL`, and `startEmulator`'s own). Each would have printed human-readable text to stdout *alongside* the envelope, breaking the single-JSON-object guarantee. `startEmulator` now takes the sink as a parameter, built once per `RunE` via `jsonAwareSink`. +- [x] 5.9 `selectContainersToStart`'s "LocalStack is answering on the port but is not in a container lstk manages" branch (host-network mode, docker compose, a foreign container) exits 0 but emitted only a warning — no result. Under `--json` that produced `status: "ok"` with no `emulators` key at all, violating the documented shape. It now emits `EmulatorStartedEvent` too, which also gives plain text the endpoint line it never printed in that case. +- [x] 5.10 `EnvelopeSink`'s new `SnapshotLoadedEvent` case writes `data.snapshotLoaded`, which is `start`'s nesting. `snapshot load` (task 6.2) spreads `source`/`services` at the top level instead, so 6.2 must either reshape this case per command or introduce a distinct event — `EnvelopeSink` is a single type switch shared by every command. Noted in a doc comment at the case. +- [x] 5.11 Classifying errors in `cmd/` must not emit unconditionally: doing so moved three snapshot-flag failures from plain `Error: ` on stderr to styled output on stdout, breaking `TestStartSnapshot*` and four `TestFirstRun*`/`TestEmulatorSelection*` PTY tests. `startEmulator`'s `failWithCode` follows `cmd/reset.go`'s pattern — emit the `ErrorEvent` only when `cfg.JSON` is set, otherwise return the bare error. ## 6. Snapshots diff --git a/test/integration/__snapshots__/json_flag_test.snap b/test/integration/__snapshots__/json_flag_test.snap index 9349deb7..42ab8a51 100644 --- a/test/integration/__snapshots__/json_flag_test.snap +++ b/test/integration/__snapshots__/json_flag_test.snap @@ -213,22 +213,6 @@ Error: terraform not found in PATH } --- -[TestJSONFlagRejectsDefaultStartBehavior_1] -{ - "command": "start", - "data": null, - "error": { - "category": "USAGE", - "code": "NOT_JSON_CAPABLE", - "message": "\"start\" is not able to provide output in JSON format", - "retryable": false - }, - "schemaVersion": 1, - "status": "error", - "warnings": [] -} ---- - [TestJSONFlagRejectsUnannotatedBuiltinCommand_1] { "command": "status", diff --git a/test/integration/json_flag_test.go b/test/integration/json_flag_test.go index c805f4f0..fd120dcc 100644 --- a/test/integration/json_flag_test.go +++ b/test/integration/json_flag_test.go @@ -24,19 +24,15 @@ func TestJSONFlagRejectsUnannotatedBuiltinCommand(t *testing.T) { assert.Empty(t, stderr, "the rejection is rendered as JSON on stdout, not plain text on stderr") } -func TestJSONFlagRejectsDefaultStartBehavior(t *testing.T) { - t.Parallel() - stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--json") - requireExitCode(t, 1, err) - decodeEnvelope(t, stdout) - snap.MatchJSON(t, []byte(stdout)) - assert.Empty(t, stderr, "the rejection is rendered as JSON on stdout, not plain text on stderr") -} +// `start` and the bare invocation are JSON-capable; see start_json_test.go. func TestJSONFlagDoesNotLaunchTUIOnPTY(t *testing.T) { t.Parallel() - out, err := runLstkInPTY(t, testContext(t), testEnvWithHome(t.TempDir(), ""), "start", "--json") + // Without an unreachable runtime, a machine with a live daemon gets past the + // runtime check into AUTH_REQUIRED (exit 4) instead of exit 1. + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + out, err := runLstkInPTY(t, testContext(t), e, "start", "--json") requireExitCode(t, 1, err) require.Contains(t, out, "start") // If the TUI had launched, it would have shown the auth prompt (start with diff --git a/test/integration/start_json_test.go b/test/integration/start_json_test.go new file mode 100644 index 00000000..468408d4 --- /dev/null +++ b/test/integration/start_json_test.go @@ -0,0 +1,286 @@ +package integration_test + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jsonStartData mirrors `start`'s data shape in docs/structured-output.md. +// The shared envelope harness leaves Data raw on purpose. +type jsonStartData struct { + Emulators []jsonStartedEmulator `json:"emulators"` + SnapshotLoaded *jsonSnapshotLoaded `json:"snapshotLoaded"` +} + +type jsonStartedEmulator struct { + Type string `json:"type"` + Name string `json:"name"` + Host string `json:"host"` + Version string `json:"version"` + AlreadyRunning bool `json:"alreadyRunning"` + Persist bool `json:"persist"` +} + +type jsonSnapshotLoaded struct { + Source string `json:"source"` + Services []string `json:"services"` +} + +func decodeStartData(t *testing.T, envelope jsonEnvelope) jsonStartData { + t.Helper() + var data jsonStartData + require.NoError(t, json.Unmarshal(envelope.Data, &data), "data should decode into start's documented shape: %s", envelope.Data) + return data +} + +// The flag conflict resolves before any Docker interaction, so this pins the +// envelope wiring (annotation + sink) without a daemon. +func TestStartJSONRejectsConflictingSnapshotFlags(t *testing.T) { + t.Parallel() + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "start", "--json", "--snapshot", "pod:baseline", "--no-snapshot") + requireExitCode(t, 1, err) + assert.Empty(t, stderr, "the envelope is the only output; no plain-text fallback") + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + assert.Equal(t, "error", envelope.Status) + assert.JSONEq(t, "null", string(envelope.Data), "data must be null when status is error") + require.NotNil(t, envelope.Error) + assert.Equal(t, "VALIDATION_ERROR", envelope.Error.Code) + assert.Equal(t, "USAGE", envelope.Error.Category) + assert.False(t, envelope.Error.Retryable) +} + +// Replaces the unreachable "two configured types report two entries" scenario: +// checkSingleContainer runs first, so this is a config error, not a 2-entry array. +func TestStartJSONMultipleContainersRendersConfigInvalid(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, + []byte("[[containers]]\ntype = \"aws\"\nport = \"4566\"\n\n[[containers]]\ntype = \"snowflake\"\nport = \"4567\"\n"), 0644)) + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "--config", configFile, "start", "--json") + requireExitCode(t, 1, err) + assert.Empty(t, stderr, "the envelope is the only output; no plain-text fallback") + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "CONFIG_INVALID", envelope.Error.Code) + assert.Equal(t, "CONFIG", envelope.Error.Category) + summary, _ := envelope.Error.Details["summary"].(string) + assert.Contains(t, summary, "only one is supported at a time", + "the diagnostic plain text shows must survive into details") +} + +// Pins the retryable classification for an unreachable runtime. +func TestStartJSONRuntimeUnavailable(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte("[[containers]]\ntype = \"aws\"\nport = \"4566\"\n"), 0644)) + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "--config", configFile, "start", "--json") + requireExitCode(t, 1, err) + assert.Empty(t, stderr, "the envelope is the only output; no plain-text fallback") + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "RUNTIME_UNAVAILABLE", envelope.Error.Code) + assert.Equal(t, "RUNTIME", envelope.Error.Category) + assert.True(t, envelope.Error.Retryable, "an unreachable runtime is worth retrying") +} + +// `lstk --json` runs start's behavior, so its envelope must name `start` +// rather than be rejected as JSON-incapable. +func TestStartJSONBareRootReportsStartCommand(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte("[[containers]]\ntype = \"aws\"\nport = \"4566\"\n"), 0644)) + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "--config", configFile, "--json") + requireExitCode(t, 1, err) + assert.Empty(t, stderr) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command, "the bare invocation shares start's identity") + require.NotNil(t, envelope.Error) + assert.Equal(t, "RUNTIME_UNAVAILABLE", envelope.Error.Code) +} + +// Guards the hard-coded PlainSink rejectEndpointURL used here, which printed a +// human-readable line to stdout in addition to the envelope. +func TestStartJSONRejectsEndpointURLAsSingleEnvelope(t *testing.T) { + t.Parallel() + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, + "start", "--json", "--endpoint-url", "http://localhost:4566") + requireExitCode(t, 1, err) + assert.Empty(t, stderr) + + // decodeEnvelope is the assertion: it fails on anything but one JSON object. + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + assert.Equal(t, "error", envelope.Status) + require.NotNil(t, envelope.Error) +} + +// The other plain-text leak: a fresh install emits a "Configured with default +// emulator" note that must not reach stdout under --json. +func TestStartJSONFirstRunEmitsSingleEnvelope(t *testing.T) { + t.Parallel() + + e := append(testEnvWithHome(t.TempDir(), ""), unreachableDockerHost) + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, "start", "--json") + requireExitCode(t, 1, err) + assert.Empty(t, stderr) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "RUNTIME_UNAVAILABLE", envelope.Error.Code) +} + +// The success payload: one entry per configured container, snapshotLoaded null. +func TestStartJSONSucceeds(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + stdout, stderr, err := runLstk(t, testContext(t), "", env.With(env.APIEndpoint, mockServer.URL), "start", "--json") + require.NoError(t, err, "lstk start --json failed: %s", stderr) + requireExitCode(t, 0, err) + assert.Empty(t, stderr) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + assert.Equal(t, "ok", envelope.Status) + assert.Nil(t, envelope.Error, "error must be null when status is ok") + + data := decodeStartData(t, envelope) + require.Len(t, data.Emulators, 1, "one entry per configured container") + emulator := data.Emulators[0] + assert.Equal(t, "aws", emulator.Type) + assert.Equal(t, containerName, emulator.Name) + assert.Equal(t, "localhost.localstack.cloud:4566", emulator.Host) + assert.NotEmpty(t, emulator.Version, "a started emulator reports its resolved version") + assert.False(t, emulator.AlreadyRunning) + assert.False(t, emulator.Persist) + assert.Nil(t, data.SnapshotLoaded, "snapshotLoaded is null when nothing was auto-loaded") +} + +// Starting against an emulator already up is still "ok", with alreadyRunning +// flipped. Mirrors TestStartCommandAttachesWhenLocalStackRespondingOnPort, so +// the version comes from the mock /_localstack/info and no real token is needed. +func TestStartJSONReportsAlreadyRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ln, err := net.Listen("tcp", ":4566") + require.NoError(t, err, "failed to bind port 4566 for test") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_localstack/info" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"3.4.0","edition":"pro"}`)) + return + } + http.NotFound(w, r) + })) + srv.Listener = ln + srv.Start() + defer srv.Close() + + stdout, stderr, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start", "--json") + require.NoError(t, err, "lstk start --json failed against a running emulator: %s", stderr) + requireExitCode(t, 0, err) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + assert.Equal(t, "ok", envelope.Status) + assert.Nil(t, envelope.Error) + + data := decodeStartData(t, envelope) + require.Len(t, data.Emulators, 1) + emulator := data.Emulators[0] + assert.True(t, emulator.AlreadyRunning, "the emulator was already running") + assert.Equal(t, "aws", emulator.Type) + assert.Equal(t, "3.4.0", emulator.Version, "the running emulator's own reported version") + assert.Equal(t, "localhost.localstack.cloud:4566", emulator.Host) + assert.Nil(t, data.SnapshotLoaded) +} + +// persist is otherwise only observable as a plain-text bullet. +func TestStartJSONReportsPersist(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + stdout, stderr, err := runLstk(t, testContext(t), "", env.With(env.APIEndpoint, mockServer.URL), "start", "--persist", "--json") + require.NoError(t, err, "lstk start --persist --json failed: %s", stderr) + + data := decodeStartData(t, decodeEnvelope(t, stdout)) + require.Len(t, data.Emulators, 1) + assert.True(t, data.Emulators[0].Persist) +} + +// Pins the AUTH_REQUIRED / exit-4 reservation. Docker must be healthy, since +// the runtime check precedes the auth check. +func TestStartJSONAuthRequiredExitsFour(t *testing.T) { + requireDocker(t) + + cleanup() + t.Cleanup(cleanup) + + home := t.TempDir() + configFile := filepath.Join(home, "config.toml") + require.NoError(t, os.WriteFile(configFile, + []byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\ntag = \"latest\"\nport = %q\n", "4599")), 0644)) + + // Isolated HOME empties the file keyring; Without drops the env var the + // developer's shell or CI may have set. No token resolvable from any source. + e := env.Environ(testEnvWithHome(home, "")).Without(env.AuthToken) + stdout, stderr, err := runLstk(t, testContext(t), "", e, + "--config", configFile, "start", "--json") + requireExitCode(t, 4, err) + assert.Empty(t, stderr) + + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "start", envelope.Command) + require.NotNil(t, envelope.Error) + assert.Equal(t, "AUTH_REQUIRED", envelope.Error.Code) + assert.Equal(t, "AUTH", envelope.Error.Category) + assert.False(t, envelope.Error.Retryable) +}