Skip to content

Commit ea016ac

Browse files
committed
Add sandbox command
1 parent e3c4ced commit ea016ac

6 files changed

Lines changed: 982 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
6363
This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations.
6464
The deprecated `lstk config profile` command still works but points users to `lstk setup aws`.
6565

66+
# Sandbox Commands
67+
68+
Use `lstk sandbox <command>` to manage cloud-hosted LocalStack sandbox instances:
69+
- `lstk sandbox create <name> [--timeout 60] [-e KEY=VALUE ...]` — Create a sandbox instance. `--timeout` is in minutes (matches the API payload).
70+
- `lstk sandbox list` — List sandbox instances in a table.
71+
- `lstk sandbox describe <name>` — Print the raw JSON instance state.
72+
- `lstk sandbox delete <name> [--wait] [--timeout 5m]` — Delete a sandbox instance, optionally polling until deletion completes.
73+
- `lstk sandbox logs <name>` — Print current instance logs.
74+
- `lstk sandbox url <name>` — Print only the endpoint URL for scripting, e.g. `AWS_ENDPOINT_URL=$(lstk sandbox url <name>)`.
75+
- `lstk sandbox reset <name>` — Reset all LocalStack state by calling `/_localstack/state/reset` on the sandbox endpoint.
76+
77+
Use positional `<name>` for the primary sandbox identifier. Hidden `--name` compatibility aliases may exist for migration from `localstack ephemeral`, but new help/docs should use positionals.
78+
Keep sandbox commands cloud-only for now; do not add a `--runtime` dimension unless the local/cloud sandbox lifecycle design is revisited explicitly.
79+
6680
Environment variables:
6781
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
6882
- `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).

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
7777
newUpdateCmd(cfg),
7878
newDocsCmd(),
7979
newAWSCmd(cfg),
80+
newSandboxCmd(cfg, logger),
8081
)
8182

8283
return root

cmd/sandbox.go

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"strings"
9+
"time"
10+
11+
"github.com/localstack/lstk/internal/env"
12+
"github.com/localstack/lstk/internal/log"
13+
"github.com/localstack/lstk/internal/output"
14+
"github.com/localstack/lstk/internal/sandbox"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func newSandboxCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "sandbox",
21+
Short: "Manage cloud-hosted LocalStack sandbox instances",
22+
Long: "Manage cloud-hosted LocalStack sandbox instances.",
23+
}
24+
cmd.AddCommand(
25+
newSandboxCreateCmd(cfg, logger),
26+
newSandboxListCmd(cfg, logger),
27+
newSandboxDescribeCmd(cfg, logger),
28+
newSandboxDeleteCmd(cfg, logger),
29+
newSandboxLogsCmd(cfg, logger),
30+
newSandboxURLCmd(cfg, logger),
31+
newSandboxResetCmd(cfg, logger),
32+
)
33+
return cmd
34+
}
35+
36+
func newSandboxCreateCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
37+
var (
38+
name string
39+
timeout int
40+
envVars []string
41+
)
42+
cmd := &cobra.Command{
43+
Use: "create <name>",
44+
Short: "Create a sandbox instance",
45+
Args: cobra.MaximumNArgs(1),
46+
PreRunE: initConfig,
47+
RunE: func(cmd *cobra.Command, args []string) error {
48+
name, err := sandboxName(args, name)
49+
if err != nil {
50+
return err
51+
}
52+
if timeout <= 0 {
53+
return fmt.Errorf("--timeout must be greater than 0")
54+
}
55+
parsedEnv, err := parseSandboxEnv(envVars)
56+
if err != nil {
57+
return err
58+
}
59+
client, err := newSandboxClient(cfg, logger)
60+
if err != nil {
61+
return err
62+
}
63+
body, err := client.Create(cmd.Context(), sandbox.CreateOptions{
64+
Name: name,
65+
LifetimeMinutes: timeout,
66+
EnvVars: parsedEnv,
67+
})
68+
if err != nil {
69+
return err
70+
}
71+
emitRawJSON(output.NewPlainSink(os.Stdout), body)
72+
return nil
73+
},
74+
}
75+
cmd.Flags().IntVar(&timeout, "timeout", 60, "Instance lifetime in minutes")
76+
cmd.Flags().StringArrayVarP(&envVars, "env", "e", nil, "Environment variable to pass to the instance (KEY=VALUE)")
77+
addHiddenSandboxNameFlag(cmd, &name)
78+
return cmd
79+
}
80+
81+
func newSandboxListCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
82+
cmd := &cobra.Command{
83+
Use: "list",
84+
Short: "List sandbox instances",
85+
Args: cobra.NoArgs,
86+
PreRunE: initConfig,
87+
RunE: func(cmd *cobra.Command, _ []string) error {
88+
client, err := newSandboxClient(cfg, logger)
89+
if err != nil {
90+
return err
91+
}
92+
instances, err := client.List(cmd.Context())
93+
if err != nil {
94+
return err
95+
}
96+
sink := output.NewPlainSink(os.Stdout)
97+
if len(instances) == 0 {
98+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No sandbox instances found"})
99+
return nil
100+
}
101+
rows := make([][]string, 0, len(instances))
102+
for _, inst := range instances {
103+
rows = append(rows, []string{inst.Name, inst.Status, inst.Endpoint, inst.Expires})
104+
}
105+
sink.Emit(output.TableEvent{
106+
Headers: []string{"Name", "Status", "Endpoint", "Expires"},
107+
Rows: rows,
108+
})
109+
return nil
110+
},
111+
}
112+
return cmd
113+
}
114+
115+
func newSandboxDescribeCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
116+
var name string
117+
cmd := &cobra.Command{
118+
Use: "describe <name>",
119+
Short: "Show the current state of a sandbox instance",
120+
Args: cobra.MaximumNArgs(1),
121+
PreRunE: initConfig,
122+
RunE: func(cmd *cobra.Command, args []string) error {
123+
name, err := sandboxName(args, name)
124+
if err != nil {
125+
return err
126+
}
127+
client, err := newSandboxClient(cfg, logger)
128+
if err != nil {
129+
return err
130+
}
131+
instance, err := client.Describe(cmd.Context(), name)
132+
if err != nil {
133+
if errors.Is(err, sandbox.ErrNotFound) {
134+
return fmt.Errorf("sandbox instance %q not found", name)
135+
}
136+
return err
137+
}
138+
emitRawJSON(output.NewPlainSink(os.Stdout), []byte(fmt.Sprintf(`{"name":%q,"status":%q,"endpoint":%q,"expires":%q}`, instance.Name, instance.Status, instance.Endpoint, instance.Expires)))
139+
return nil
140+
},
141+
}
142+
addHiddenSandboxNameFlag(cmd, &name)
143+
return cmd
144+
}
145+
146+
func newSandboxDeleteCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
147+
var (
148+
name string
149+
wait bool
150+
waitTimeout time.Duration
151+
)
152+
cmd := &cobra.Command{
153+
Use: "delete <name>",
154+
Short: "Delete a sandbox instance",
155+
Args: cobra.MaximumNArgs(1),
156+
PreRunE: initConfig,
157+
RunE: func(cmd *cobra.Command, args []string) error {
158+
name, err := sandboxName(args, name)
159+
if err != nil {
160+
return err
161+
}
162+
if waitTimeout <= 0 {
163+
return fmt.Errorf("--wait-timeout must be greater than 0")
164+
}
165+
client, err := newSandboxClient(cfg, logger)
166+
if err != nil {
167+
return err
168+
}
169+
sink := output.NewPlainSink(os.Stdout)
170+
171+
if err := client.Delete(cmd.Context(), name); err != nil {
172+
if errors.Is(err, sandbox.ErrNotFound) {
173+
return fmt.Errorf("sandbox instance %q not found", name)
174+
}
175+
return err
176+
}
177+
178+
if wait {
179+
if err := client.WaitForDeletion(cmd.Context(), sink, name, waitTimeout); err != nil {
180+
return err
181+
}
182+
}
183+
184+
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Deleted sandbox instance %q", name)})
185+
return nil
186+
},
187+
}
188+
cmd.Flags().BoolVar(&wait, "wait", false, "Wait until the instance is fully deleted")
189+
cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 5*time.Minute, "Maximum time to wait for deletion when --wait is set")
190+
addHiddenSandboxNameFlag(cmd, &name)
191+
return cmd
192+
}
193+
194+
func newSandboxLogsCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
195+
var name string
196+
cmd := &cobra.Command{
197+
Use: "logs <name>",
198+
Short: "Fetch logs from a sandbox instance",
199+
Args: cobra.MaximumNArgs(1),
200+
PreRunE: initConfig,
201+
RunE: func(cmd *cobra.Command, args []string) error {
202+
name, err := sandboxName(args, name)
203+
if err != nil {
204+
return err
205+
}
206+
client, err := newSandboxClient(cfg, logger)
207+
if err != nil {
208+
return err
209+
}
210+
lines, err := client.Logs(cmd.Context(), name)
211+
if err != nil {
212+
if errors.Is(err, sandbox.ErrNotFound) {
213+
return fmt.Errorf("sandbox instance %q not found", name)
214+
}
215+
return err
216+
}
217+
sink := output.NewPlainSink(os.Stdout)
218+
if len(lines) == 0 {
219+
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No logs available for this instance"})
220+
return nil
221+
}
222+
for _, line := range lines {
223+
sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line})
224+
}
225+
return nil
226+
},
227+
}
228+
addHiddenSandboxNameFlag(cmd, &name)
229+
return cmd
230+
}
231+
232+
func newSandboxURLCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
233+
var name string
234+
cmd := &cobra.Command{
235+
Use: "url <name>",
236+
Short: "Print the sandbox endpoint URL",
237+
Args: cobra.MaximumNArgs(1),
238+
PreRunE: initConfig,
239+
RunE: func(cmd *cobra.Command, args []string) error {
240+
name, err := sandboxName(args, name)
241+
if err != nil {
242+
return err
243+
}
244+
client, err := newSandboxClient(cfg, logger)
245+
if err != nil {
246+
return err
247+
}
248+
endpoint, err := resolveSandboxEndpoint(cmd.Context(), client, name)
249+
if err != nil {
250+
return err
251+
}
252+
output.NewPlainSink(os.Stdout).Emit(output.MessageEvent{Text: endpoint})
253+
return nil
254+
},
255+
}
256+
addHiddenSandboxNameFlag(cmd, &name)
257+
return cmd
258+
}
259+
260+
func newSandboxResetCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
261+
var name string
262+
cmd := &cobra.Command{
263+
Use: "reset <name>",
264+
Short: "Reset all state in a running sandbox instance",
265+
Args: cobra.MaximumNArgs(1),
266+
PreRunE: initConfig,
267+
RunE: func(cmd *cobra.Command, args []string) error {
268+
name, err := sandboxName(args, name)
269+
if err != nil {
270+
return err
271+
}
272+
client, err := newSandboxClient(cfg, logger)
273+
if err != nil {
274+
return err
275+
}
276+
endpoint, err := resolveSandboxEndpoint(cmd.Context(), client, name)
277+
if err != nil {
278+
return err
279+
}
280+
if err := client.ResetState(cmd.Context(), endpoint); err != nil {
281+
return err
282+
}
283+
output.NewPlainSink(os.Stdout).Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Reset sandbox instance %q", name)})
284+
return nil
285+
},
286+
}
287+
addHiddenSandboxNameFlag(cmd, &name)
288+
return cmd
289+
}
290+
291+
func newSandboxClient(cfg *env.Env, logger log.Logger) (*sandbox.Client, error) {
292+
if cfg.AuthToken == "" {
293+
return nil, fmt.Errorf("authentication required: run `lstk login` or set LOCALSTACK_AUTH_TOKEN")
294+
}
295+
return sandbox.NewClient(cfg.APIEndpoint, cfg.AuthToken, logger), nil
296+
}
297+
298+
func addHiddenSandboxNameFlag(cmd *cobra.Command, target *string) {
299+
cmd.Flags().StringVar(target, "name", "", "Name of the sandbox instance")
300+
_ = cmd.Flags().MarkHidden("name")
301+
}
302+
303+
func resolveSandboxEndpoint(ctx context.Context, client *sandbox.Client, name string) (string, error) {
304+
instance, err := client.Describe(ctx, name)
305+
if err != nil {
306+
if errors.Is(err, sandbox.ErrNotFound) {
307+
return "", fmt.Errorf("sandbox instance %q not found", name)
308+
}
309+
return "", err
310+
}
311+
if instance.Endpoint == "" {
312+
return "", fmt.Errorf("sandbox instance %q has no endpoint URL", name)
313+
}
314+
return instance.Endpoint, nil
315+
}
316+
317+
func sandboxName(args []string, flagValue string) (string, error) {
318+
if len(args) == 1 && flagValue != "" {
319+
return "", fmt.Errorf("provide the sandbox name either as an argument or with --name, not both")
320+
}
321+
if len(args) == 1 {
322+
return args[0], nil
323+
}
324+
if flagValue != "" {
325+
return flagValue, nil
326+
}
327+
return "", fmt.Errorf("sandbox name is required")
328+
}
329+
330+
func parseSandboxEnv(values []string) (map[string]string, error) {
331+
result := make(map[string]string, len(values))
332+
for _, value := range values {
333+
key, val, ok := strings.Cut(value, "=")
334+
if !ok {
335+
return nil, fmt.Errorf("invalid environment variable %q: expected KEY=VALUE", value)
336+
}
337+
key = strings.TrimSpace(key)
338+
if key == "" {
339+
return nil, fmt.Errorf("invalid environment variable %q: key cannot be empty", value)
340+
}
341+
result[key] = strings.TrimSpace(val)
342+
}
343+
return result, nil
344+
}
345+
346+
func emitRawJSON(sink output.Sink, body []byte) {
347+
sink.Emit(output.MessageEvent{Text: strings.TrimSpace(string(body))})
348+
}

0 commit comments

Comments
 (0)