From 6d0054183584d89f537910c65f414b8364b23fc2 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Mon, 4 May 2026 16:40:00 +0300 Subject: [PATCH 1/2] Add sandbox command --- CLAUDE.md | 14 ++ cmd/root.go | 1 + cmd/sandbox.go | 348 +++++++++++++++++++++++++++++++ internal/sandbox/sandbox.go | 327 +++++++++++++++++++++++++++++ internal/sandbox/sandbox_test.go | 163 +++++++++++++++ test/integration/sandbox_test.go | 129 ++++++++++++ 6 files changed, 982 insertions(+) create mode 100644 cmd/sandbox.go create mode 100644 internal/sandbox/sandbox.go create mode 100644 internal/sandbox/sandbox_test.go create mode 100644 test/integration/sandbox_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 748a14ab..18b4c531 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,20 @@ Use `lstk setup ` to set up CLI integration for an emulator type: This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations. The deprecated `lstk config profile` command still works but points users to `lstk setup aws`. +# Sandbox Commands + +Use `lstk sandbox ` to manage cloud-hosted LocalStack sandbox instances: +- `lstk sandbox create [--timeout 60] [-e KEY=VALUE ...]` — Create a sandbox instance. `--timeout` is in minutes (matches the API payload). +- `lstk sandbox list` — List sandbox instances in a table. +- `lstk sandbox describe ` — Print the raw JSON instance state. +- `lstk sandbox delete [--wait] [--timeout 5m]` — Delete a sandbox instance, optionally polling until deletion completes. +- `lstk sandbox logs ` — Print current instance logs. +- `lstk sandbox url ` — Print only the endpoint URL for scripting, e.g. `AWS_ENDPOINT_URL=$(lstk sandbox url )`. +- `lstk sandbox reset ` — Reset all LocalStack state by calling `/_localstack/state/reset` on the sandbox endpoint. + +Use positional `` for the primary sandbox identifier. Hidden `--name` compatibility aliases may exist for migration from `localstack ephemeral`, but new help/docs should use positionals. +Keep sandbox commands cloud-only for now; do not add a `--runtime` dimension unless the local/cloud sandbox lifecycle design is revisited explicitly. + Environment variables: - `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set) - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). diff --git a/cmd/root.go b/cmd/root.go index c2babb05..c9626426 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,6 +77,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newUpdateCmd(cfg), newDocsCmd(), newAWSCmd(cfg), + newSandboxCmd(cfg, logger), ) return root diff --git a/cmd/sandbox.go b/cmd/sandbox.go new file mode 100644 index 00000000..27486f97 --- /dev/null +++ b/cmd/sandbox.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/sandbox" + "github.com/spf13/cobra" +) + +func newSandboxCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "sandbox", + Short: "Manage cloud-hosted LocalStack sandbox instances", + Long: "Manage cloud-hosted LocalStack sandbox instances.", + } + cmd.AddCommand( + newSandboxCreateCmd(cfg, logger), + newSandboxListCmd(cfg, logger), + newSandboxDescribeCmd(cfg, logger), + newSandboxDeleteCmd(cfg, logger), + newSandboxLogsCmd(cfg, logger), + newSandboxURLCmd(cfg, logger), + newSandboxResetCmd(cfg, logger), + ) + return cmd +} + +func newSandboxCreateCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var ( + name string + timeout int + envVars []string + ) + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a sandbox instance", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + if timeout <= 0 { + return fmt.Errorf("--timeout must be greater than 0") + } + parsedEnv, err := parseSandboxEnv(envVars) + if err != nil { + return err + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + body, err := client.Create(cmd.Context(), sandbox.CreateOptions{ + Name: name, + LifetimeMinutes: timeout, + EnvVars: parsedEnv, + }) + if err != nil { + return err + } + emitRawJSON(output.NewPlainSink(os.Stdout), body) + return nil + }, + } + cmd.Flags().IntVar(&timeout, "timeout", 60, "Instance lifetime in minutes") + cmd.Flags().StringArrayVarP(&envVars, "env", "e", nil, "Environment variable to pass to the instance (KEY=VALUE)") + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxListCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List sandbox instances", + Args: cobra.NoArgs, + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + instances, err := client.List(cmd.Context()) + if err != nil { + return err + } + sink := output.NewPlainSink(os.Stdout) + if len(instances) == 0 { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No sandbox instances found"}) + return nil + } + rows := make([][]string, 0, len(instances)) + for _, inst := range instances { + rows = append(rows, []string{inst.Name, inst.Status, inst.Endpoint, inst.Expires}) + } + sink.Emit(output.TableEvent{ + Headers: []string{"Name", "Status", "Endpoint", "Expires"}, + Rows: rows, + }) + return nil + }, + } + return cmd +} + +func newSandboxDescribeCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "describe ", + Short: "Show the current state of a sandbox instance", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + instance, err := client.Describe(cmd.Context(), name) + if err != nil { + if errors.Is(err, sandbox.ErrNotFound) { + return fmt.Errorf("sandbox instance %q not found", name) + } + return err + } + emitRawJSON(output.NewPlainSink(os.Stdout), []byte(fmt.Sprintf(`{"name":%q,"status":%q,"endpoint":%q,"expires":%q}`, instance.Name, instance.Status, instance.Endpoint, instance.Expires))) + return nil + }, + } + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxDeleteCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var ( + name string + wait bool + waitTimeout time.Duration + ) + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a sandbox instance", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + if waitTimeout <= 0 { + return fmt.Errorf("--wait-timeout must be greater than 0") + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + sink := output.NewPlainSink(os.Stdout) + + if err := client.Delete(cmd.Context(), name); err != nil { + if errors.Is(err, sandbox.ErrNotFound) { + return fmt.Errorf("sandbox instance %q not found", name) + } + return err + } + + if wait { + if err := client.WaitForDeletion(cmd.Context(), sink, name, waitTimeout); err != nil { + return err + } + } + + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Deleted sandbox instance %q", name)}) + return nil + }, + } + cmd.Flags().BoolVar(&wait, "wait", false, "Wait until the instance is fully deleted") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 5*time.Minute, "Maximum time to wait for deletion when --wait is set") + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxLogsCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "logs ", + Short: "Fetch logs from a sandbox instance", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + lines, err := client.Logs(cmd.Context(), name) + if err != nil { + if errors.Is(err, sandbox.ErrNotFound) { + return fmt.Errorf("sandbox instance %q not found", name) + } + return err + } + sink := output.NewPlainSink(os.Stdout) + if len(lines) == 0 { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No logs available for this instance"}) + return nil + } + for _, line := range lines { + sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line}) + } + return nil + }, + } + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxURLCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "url ", + Short: "Print the sandbox endpoint URL", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + endpoint, err := resolveSandboxEndpoint(cmd.Context(), client, name) + if err != nil { + return err + } + output.NewPlainSink(os.Stdout).Emit(output.MessageEvent{Text: endpoint}) + return nil + }, + } + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxResetCmd(cfg *env.Env, logger log.Logger) *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "reset ", + Short: "Reset all state in a running sandbox instance", + Args: cobra.MaximumNArgs(1), + PreRunE: initConfig, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := sandboxName(args, name) + if err != nil { + return err + } + client, err := newSandboxClient(cfg, logger) + if err != nil { + return err + } + endpoint, err := resolveSandboxEndpoint(cmd.Context(), client, name) + if err != nil { + return err + } + if err := client.ResetState(cmd.Context(), endpoint); err != nil { + return err + } + output.NewPlainSink(os.Stdout).Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Reset sandbox instance %q", name)}) + return nil + }, + } + addHiddenSandboxNameFlag(cmd, &name) + return cmd +} + +func newSandboxClient(cfg *env.Env, logger log.Logger) (*sandbox.Client, error) { + if cfg.AuthToken == "" { + return nil, fmt.Errorf("authentication required: run `lstk login` or set LOCALSTACK_AUTH_TOKEN") + } + return sandbox.NewClient(cfg.APIEndpoint, cfg.AuthToken, logger), nil +} + +func addHiddenSandboxNameFlag(cmd *cobra.Command, target *string) { + cmd.Flags().StringVar(target, "name", "", "Name of the sandbox instance") + _ = cmd.Flags().MarkHidden("name") +} + +func resolveSandboxEndpoint(ctx context.Context, client *sandbox.Client, name string) (string, error) { + instance, err := client.Describe(ctx, name) + if err != nil { + if errors.Is(err, sandbox.ErrNotFound) { + return "", fmt.Errorf("sandbox instance %q not found", name) + } + return "", err + } + if instance.Endpoint == "" { + return "", fmt.Errorf("sandbox instance %q has no endpoint URL", name) + } + return instance.Endpoint, nil +} + +func sandboxName(args []string, flagValue string) (string, error) { + if len(args) == 1 && flagValue != "" { + return "", fmt.Errorf("provide the sandbox name either as an argument or with --name, not both") + } + if len(args) == 1 { + return args[0], nil + } + if flagValue != "" { + return flagValue, nil + } + return "", fmt.Errorf("sandbox name is required") +} + +func parseSandboxEnv(values []string) (map[string]string, error) { + result := make(map[string]string, len(values)) + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + if !ok { + return nil, fmt.Errorf("invalid environment variable %q: expected KEY=VALUE", value) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid environment variable %q: key cannot be empty", value) + } + result[key] = strings.TrimSpace(val) + } + return result, nil +} + +func emitRawJSON(sink output.Sink, body []byte) { + sink.Emit(output.MessageEvent{Text: strings.TrimSpace(string(body))}) +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 00000000..db8162e1 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,327 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" +) + +const ( + instancesPath = "/v1/compute/instances" + instancePath = "/v1/compute/instances/%s" + instanceLogsPath = "/v1/compute/instances/%s/logs" + stateResetAPIPath = "/_localstack/state/reset" +) + +var ErrNotFound = errors.New("sandbox instance not found") + +type CreateOptions struct { + Name string + LifetimeMinutes int + EnvVars map[string]string +} + +type Instance struct { + Name string + Status string + Endpoint string + Expires string +} + +type Client struct { + baseURL string + authToken string + httpClient *http.Client + logger log.Logger +} + +func NewClient(baseURL, authToken string, logger log.Logger) *Client { + if logger == nil { + logger = log.Nop() + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + authToken: authToken, + httpClient: &http.Client{Timeout: 30 * time.Second}, + logger: logger, + } +} + +func (c *Client) Create(ctx context.Context, opts CreateOptions) (json.RawMessage, error) { + payload := struct { + InstanceName string `json:"instance_name"` + Lifetime int `json:"lifetime"` + EnvVars map[string]string `json:"env_vars"` + }{ + InstanceName: opts.Name, + Lifetime: opts.LifetimeMinutes, + EnvVars: opts.EnvVars, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode create request: %w", err) + } + respBody, err := c.do(ctx, http.MethodPost, c.baseURL+instancesPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create sandbox instance: %w", err) + } + return respBody, nil +} + +func (c *Client) List(ctx context.Context) ([]Instance, error) { + body, err := c.do(ctx, http.MethodGet, c.baseURL+instancesPath, nil) + if err != nil { + return nil, fmt.Errorf("list sandbox instances: %w", err) + } + return parseInstances(body) +} + +// Describe fetches instance state. Returns ErrNotFound on 404. +func (c *Client) Describe(ctx context.Context, name string) (Instance, error) { + reqURL := fmt.Sprintf(c.baseURL+instancePath, url.PathEscape(name)) + body, err := c.do(ctx, http.MethodGet, reqURL, nil) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Instance{}, ErrNotFound + } + return Instance{}, fmt.Errorf("describe sandbox instance: %w", err) + } + return parseInstance(body) +} + +func (c *Client) Delete(ctx context.Context, name string) error { + reqURL := fmt.Sprintf(c.baseURL+instancePath, url.PathEscape(name)) + if _, err := c.do(ctx, http.MethodDelete, reqURL, nil); err != nil { + if errors.Is(err, ErrNotFound) { + return ErrNotFound + } + return fmt.Errorf("delete sandbox instance: %w", err) + } + return nil +} + +func (c *Client) Logs(ctx context.Context, name string) ([]string, error) { + reqURL := fmt.Sprintf(c.baseURL+instanceLogsPath, url.PathEscape(name)) + body, err := c.do(ctx, http.MethodGet, reqURL, nil) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("fetch sandbox logs: %w", err) + } + return parseLogLines(body) +} + +func (c *Client) ResetState(ctx context.Context, endpointURL string) error { + endpointURL = strings.TrimRight(endpointURL, "/") + if _, err := c.doNoAuth(ctx, http.MethodPost, endpointURL+stateResetAPIPath, nil); err != nil { + return fmt.Errorf("reset sandbox state: %w", err) + } + return nil +} + +func (c *Client) do(ctx context.Context, method, url string, body io.Reader) (json.RawMessage, error) { + return c.doRequest(ctx, method, url, body, true) +} + +func (c *Client) doNoAuth(ctx context.Context, method, url string, body io.Reader) (json.RawMessage, error) { + return c.doRequest(ctx, method, url, body, false) +} + +func (c *Client) doRequest(ctx context.Context, method, url string, body io.Reader, platformAuth bool) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if platformAuth { + c.setAuth(req) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer c.closeBody(resp.Body) + + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusAccepted: + case http.StatusNoContent: + return nil, nil + case http.StatusNotFound: + return nil, ErrNotFound + default: + detail, _ := io.ReadAll(resp.Body) + if len(detail) > 0 { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(detail))) + } + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + return respBody, nil +} + +// WaitForDeletion polls Describe until the instance returns 404 or timeout elapses. +// Transient request errors are swallowed and retried. +func (c *Client) WaitForDeletion(ctx context.Context, sink output.Sink, name string, timeout time.Duration) error { + sink.Emit(output.SpinnerStart(fmt.Sprintf("Waiting for instance %q to be deleted", name))) + defer sink.Emit(output.SpinnerStop()) + + deadline := time.Now().Add(timeout) + for { + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s waiting for instance %q to be deleted", timeout, name) + } + + _, err := c.Describe(ctx, name) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + c.logger.Info("sandbox poll error (will retry): %v", err) + } + + timer := time.NewTimer(2 * time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (c *Client) setAuth(req *http.Request) { + if c.authToken == "" { + return + } + encoded := base64.StdEncoding.EncodeToString([]byte(":" + c.authToken)) + req.Header.Set("Authorization", "Basic "+encoded) +} + +func (c *Client) closeBody(body io.ReadCloser) { + if err := body.Close(); err != nil { + c.logger.Error("failed to close response body: %v", err) + } +} + +func parseInstances(body []byte) ([]Instance, error) { + var direct []Instance + if err := json.Unmarshal(body, &direct); err == nil { + return direct, nil + } + var wrapped map[string][]map[string]any + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + var raw []map[string]any + for _, key := range []string{"instances", "items", "data"} { + if v, ok := wrapped[key]; ok { + raw = v + break + } + } + if raw == nil { + return nil, fmt.Errorf("sandbox list response did not contain instances") + } + instances := make([]Instance, 0, len(raw)) + for _, m := range raw { + instances = append(instances, Instance{ + Name: fieldString(m, "instance_name", "instanceName", "name", "id"), + Status: fieldString(m, "status"), + Endpoint: fieldString(m, "endpoint_url", "endpointUrl", "endpoint"), + Expires: fieldString(m, "expiry_time", "expiryTime", "expires_at", "expiresAt", "expires"), + }) + } + return instances, nil +} + +func parseInstance(body []byte) (Instance, error) { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return Instance{}, err + } + return Instance{ + Name: fieldString(m, "instance_name", "instanceName", "name", "id"), + Status: fieldString(m, "status"), + Endpoint: fieldString(m, "endpoint_url", "endpointUrl", "endpoint"), + Expires: fieldString(m, "expiry_time", "expiryTime", "expires_at", "expiresAt", "expires"), + }, nil +} + +func parseLogLines(body []byte) ([]string, error) { + var records []map[string]any + if err := json.Unmarshal(body, &records); err == nil { + lines := make([]string, 0, len(records)) + for _, record := range records { + line := fieldString(record, "content", "message", "line") + if line != "" { + lines = append(lines, line) + } + } + return lines, nil + } + var lines []string + if err := json.Unmarshal(body, &lines); err != nil { + return nil, err + } + return lines, nil +} + +func fieldString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key]; ok { + return stringify(value) + } + } + return "" +} + +func stringify(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case float64: + if math.Trunc(v) == v { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + case map[string]any: + return compactJSON(v) + case []any: + return compactJSON(v) + default: + return fmt.Sprint(v) + } +} + +func compactJSON(value any) string { + data, err := json.Marshal(value) + if err != nil { + return fmt.Sprint(value) + } + return string(data) +} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go new file mode 100644 index 00000000..c39f35c7 --- /dev/null +++ b/internal/sandbox/sandbox_test.go @@ -0,0 +1,163 @@ +package sandbox + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientCreateSendsExpectedPayloadAndAuth(t *testing.T) { + var gotAuth string + var gotPayload map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/compute/instances", r.URL.Path) + gotAuth = r.Header.Get("Authorization") + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotPayload)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"instance_name":"dev","status":"pending"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + body, err := client.Create(context.Background(), CreateOptions{ + Name: "dev", + LifetimeMinutes: 90, + EnvVars: map[string]string{ + "DEBUG": "1", + }, + }) + require.NoError(t, err) + + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(":test-token")) + assert.Equal(t, expectedAuth, gotAuth) + assert.Equal(t, "dev", gotPayload["instance_name"]) + assert.Equal(t, float64(90), gotPayload["lifetime"]) + assert.Equal(t, map[string]any{"DEBUG": "1"}, gotPayload["env_vars"]) + assert.JSONEq(t, `{"instance_name":"dev","status":"pending"}`, string(body)) +} + +func TestClientDescribeMapsNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/compute/instances/missing", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + _, err := client.Describe(context.Background(), "missing") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestClientDescribeReturnsTypedInstance(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/compute/instances/my-box", r.URL.Path) + _, _ = w.Write([]byte(`{"instance_name":"my-box","status":"running","endpoint_url":"https://my-box.localstack.cloud","expiry_time":"2026-05-05T12:00:00Z"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + inst, err := client.Describe(context.Background(), "my-box") + require.NoError(t, err) + assert.Equal(t, "my-box", inst.Name) + assert.Equal(t, "running", inst.Status) + assert.Equal(t, "https://my-box.localstack.cloud", inst.Endpoint) + assert.Equal(t, "2026-05-05T12:00:00Z", inst.Expires) +} + +func TestClientListReturnsTypedInstances(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/compute/instances", r.URL.Path) + _, _ = w.Write([]byte(`{"instances":[{"instance_name":"a","status":"running","endpoint_url":"http://a.local","expiry_time":"2026-05-05T10:00:00Z"},{"instance_name":"b","status":"stopped","endpoint_url":"","expiry_time":""}]}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + instances, err := client.List(context.Background()) + require.NoError(t, err) + require.Len(t, instances, 2) + assert.Equal(t, "a", instances[0].Name) + assert.Equal(t, "running", instances[0].Status) + assert.Equal(t, "http://a.local", instances[0].Endpoint) + assert.Equal(t, "2026-05-05T10:00:00Z", instances[0].Expires) + assert.Equal(t, "b", instances[1].Name) +} + +func TestClientLogsReturnsTypedLines(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/compute/instances/my-box/logs", r.URL.Path) + _, _ = w.Write([]byte(`[{"content":"line one"},{"content":"line two"}]`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + lines, err := client.Logs(context.Background(), "my-box") + require.NoError(t, err) + assert.Equal(t, []string{"line one", "line two"}, lines) +} + +func TestClientResetStateDoesNotSendPlatformAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/_localstack/state/reset", r.URL.Path) + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := NewClient("https://api.localstack.cloud", "test-token", log.Nop()) + require.NoError(t, client.ResetState(context.Background(), srv.URL)) + assert.Empty(t, gotAuth) +} + +func TestClientWaitForDeletionRetriesTransientErrors(t *testing.T) { + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/compute/instances/to-delete", r.URL.Path) + attempts++ + switch attempts { + case 1, 2: + w.WriteHeader(http.StatusInternalServerError) + case 3: + w.WriteHeader(http.StatusNotFound) + default: + t.Fatalf("unexpected attempt %d", attempts) + } + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + sink := output.NewPlainSink(nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := client.WaitForDeletion(ctx, sink, "to-delete", 5*time.Second) + require.NoError(t, err) + assert.Equal(t, 3, attempts) +} + +func TestClientWaitForDeletionReturnsErrorOnTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"instance_name":"stuck","status":"running"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-token", log.Nop()) + sink := output.NewPlainSink(nil) + ctx := context.Background() + + err := client.WaitForDeletion(ctx, sink, "stuck", 100*time.Millisecond) + require.Error(t, err) + assert.ErrorContains(t, err, "timed out") +} diff --git a/test/integration/sandbox_test.go b/test/integration/sandbox_test.go new file mode 100644 index 00000000..03016516 --- /dev/null +++ b/test/integration/sandbox_test.go @@ -0,0 +1,129 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSandboxCommandsUsePlatformAPI(t *testing.T) { + t.Parallel() + + var resetMu sync.Mutex + resetCalls := 0 + resetSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/_localstack/state/reset" { + resetMu.Lock() + resetCalls++ + resetMu.Unlock() + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + })) + defer resetSrv.Close() + + var mu sync.Mutex + var createPayload map[string]any + var deleted bool + apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Basic OnRlc3QtdG9rZW4=", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/compute/instances": + require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload)) + _, _ = w.Write([]byte(`{"instance_name":"dev","status":"pending","endpoint_url":"` + resetSrv.URL + `"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/compute/instances": + _, _ = w.Write([]byte(`[{"instance_name":"dev","status":"running","endpoint_url":"` + resetSrv.URL + `","expiry_time":1893456000}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/compute/instances/dev": + _, _ = w.Write([]byte(`{"instance_name":"dev","status":"running","endpoint_url":"` + resetSrv.URL + `"}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/compute/instances/dev": + deleted = true + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/v1/compute/instances/dev/logs": + _, _ = w.Write([]byte(`[{"content":"ready"},{"content":"serving"}]`)) + default: + http.NotFound(w, r) + } + })) + defer apiSrv.Close() + + e := sandboxTestEnv(t, apiSrv.URL) + ctx := testContext(t) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), e, "sandbox", "create", "dev", "--timeout", "90", "-e", "DEBUG=1") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, `"instance_name":"dev"`) + mu.Lock() + assert.Equal(t, "dev", createPayload["instance_name"]) + assert.Equal(t, float64(90), createPayload["lifetime"]) + assert.Equal(t, map[string]any{"DEBUG": "1"}, createPayload["env_vars"]) + mu.Unlock() + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "list") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "NAME") + assert.Contains(t, stdout, "dev") + assert.Contains(t, stdout, "running") + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "describe", "--name", "dev") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, `"status":"running"`) + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "url", "dev") + require.NoError(t, err, stderr) + assert.Equal(t, resetSrv.URL, stdout) + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "logs", "dev") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "ready") + assert.Contains(t, stdout, "serving") + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "reset", "dev") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, `Reset sandbox instance "dev"`) + resetMu.Lock() + assert.Equal(t, 1, resetCalls) + resetMu.Unlock() + + stdout, stderr, err = runLstk(t, ctx, t.TempDir(), e, "sandbox", "delete", "dev") + require.NoError(t, err, stderr) + assert.Contains(t, stdout, `Deleted sandbox instance "dev"`) + mu.Lock() + assert.True(t, deleted) + mu.Unlock() +} + +func TestSandboxCreateRejectsInvalidEnv(t *testing.T) { + t.Parallel() + + apiSrv := httptest.NewServer(http.NotFoundHandler()) + defer apiSrv.Close() + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), sandboxTestEnv(t, apiSrv.URL), "sandbox", "create", "dev", "-e", "DEBUG") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, `invalid environment variable "DEBUG"`) +} + +func sandboxTestEnv(t *testing.T, apiEndpoint string) []string { + t.Helper() + tmpHome := t.TempDir() + xdgConfigHome := filepath.Join(tmpHome, "xdg-config-home") + return env.Environ(testEnvWithHome(tmpHome, xdgConfigHome)). + Without(env.AuthToken, env.APIEndpoint, env.DisableEvents). + With(env.AuthToken, "test-token"). + With(env.APIEndpoint, apiEndpoint). + With(env.DisableEvents, "1") +} From d8dd6f352c03e506757e3108c43f68817c433766 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Mon, 4 May 2026 17:34:49 +0300 Subject: [PATCH 2/2] Fix list subcommand --- internal/sandbox/sandbox.go | 54 ++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index db8162e1..4a0f6932 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -35,10 +35,10 @@ type CreateOptions struct { } type Instance struct { - Name string - Status string - Endpoint string - Expires string + Name string `json:"instance_name"` + Status string `json:"status"` + Endpoint string `json:"endpoint_url"` + Expires string `json:"expiry_time"` } type Client struct { @@ -225,34 +225,28 @@ func (c *Client) closeBody(body io.ReadCloser) { } func parseInstances(body []byte) ([]Instance, error) { - var direct []Instance - if err := json.Unmarshal(body, &direct); err == nil { - return direct, nil + var items []any + if err := json.Unmarshal(body, &items); err == nil { + instances := make([]Instance, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + instances = append(instances, instanceFromMap(m)) + } + return instances, nil } var wrapped map[string][]map[string]any if err := json.Unmarshal(body, &wrapped); err != nil { return nil, err } - var raw []map[string]any for _, key := range []string{"instances", "items", "data"} { if v, ok := wrapped[key]; ok { - raw = v - break + return instancesFromMaps(v), nil } } - if raw == nil { - return nil, fmt.Errorf("sandbox list response did not contain instances") - } - instances := make([]Instance, 0, len(raw)) - for _, m := range raw { - instances = append(instances, Instance{ - Name: fieldString(m, "instance_name", "instanceName", "name", "id"), - Status: fieldString(m, "status"), - Endpoint: fieldString(m, "endpoint_url", "endpointUrl", "endpoint"), - Expires: fieldString(m, "expiry_time", "expiryTime", "expires_at", "expiresAt", "expires"), - }) - } - return instances, nil + return nil, fmt.Errorf("sandbox list response did not contain instances") } func parseInstance(body []byte) (Instance, error) { @@ -260,12 +254,24 @@ func parseInstance(body []byte) (Instance, error) { if err := json.Unmarshal(body, &m); err != nil { return Instance{}, err } + return instanceFromMap(m), nil +} + +func instancesFromMaps(maps []map[string]any) []Instance { + instances := make([]Instance, 0, len(maps)) + for _, m := range maps { + instances = append(instances, instanceFromMap(m)) + } + return instances +} + +func instanceFromMap(m map[string]any) Instance { return Instance{ Name: fieldString(m, "instance_name", "instanceName", "name", "id"), Status: fieldString(m, "status"), Endpoint: fieldString(m, "endpoint_url", "endpointUrl", "endpoint"), Expires: fieldString(m, "expiry_time", "expiryTime", "expires_at", "expiresAt", "expires"), - }, nil + } } func parseLogLines(body []byte) ([]string, error) {