Skip to content

Commit 45e88dc

Browse files
feat: added auto pause
1 parent 3637eef commit 45e88dc

4 files changed

Lines changed: 171 additions & 31 deletions

File tree

cmd/sandbox/create.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"strings"
7+
"time"
78

89
"github.com/pterm/pterm"
910
"github.com/urfave/cli/v2"
@@ -75,6 +76,10 @@ Examples:
7576
Name: "disk",
7677
Usage: "S3 disk to mount at creation (repeatable): <name|id>:/mount/path",
7778
},
79+
&cli.StringFlag{
80+
Name: "auto-pause",
81+
Usage: "Pause the sandbox automatically after this long with no activity — e.g. 10m, 1h, 30m. Leave empty to keep it running until you stop it.",
82+
},
7883
},
7984
Action: runCreate,
8085
}
@@ -172,6 +177,14 @@ func runCreate(c *cli.Context) error {
172177
req.Disks = disks
173178
}
174179

180+
if raw := strings.TrimSpace(c.String("auto-pause")); raw != "" {
181+
secs, parseErr := parseDurationToSeconds(raw)
182+
if parseErr != nil {
183+
return fmt.Errorf("--auto-pause %q: %w", raw, parseErr)
184+
}
185+
req.AutoPauseAfterSeconds = &secs
186+
}
187+
175188
spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") //nolint:errcheck
176189
resp, err := client.CreateSandbox(c.Context, req)
177190
if err != nil {
@@ -280,4 +293,34 @@ func printCreateResult(resp *api.SandboxCreateResp) {
280293
fmt.Printf(" %s\n", resp.IngressURLTemplate)
281294
pterm.Println(pterm.Gray(" Replace <port> with the port your service is listening on."))
282295
}
296+
297+
if resp.AutoPauseAfterSeconds != nil {
298+
d := time.Duration(*resp.AutoPauseAfterSeconds) * time.Second
299+
pterm.Println(pterm.Gray(fmt.Sprintf(" Will pause automatically after %s with no activity.", formatDuration(d))))
300+
}
301+
}
302+
303+
// parseDurationToSeconds parses human durations like "10m", "1h", "30m" into
304+
// seconds. Returns an error for values outside 60–86400.
305+
func parseDurationToSeconds(s string) (int, error) {
306+
d, err := time.ParseDuration(s)
307+
if err != nil {
308+
return 0, fmt.Errorf("use a duration like 10m, 1h, or 30m")
309+
}
310+
secs := int(d.Seconds())
311+
if secs < 60 || secs > 86400 {
312+
return 0, fmt.Errorf("must be between 1 minute (1m) and 24 hours (24h)")
313+
}
314+
return secs, nil
315+
}
316+
317+
// formatDuration renders a duration in the most readable unit (e.g. "10m", "1h").
318+
func formatDuration(d time.Duration) string {
319+
if d >= time.Hour && d%time.Hour == 0 {
320+
return fmt.Sprintf("%dh", int(d.Hours()))
321+
}
322+
if d >= time.Minute && d%time.Minute == 0 {
323+
return fmt.Sprintf("%dm", int(d.Minutes()))
324+
}
325+
return d.String()
283326
}

cmd/sandbox/edit.go

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sandbox
33
import (
44
"fmt"
55
"strings"
6+
"time"
67

78
"github.com/pterm/pterm"
89
"github.com/urfave/cli/v2"
@@ -37,6 +38,10 @@ func newEditCommand() *cli.Command {
3738
Name: "add-ssh-key",
3839
Usage: "Path to a public-key file to add (repeatable)",
3940
},
41+
&cli.StringFlag{
42+
Name: "auto-pause",
43+
Usage: "Pause automatically after this long with no activity (e.g. 10m, 1h). Use `off` to disable.",
44+
},
4045
},
4146
Action: runEdit,
4247
}
@@ -51,8 +56,8 @@ func runEdit(c *cli.Context) error {
5156
// urfave/cli v2 stops flag parsing at the first positional, so
5257
// `edit my-sb --ingress on` loses `--ingress`. Re-scan args by hand
5358
// so users can put flags anywhere.
54-
ref, ingressFlag, sshFiles := parseEditArgs(c)
55-
hasFlagChanges := ingressFlag != "" || len(sshFiles) > 0
59+
ref, ingressFlag, autoPauseFlag, sshFiles := parseEditArgs(c)
60+
hasFlagChanges := ingressFlag != "" || autoPauseFlag != "" || len(sshFiles) > 0
5661

5762
// Resolve the sandbox first — either from positional or via picker.
5863
id, label, err := resolveTarget(c, client, ref)
@@ -73,6 +78,11 @@ func runEdit(c *cli.Context) error {
7378
return err
7479
}
7580
}
81+
if autoPauseFlag != "" {
82+
if err := applyAutoPauseFlag(c, client, label, id, autoPauseFlag); err != nil {
83+
return err
84+
}
85+
}
7686
if len(sshFiles) > 0 {
7787
if err := applyAddSSHKeys(c, client, label, id, sshFiles); err != nil {
7888
return err
@@ -83,7 +93,7 @@ func runEdit(c *cli.Context) error {
8393

8494
// No flags — interactive only.
8595
if !terminal.IsInteractive() {
86-
return fmt.Errorf("nothing to do — pass --ingress or --add-ssh-key, or run again on a terminal for an interactive menu")
96+
return fmt.Errorf("nothing to do — pass --ingress, --auto-pause, or --add-ssh-key, or run again on a terminal for an interactive menu")
8797
}
8898
return runEditMenu(c, client, label, id)
8999
}
@@ -93,10 +103,9 @@ func runEdit(c *cli.Context) error {
93103
// as the sandbox ref, and recognises `--ingress <value>`,
94104
// `--ingress=<value>`, `--add-ssh-key <path>`, `--add-ssh-key=<path>`
95105
// in any position.
96-
func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) {
97-
// Start with whatever urfave/cli already parsed (covers
98-
// flags-before-positional). Use as defaults.
106+
func parseEditArgs(c *cli.Context) (ref, ingressVal, autoPauseVal string, sshPaths []string) {
99107
ingressVal = strings.ToLower(strings.TrimSpace(c.String("ingress")))
108+
autoPauseVal = strings.TrimSpace(c.String("auto-pause"))
100109
sshPaths = append([]string{}, c.StringSlice("add-ssh-key")...)
101110

102111
args := c.Args().Slice()
@@ -110,6 +119,13 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) {
110119
}
111120
case strings.HasPrefix(a, "--ingress="):
112121
ingressVal = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--ingress=")))
122+
case a == "--auto-pause":
123+
if i+1 < len(args) {
124+
autoPauseVal = strings.TrimSpace(args[i+1])
125+
i++
126+
}
127+
case strings.HasPrefix(a, "--auto-pause="):
128+
autoPauseVal = strings.TrimSpace(strings.TrimPrefix(a, "--auto-pause="))
113129
case a == "--add-ssh-key":
114130
if i+1 < len(args) {
115131
sshPaths = append(sshPaths, strings.TrimSpace(args[i+1]))
@@ -123,7 +139,7 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) {
123139
}
124140
}
125141
}
126-
return ref, ingressVal, sshPaths
142+
return ref, ingressVal, autoPauseVal, sshPaths
127143
}
128144

129145
// resolveTarget figures out which sandbox the user wants to edit. With
@@ -156,7 +172,11 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er
156172

157173
fmt.Println()
158174
pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" Editing %s", refLabel(label, id))
159-
header := fmt.Sprintf(" Public URL: %s SSH keys: %d", onOff(sb.IngressEnabled), len(sb.SSHPubkeys))
175+
autoPauseLabel := "off"
176+
if sb.AutoPauseAfterSeconds != nil {
177+
autoPauseLabel = "pauses after " + formatDuration(time.Duration(*sb.AutoPauseAfterSeconds)*time.Second) + " idle"
178+
}
179+
header := fmt.Sprintf(" Public URL: %s SSH keys: %d Auto-pause: %s", onOff(sb.IngressEnabled), len(sb.SSHPubkeys), autoPauseLabel)
160180
if bw != nil {
161181
bwLine := fmt.Sprintf("%s used of %s", humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes))
162182
if bw.Capped {
@@ -171,11 +191,12 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er
171191
optIngress = "Toggle public URL"
172192
optSSH = "Add an SSH key"
173193
optBandwidth = "Top up bandwidth"
194+
optAutoPause = "Auto-pause when idle"
174195
optDone = "Done"
175196
)
176197
for {
177198
choice, err := pterm.DefaultInteractiveSelect.
178-
WithOptions([]string{optIngress, optSSH, optBandwidth, optDone}).
199+
WithOptions([]string{optIngress, optSSH, optBandwidth, optAutoPause, optDone}).
179200
WithDefaultText("What would you like to change?").
180201
Show()
181202
if err != nil {
@@ -258,6 +279,28 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er
258279
humanBytes(bytes),
259280
humanBytes(updated.UsedBytes), humanBytes(updated.QuotaBytes),
260281
humanBytes(updated.RemainingBytes))
282+
case optAutoPause:
283+
current := "off"
284+
if sb.AutoPauseAfterSeconds != nil {
285+
current = formatDuration(time.Duration(*sb.AutoPauseAfterSeconds) * time.Second)
286+
}
287+
input, err := pterm.DefaultInteractiveTextInput.
288+
WithDefaultText(fmt.Sprintf("Pause after how long with no activity? (current: %s — e.g. 10m, 1h, or 'off')", current)).
289+
Show()
290+
if err != nil {
291+
return fmt.Errorf("could not read your input: %w", err)
292+
}
293+
input = strings.TrimSpace(input)
294+
if input == "" {
295+
continue
296+
}
297+
if err := applyAutoPauseFlag(c, client, label, id, input); err != nil {
298+
pterm.Error.Printfln("%v", err)
299+
continue
300+
}
301+
if refreshed, err := client.GetSandbox(c.Context, id); err == nil {
302+
sb = refreshed
303+
}
261304
case optDone:
262305
return nil
263306
}
@@ -308,6 +351,32 @@ func applyAddSSHKeys(c *cli.Context, client *api.SandboxClient, label, id string
308351
return nil
309352
}
310353

354+
// applyAutoPauseFlag handles --auto-pause <value>: "off" disables, a duration enables.
355+
func applyAutoPauseFlag(c *cli.Context, client *api.SandboxClient, label, id, value string) error {
356+
var seconds *int
357+
switch strings.ToLower(value) {
358+
case "off", "disable", "false", "no":
359+
// leave seconds nil → disable
360+
default:
361+
secs, err := parseDurationToSeconds(value)
362+
if err != nil {
363+
return fmt.Errorf("--auto-pause %q: %w", value, err)
364+
}
365+
seconds = &secs
366+
}
367+
updated, err := client.SetAutoPause(c.Context, id, seconds)
368+
if err != nil {
369+
return err
370+
}
371+
if updated.AutoPauseAfterSeconds != nil {
372+
d := time.Duration(*updated.AutoPauseAfterSeconds) * time.Second
373+
pterm.Success.Printfln("Auto-pause set to %s for %s", formatDuration(d), refLabel(label, id))
374+
} else {
375+
pterm.Success.Printfln("Auto-pause turned off for %s", refLabel(label, id))
376+
}
377+
return nil
378+
}
379+
311380
// onOff renders true/false as the verb the user typed mentally.
312381
func onOff(v bool) string {
313382
if v {

internal/api/sandbox.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,31 @@ func (c *SandboxClient) SetSandboxIngress(ctx context.Context, id string, enable
220220
return &envelope.Data, nil
221221
}
222222

223+
// SetAutoPause sets or clears the idle-pause timeout on a sandbox.
224+
// Pass nil to turn auto-pause off; pass a pointer to seconds (60–86400) to enable.
225+
func (c *SandboxClient) SetAutoPause(ctx context.Context, id string, seconds *int) (*SandboxView, error) {
226+
var body map[string]any
227+
if seconds == nil {
228+
body = map[string]any{"disable_auto_pause": true}
229+
} else {
230+
body = map[string]any{"auto_pause_after_seconds": *seconds}
231+
}
232+
var envelope Response[SandboxView]
233+
resp, err := c.Client.R().
234+
SetContext(ctx).
235+
SetPathParam("id", id).
236+
SetBody(body).
237+
SetResult(&envelope).
238+
Patch("/v1/sandboxes/{id}")
239+
if err != nil {
240+
return nil, err
241+
}
242+
if resp.IsError() {
243+
return nil, ParseAPIError(resp.StatusCode(), resp.Body())
244+
}
245+
return &envelope.Data, nil
246+
}
247+
223248
// ── Disks ─────────────────────────────────────────────────────────
224249

225250
// CreateDisk registers an S3 bucket as a named disk the caller can

internal/api/sandbox_types.go

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ import "time"
1212
// SandboxCreateReq is the body of POST /v1/sandboxes.
1313
// `host_id` is deliberately absent — pinning was removed from the API.
1414
type SandboxCreateReq struct {
15-
Name string `json:"name,omitempty"`
16-
Shape string `json:"shape"`
17-
Rootfs string `json:"rootfs,omitempty"`
18-
DiskMib int64 `json:"disk_mib,omitempty"`
19-
SSHPubkeys []string `json:"ssh_pubkeys,omitempty"`
20-
Egress []string `json:"egress,omitempty"`
21-
Envs map[string]string `json:"envs,omitempty"`
22-
IngressEnabled bool `json:"ingress_enabled,omitempty"`
23-
Networks []SandboxNetworkAttach `json:"networks,omitempty"`
24-
Disks []SandboxDiskAttach `json:"disks,omitempty"`
15+
Name string `json:"name,omitempty"`
16+
Shape string `json:"shape"`
17+
Rootfs string `json:"rootfs,omitempty"`
18+
DiskMib int64 `json:"disk_mib,omitempty"`
19+
SSHPubkeys []string `json:"ssh_pubkeys,omitempty"`
20+
Egress []string `json:"egress,omitempty"`
21+
Envs map[string]string `json:"envs,omitempty"`
22+
IngressEnabled bool `json:"ingress_enabled,omitempty"`
23+
Networks []SandboxNetworkAttach `json:"networks,omitempty"`
24+
Disks []SandboxDiskAttach `json:"disks,omitempty"`
25+
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
2526
}
2627

2728
// SandboxNetworkAttach binds a sandbox to a private network at create time.
@@ -85,18 +86,19 @@ type SandboxForkReq struct {
8586
// SandboxCreateResp is the response body for POST /v1/sandboxes.
8687
// `mode` is deliberately absent — it's an internal boot-path detail.
8788
type SandboxCreateResp struct {
88-
ID string `json:"id"`
89-
Name *string `json:"name,omitempty"`
90-
IP string `json:"ip"`
91-
Shape string `json:"shape"`
92-
Rootfs *string `json:"rootfs,omitempty"`
93-
VCPU int `json:"vcpu"`
94-
MemMib int `json:"mem_mib"`
95-
DiskMib int64 `json:"disk_mib"`
96-
SpawnMs float64 `json:"spawn_ms,omitempty"`
97-
Egress []string `json:"egress,omitempty"`
98-
BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"`
99-
IngressURLTemplate string `json:"ingress_url_template,omitempty"`
89+
ID string `json:"id"`
90+
Name *string `json:"name,omitempty"`
91+
IP string `json:"ip"`
92+
Shape string `json:"shape"`
93+
Rootfs *string `json:"rootfs,omitempty"`
94+
VCPU int `json:"vcpu"`
95+
MemMib int `json:"mem_mib"`
96+
DiskMib int64 `json:"disk_mib"`
97+
SpawnMs float64 `json:"spawn_ms,omitempty"`
98+
Egress []string `json:"egress,omitempty"`
99+
BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"`
100+
IngressURLTemplate string `json:"ingress_url_template,omitempty"`
101+
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
100102
}
101103

102104
// SandboxView is the projection returned by GET /v1/sandboxes and
@@ -126,6 +128,7 @@ type SandboxView struct {
126128
PausedAt *time.Time `json:"paused_at,omitempty"`
127129
LastResumedAt *time.Time `json:"last_resumed_at,omitempty"`
128130
ForkedFrom *string `json:"forked_from,omitempty"`
131+
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
129132
}
130133

131134
// ── List shape ────────────────────────────────────────────────────

0 commit comments

Comments
 (0)