Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions cmd/sandbox/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"

"github.com/pterm/pterm"
"github.com/urfave/cli/v2"
Expand Down Expand Up @@ -75,6 +76,10 @@ Examples:
Name: "disk",
Usage: "S3 disk to mount at creation (repeatable): <name|id>:/mount/path",
},
&cli.StringFlag{
Name: "auto-pause",
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.",
},
},
Action: runCreate,
}
Expand Down Expand Up @@ -172,6 +177,14 @@ func runCreate(c *cli.Context) error {
req.Disks = disks
}

if raw := strings.TrimSpace(c.String("auto-pause")); raw != "" {
secs, parseErr := parseDurationToSeconds(raw)
if parseErr != nil {
return fmt.Errorf("--auto-pause %q: %w", raw, parseErr)
}
req.AutoPauseAfterSeconds = &secs
}

spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") //nolint:errcheck
resp, err := client.CreateSandbox(c.Context, req)
if err != nil {
Expand Down Expand Up @@ -280,4 +293,34 @@ func printCreateResult(resp *api.SandboxCreateResp) {
fmt.Printf(" %s\n", resp.IngressURLTemplate)
pterm.Println(pterm.Gray(" Replace <port> with the port your service is listening on."))
}

if resp.AutoPauseAfterSeconds != nil {
d := time.Duration(*resp.AutoPauseAfterSeconds) * time.Second
pterm.Println(pterm.Gray(fmt.Sprintf(" Will pause automatically after %s with no activity.", formatDuration(d))))
}
}

// parseDurationToSeconds parses human durations like "10m", "1h", "30m" into
// seconds. Returns an error for values outside 60–86400.
func parseDurationToSeconds(s string) (int, error) {
d, err := time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("use a duration like 10m, 1h, or 30m")
}
secs := int(d.Seconds())
if secs < 60 || secs > 86400 {
return 0, fmt.Errorf("must be between 1 minute (1m) and 24 hours (24h)")
}
return secs, nil
}

// formatDuration renders a duration in the most readable unit (e.g. "10m", "1h").
func formatDuration(d time.Duration) string {
if d >= time.Hour && d%time.Hour == 0 {
return fmt.Sprintf("%dh", int(d.Hours()))
}
if d >= time.Minute && d%time.Minute == 0 {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
return d.String()
}
87 changes: 78 additions & 9 deletions cmd/sandbox/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sandbox
import (
"fmt"
"strings"
"time"

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

// Resolve the sandbox first — either from positional or via picker.
id, label, err := resolveTarget(c, client, ref)
Expand All @@ -73,6 +78,11 @@ func runEdit(c *cli.Context) error {
return err
}
}
if autoPauseFlag != "" {
if err := applyAutoPauseFlag(c, client, label, id, autoPauseFlag); err != nil {
return err
}
}
if len(sshFiles) > 0 {
if err := applyAddSSHKeys(c, client, label, id, sshFiles); err != nil {
return err
Expand All @@ -83,7 +93,7 @@ func runEdit(c *cli.Context) error {

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

args := c.Args().Slice()
Expand All @@ -110,6 +119,13 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) {
}
case strings.HasPrefix(a, "--ingress="):
ingressVal = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--ingress=")))
case a == "--auto-pause":
if i+1 < len(args) {
autoPauseVal = strings.TrimSpace(args[i+1])
i++
}
case strings.HasPrefix(a, "--auto-pause="):
autoPauseVal = strings.TrimSpace(strings.TrimPrefix(a, "--auto-pause="))
case a == "--add-ssh-key":
if i+1 < len(args) {
sshPaths = append(sshPaths, strings.TrimSpace(args[i+1]))
Expand All @@ -123,7 +139,7 @@ func parseEditArgs(c *cli.Context) (ref, ingressVal string, sshPaths []string) {
}
}
}
return ref, ingressVal, sshPaths
return ref, ingressVal, autoPauseVal, sshPaths
}

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

fmt.Println()
pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" Editing %s", refLabel(label, id))
header := fmt.Sprintf(" Public URL: %s SSH keys: %d", onOff(sb.IngressEnabled), len(sb.SSHPubkeys))
autoPauseLabel := "off"
if sb.AutoPauseAfterSeconds != nil {
autoPauseLabel = "pauses after " + formatDuration(time.Duration(*sb.AutoPauseAfterSeconds)*time.Second) + " idle"
}
header := fmt.Sprintf(" Public URL: %s SSH keys: %d Auto-pause: %s", onOff(sb.IngressEnabled), len(sb.SSHPubkeys), autoPauseLabel)
if bw != nil {
bwLine := fmt.Sprintf("%s used of %s", humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes))
if bw.Capped {
Expand All @@ -171,11 +191,12 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er
optIngress = "Toggle public URL"
optSSH = "Add an SSH key"
optBandwidth = "Top up bandwidth"
optAutoPause = "Auto-pause when idle"
optDone = "Done"
)
for {
choice, err := pterm.DefaultInteractiveSelect.
WithOptions([]string{optIngress, optSSH, optBandwidth, optDone}).
WithOptions([]string{optIngress, optSSH, optBandwidth, optAutoPause, optDone}).
WithDefaultText("What would you like to change?").
Show()
if err != nil {
Expand Down Expand Up @@ -258,6 +279,28 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er
humanBytes(bytes),
humanBytes(updated.UsedBytes), humanBytes(updated.QuotaBytes),
humanBytes(updated.RemainingBytes))
case optAutoPause:
current := "off"
if sb.AutoPauseAfterSeconds != nil {
current = formatDuration(time.Duration(*sb.AutoPauseAfterSeconds) * time.Second)
}
input, err := pterm.DefaultInteractiveTextInput.
WithDefaultText(fmt.Sprintf("Pause after how long with no activity? (current: %s — e.g. 10m, 1h, or 'off')", current)).
Show()
if err != nil {
return fmt.Errorf("could not read your input: %w", err)
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
if err := applyAutoPauseFlag(c, client, label, id, input); err != nil {
pterm.Error.Printfln("%v", err)
continue
}
if refreshed, err := client.GetSandbox(c.Context, id); err == nil {
sb = refreshed
}
case optDone:
return nil
}
Expand Down Expand Up @@ -308,6 +351,32 @@ func applyAddSSHKeys(c *cli.Context, client *api.SandboxClient, label, id string
return nil
}

// applyAutoPauseFlag handles --auto-pause <value>: "off" disables, a duration enables.
func applyAutoPauseFlag(c *cli.Context, client *api.SandboxClient, label, id, value string) error {
var seconds *int
switch strings.ToLower(value) {
case "off", "disable", "false", "no":
// leave seconds nil → disable
default:
secs, err := parseDurationToSeconds(value)
if err != nil {
return fmt.Errorf("--auto-pause %q: %w", value, err)
}
seconds = &secs
}
updated, err := client.SetAutoPause(c.Context, id, seconds)
if err != nil {
return err
}
if updated.AutoPauseAfterSeconds != nil {
d := time.Duration(*updated.AutoPauseAfterSeconds) * time.Second
pterm.Success.Printfln("Auto-pause set to %s for %s", formatDuration(d), refLabel(label, id))
} else {
pterm.Success.Printfln("Auto-pause turned off for %s", refLabel(label, id))
}
return nil
}

// onOff renders true/false as the verb the user typed mentally.
func onOff(v bool) string {
if v {
Expand Down
16 changes: 14 additions & 2 deletions internal/api/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,24 @@ func (c *SandboxClient) AddSSHPubkeys(ctx context.Context, id string, keys []str
// the updated SandboxView (with ingress_url_template when enabled and
// the cluster knows its domain suffix).
func (c *SandboxClient) SetSandboxIngress(ctx context.Context, id string, enabled bool) (*SandboxView, error) {
body := map[string]bool{"ingress_enabled": enabled}
return c.patchSandbox(ctx, id, SandboxPatchReq{IngressEnabled: &enabled})
}

// SetAutoPause sets or clears the idle-pause timeout on a sandbox.
// Pass nil to turn auto-pause off; pass a pointer to seconds (60–86400) to enable.
func (c *SandboxClient) SetAutoPause(ctx context.Context, id string, seconds *int) (*SandboxView, error) {
if seconds == nil {
return c.patchSandbox(ctx, id, SandboxPatchReq{DisableAutoPause: true})
}
return c.patchSandbox(ctx, id, SandboxPatchReq{AutoPauseAfterSeconds: seconds})
}

func (c *SandboxClient) patchSandbox(ctx context.Context, id string, req SandboxPatchReq) (*SandboxView, error) {
var envelope Response[SandboxView]
resp, err := c.Client.R().
SetContext(ctx).
SetPathParam("id", id).
SetBody(body).
SetBody(req).
SetResult(&envelope).
Patch("/v1/sandboxes/{id}")
if err != nil {
Expand Down
55 changes: 33 additions & 22 deletions internal/api/sandbox_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ import "time"
// SandboxCreateReq is the body of POST /v1/sandboxes.
// `host_id` is deliberately absent — pinning was removed from the API.
type SandboxCreateReq struct {
Name string `json:"name,omitempty"`
Shape string `json:"shape"`
Rootfs string `json:"rootfs,omitempty"`
DiskMib int64 `json:"disk_mib,omitempty"`
SSHPubkeys []string `json:"ssh_pubkeys,omitempty"`
Egress []string `json:"egress,omitempty"`
Envs map[string]string `json:"envs,omitempty"`
IngressEnabled bool `json:"ingress_enabled,omitempty"`
Networks []SandboxNetworkAttach `json:"networks,omitempty"`
Disks []SandboxDiskAttach `json:"disks,omitempty"`
Name string `json:"name,omitempty"`
Shape string `json:"shape"`
Rootfs string `json:"rootfs,omitempty"`
DiskMib int64 `json:"disk_mib,omitempty"`
SSHPubkeys []string `json:"ssh_pubkeys,omitempty"`
Egress []string `json:"egress,omitempty"`
Envs map[string]string `json:"envs,omitempty"`
IngressEnabled bool `json:"ingress_enabled,omitempty"`
Networks []SandboxNetworkAttach `json:"networks,omitempty"`
Disks []SandboxDiskAttach `json:"disks,omitempty"`
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
}

// SandboxPatchReq is the body of PATCH /v1/sandboxes/:id.
// Only non-nil / non-zero fields are sent; omitempty keeps unset ones out of the wire.
type SandboxPatchReq struct {
IngressEnabled *bool `json:"ingress_enabled,omitempty"`
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
DisableAutoPause bool `json:"disable_auto_pause,omitempty"`
}

// SandboxNetworkAttach binds a sandbox to a private network at create time.
Expand Down Expand Up @@ -85,18 +94,19 @@ type SandboxForkReq struct {
// SandboxCreateResp is the response body for POST /v1/sandboxes.
// `mode` is deliberately absent — it's an internal boot-path detail.
type SandboxCreateResp struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
IP string `json:"ip"`
Shape string `json:"shape"`
Rootfs *string `json:"rootfs,omitempty"`
VCPU int `json:"vcpu"`
MemMib int `json:"mem_mib"`
DiskMib int64 `json:"disk_mib"`
SpawnMs float64 `json:"spawn_ms,omitempty"`
Egress []string `json:"egress,omitempty"`
BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"`
IngressURLTemplate string `json:"ingress_url_template,omitempty"`
ID string `json:"id"`
Name *string `json:"name,omitempty"`
IP string `json:"ip"`
Shape string `json:"shape"`
Rootfs *string `json:"rootfs,omitempty"`
VCPU int `json:"vcpu"`
MemMib int `json:"mem_mib"`
DiskMib int64 `json:"disk_mib"`
SpawnMs float64 `json:"spawn_ms,omitempty"`
Egress []string `json:"egress,omitempty"`
BandwidthQuotaBytes int64 `json:"bandwidth_quota_bytes,omitempty"`
IngressURLTemplate string `json:"ingress_url_template,omitempty"`
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
}

// SandboxView is the projection returned by GET /v1/sandboxes and
Expand Down Expand Up @@ -126,6 +136,7 @@ type SandboxView struct {
PausedAt *time.Time `json:"paused_at,omitempty"`
LastResumedAt *time.Time `json:"last_resumed_at,omitempty"`
ForkedFrom *string `json:"forked_from,omitempty"`
AutoPauseAfterSeconds *int `json:"auto_pause_after_seconds,omitempty"`
}

// ── List shape ────────────────────────────────────────────────────
Expand Down