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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ Sensitive and noisy files are always excluded: `.env`, `.env.*`, secrets/keys (`

Sandboxes are fast-booting VMs — isolated environments you can exec into, sync files to, tunnel ports through, and snapshot at will.

Anywhere a command takes a sandbox, you can pass its full ID, its name, or
just the first few characters of either — the same shortcut Docker allows:

```bash
createos sandbox rm sb-01243e # enough of the ID to be unique
createos sandbox get my-box # the name you gave it
createos sandbox exec my-b -- ls # enough of the name to be unique
```

If what you type matches more than one sandbox, the CLI lists the matches and
asks for a few more characters rather than guessing.

| Command | Description |
| ---------------------------------- | ------------------------------------------------------------- |
| `createos sandbox create` | Create a new sandbox |
Expand Down
162 changes: 133 additions & 29 deletions cmd/sandbox/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sandbox
import (
"context"
"fmt"
"net/http"
"sort"
"strings"

Expand Down Expand Up @@ -52,48 +53,151 @@ func splitForceFlag(args []string) (refs []string, force bool) {
}

// resolveSandboxRef resolves a sandbox identifier supplied on the CLI.
// The user can pass either a raw id (`sb-<ulid>`) or a friendly name
// they set at create time. Names are unique within a session but the
// API doesn't enforce uniqueness — when multiple sandboxes share the
// same name, the most-recently-created one wins (matches the
// "what they probably meant" intuition).
//
// Behavior:
// - Input that already starts with `sb-` is returned verbatim — no
// extra round-trip. We let the actual operation (GET / DELETE)
// surface the "not found" if the id is bogus.
// - Otherwise we list the caller's sandboxes (any status, up to 200)
// and pick the most recent with a matching name.
// - Whitespace is trimmed; comparison is case-sensitive (matches
// how the server stores the name).
//
// Returns a friendly error pointing at `sandbox list` when no match
// is found.
// The user can pass a full id (`sb-<ulid>`), an unambiguous leading
// chunk of one (`sb-01243e`), a friendly name, or a leading chunk of a
// name. It lists the caller's sandboxes (any status, up to 200) and
// hands the rows to matchSandboxRef, which holds all the matching
// rules — see there for precedence and ambiguity behavior.
func resolveSandboxRef(ctx context.Context, client *api.SandboxClient, ref string) (string, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", fmt.Errorf("please provide a sandbox ID or name")
}
if strings.HasPrefix(ref, sandboxIDPrefix) {
return ref, nil
}

rows, _, err := client.ListSandboxes(ctx, api.ListSandboxesOpts{Limit: 200})
if err != nil {
// Prefix matching needs the list, but an id-shaped ref used to
// resolve without one. Keep that working when listing fails so a
// flaky list endpoint can't break `rm sb-<full-id>`; the real
// operation still reports an authoritative error if the id is
// wrong. A name-shaped ref has no such fallback.
if strings.HasPrefix(ref, sandboxIDPrefix) {
return ref, nil
}
return "", err
}
matches := make([]api.SandboxView, 0)
return matchSandboxRef(rows, ref)
}

// matchSandboxRef picks the sandbox a user meant from the list they can
// see. It is pure so the matching rules can be unit-tested without an
// API — SandboxClient wraps a live resty client with no mock seam.
//
// Precedence is most-specific-first, the same shape Docker uses for
// container refs:
//
// ref starts with `sb-` → treated as an id
// exact id match → that sandbox (ids are unique)
// one id prefix match → that sandbox
// many prefix matches → ambiguous; ask for more characters
// no match → the ref verbatim, so the server decides
// otherwise → treated as a name
// exact name match → most-recently-created, since the API does
// not enforce unique names
// one name prefix hit → that sandbox
// many prefix hits → ambiguous; ask for more characters
// no match → friendly error pointing at `sandbox list`
//
// Falling back to the verbatim ref on a zero-match id is deliberate:
// the visible list is capped, so a valid id outside that window (or a
// destroyed sandbox) must still reach the API for an authoritative
// answer rather than getting a wrong "not found" from the CLI.
//
// Comparison is case-sensitive throughout, matching how the server
// stores names.
func matchSandboxRef(rows []api.SandboxView, ref string) (string, error) {
if strings.HasPrefix(ref, sandboxIDPrefix) {
var prefixed []api.SandboxView
for _, r := range rows {
if r.ID == ref {
return r.ID, nil
}
if strings.HasPrefix(r.ID, ref) {
prefixed = append(prefixed, r)
}
}
switch len(prefixed) {
case 0:
return ref, nil
case 1:
return prefixed[0].ID, nil
default:
return "", ambiguousRefError(ref, prefixed)
}
}

var exact, prefixed []api.SandboxView
for _, r := range rows {
if r.Name != nil && *r.Name == ref {
matches = append(matches, r)
if r.Name == nil {
continue
}
switch {
case *r.Name == ref:
exact = append(exact, r)
case strings.HasPrefix(*r.Name, ref):
prefixed = append(prefixed, r)
}
}
if len(exact) > 0 {
return mostRecent(exact).ID, nil
}
if len(matches) == 0 {
return "", fmt.Errorf("no sandbox named %q\n\n To see your sandboxes, run:\n createos sandbox list", ref)
switch len(prefixed) {
case 0:
// APIError rather than a bare fmt.Errorf so the text survives
// api.UserMessage, which rewrites any other error type into a
// generic "something went wrong" (see internal/api/types.go).
return "", &api.APIError{
StatusCode: http.StatusNotFound,
Message: fmt.Sprintf("no sandbox matching %q\n\n To see your sandboxes, run:\n createos sandbox list", ref),
}
case 1:
return prefixed[0].ID, nil
default:
return "", ambiguousRefError(ref, prefixed)
}
// Most-recent wins. Stable sort so deterministic when timestamps tie.
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].CreatedAt.After(matches[j].CreatedAt)
}

// mostRecent returns the newest sandbox of the bunch. Stable sort keeps
// the pick deterministic when timestamps tie.
func mostRecent(rows []api.SandboxView) api.SandboxView {
sorted := make([]api.SandboxView, len(rows))
copy(sorted, rows)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
})
return matches[0].ID, nil
return sorted[0]
}

// ambiguousRefErrorLimit caps how many candidates an ambiguity error
// lists, so a very short prefix doesn't flood the terminal.
const ambiguousRefErrorLimit = 10

// ambiguousRefError explains which sandboxes a prefix hit and asks for
// more characters. Candidates are listed newest first so the one the
// user most likely meant is at the top.
func ambiguousRefError(ref string, matches []api.SandboxView) error {
sorted := make([]api.SandboxView, len(matches))
copy(sorted, matches)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
})

var b strings.Builder
fmt.Fprintf(&b, "%q matches %d sandboxes:\n", ref, len(sorted))
for i, r := range sorted {
if i == ambiguousRefErrorLimit {
fmt.Fprintf(&b, " … and %d more\n", len(sorted)-i)
break
}
if r.Name != nil && *r.Name != "" {
fmt.Fprintf(&b, " %s (%s)\n", r.ID, *r.Name)
} else {
fmt.Fprintf(&b, " %s\n", r.ID)
}
}
b.WriteString("\n Type more characters to pick just one, or run:\n createos sandbox list")
// APIError so the text survives api.UserMessage — see the not-found
// branch in matchSandboxRef for why. 400: the ref is under-specified,
// not missing.
return &api.APIError{StatusCode: http.StatusBadRequest, Message: b.String()}
}
Loading