diff --git a/README.md b/README.md index c2f8fd7..c468e24 100644 --- a/README.md +++ b/README.md @@ -363,10 +363,11 @@ under `packages/orca-plugin`. **When to use `exec`, `shell`, `process`, and PTY:** -Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox shell` when you want an immediate interactive terminal and do not need to reconnect later. Use `sandbox process` when the command should be manageable after it starts — list it, reconnect to output, send input, wait for it, signal it, or stop it. Add `--pty`/`--tty`/`-t` to `process run` or `process start` when the managed command needs terminal behavior. +Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox run` when you want a fresh devbox sandbox that runs a Docker image for you. Use `sandbox shell` when you want an immediate interactive terminal and do not need to reconnect later. Use `sandbox process` when the command should be manageable after it starts — list it, reconnect to output, send input, wait for it, signal it, or stop it. Add `--pty`/`--tty`/`-t` to `process run` or `process start` when the managed command needs terminal behavior. | Command | Description | | -------------------------------------------- | ------------------------------------------------------------ | +| `createos sandbox run [args…]` | Create a devbox sandbox and run a Docker image inside it | | `createos sandbox process run -- ` | Run a managed command, stream output, and return its exit code | | `createos sandbox process start -- ` | Start a managed command and print its process ID | | `createos sandbox process shell ` | Start a persistent shell session that can be reattached | @@ -395,9 +396,9 @@ Interactive attach without a process ID shows running managed processes. Pick a | `createos sandbox disk rm ` | Delete a disk (auto-detaches first) | | `createos sandbox network create ` | Create a private network | | `createos sandbox network ls` | List your networks | -| `createos sandbox network show ` | Show a network and its attached sandboxes | -| `createos sandbox network attach ` | Add a sandbox to a network | -| `createos sandbox network detach ` | Remove a sandbox from a network | +| `createos sandbox network show ` | Show a network and its attached sandbox members | +| `createos sandbox network attach ` | Add a sandbox or device to a network | +| `createos sandbox network detach ` | Remove a sandbox or device from a network | | `createos sandbox network rm ` | Delete a network (auto-detaches first) | | `createos sandbox firewall show ` | Show what the sandbox is allowed to reach | | `createos sandbox firewall set ` | Replace the outbound allowlist | @@ -568,6 +569,24 @@ createos sandbox rm my-box --force createos sandbox shapes createos sandbox rootfs +# Sandbox run +createos sandbox run nginx --local 8080 --remote 80 --rm +createos sandbox run postgres \ + --disk pg-data,/data:/var/lib/postgresql/data \ + --local 5432 --remote 5432 \ + --env POSTGRES_PASSWORD=secret \ + --rm +createos sandbox run my-app:local --push-local --env NODE_ENV=development --rm +createos sandbox run nginx \ + --sync ./site,/workspace:/usr/share/nginx/html \ + --local 8080 --remote 80 \ + --rm + +# `--disk ,:` attaches the disk at +# in the sandbox, then mounts that path into the Docker container. +# `--sync ,:` syncs a local directory to +# the sandbox first, then mounts that sandbox path into the Docker container. + # Sandbox sync createos sandbox sync my-box --local ~/work/project --remote /root/work createos sandbox sync my-box --exclude '*.log' --exclude node_modules # skip files (repeatable) @@ -587,7 +606,9 @@ createos sandbox disk rm my-data --yes createos sandbox network create my-net createos sandbox network ls createos sandbox network attach my-net my-box +createos sandbox network attach my-net createos sandbox network detach my-net my-box --yes +createos sandbox network detach my-net --yes createos sandbox network rm my-net --yes # Sandbox firewall diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 0877c9c..efea5cd 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -684,7 +684,7 @@ func preflightVPN(c *cli.Context, client *api.SandboxClient, sandboxID string) e pterm.Warning.Println("this sandbox and your device aren't in the same network yet.") pterm.Println() pterm.Println(" Add the sandbox to a network your device is in:") - pterm.Println(" createos sandbox network attach " + sandboxID + " ") + pterm.Println(" createos sandbox network attach " + sandboxID) pterm.Println() pterm.Println(" Or add the device to a network the sandbox is in:") pterm.Println(" createos sandbox devices attach ") diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go index 0d5d977..dea61c0 100644 --- a/cmd/sandbox/network.go +++ b/cmd/sandbox/network.go @@ -321,9 +321,9 @@ func newNetworkAttachCommand() *cli.Command { } // isDeviceRef reports whether ref looks like a device id (dev-…) — used -// so `network attach dev-… ` routes to the device-attach API -// instead of the sandbox one. Plain prefix sniff: device ids are minted -// with this prefix and nothing else legitimately starts with it. +// so `network attach dev-…` routes to the device-attach API instead +// of the sandbox one. Plain prefix sniff: device ids are minted with this +// prefix and nothing else legitimately starts with it. func isDeviceRef(ref string) bool { return strings.HasPrefix(ref, "dev-") || strings.HasPrefix(ref, "dev_") } @@ -370,6 +370,9 @@ func runNetworkAttach(c *cli.Context) error { } ref = picked } + if looksLikeSandboxRef(netRef) && !looksLikeSandboxRef(ref) && !isDeviceRef(ref) { + return fmt.Errorf("network attach expects \n\n Did you mean?\n createos sandbox network attach %s %s", ref, netRef) + } if isDeviceRef(ref) { if err := client.AttachDeviceToNetwork(c.Context, ref, netRef); err != nil { return err @@ -390,6 +393,10 @@ func runNetworkAttach(c *cli.Context) error { return nil } +func looksLikeSandboxRef(ref string) bool { + return strings.HasPrefix(ref, "sb-") || strings.HasPrefix(ref, "sb_") +} + // ── detach ─────────────────────────────────────────────────────── func newNetworkDetachCommand() *cli.Command { @@ -436,7 +443,7 @@ func runNetworkDetach(c *cli.Context) error { if !tty { return fmt.Errorf("usage: createos sandbox network detach ") } - picked, err := pickEndpoint(c, client, "Detach what?") + picked, err := pickNetworkMemberEndpoint(c, client, netRef, "Detach what?") if err != nil { return err } @@ -492,8 +499,8 @@ func runNetworkDetach(c *cli.Context) error { // pickEndpoint shows a single-select picker that lists BOTH the caller's // running sandboxes and registered devices, returning whichever ref the -// user picks (sb-… or dev-…). Used by `network attach` / `network detach` -// to support attaching devices alongside sandboxes in interactive mode. +// user picks (sb-… or dev-…). Used by `network attach` to support attaching +// devices alongside sandboxes in interactive mode. func pickEndpoint(c *cli.Context, client *api.SandboxClient, title string) (string, error) { // Sandboxes (running only — same filter as the old picker). sbs, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{Limit: 200, Status: "running"}) @@ -538,6 +545,81 @@ func pickEndpoint(c *cli.Context, client *api.SandboxClient, title string) (stri return refByOpt[picked], nil } +// pickNetworkMemberEndpoint is the detach-specific picker. Unlike attach, +// detach should only offer endpoints already attached to the selected network. +func pickNetworkMemberEndpoint(c *cli.Context, client *api.SandboxClient, netRef, title string) (string, error) { + n, err := client.GetNetwork(c.Context, netRef) + if err != nil { + return "", err + } + + devs, err := client.ListDevices(c.Context) + if err != nil { + devs = nil + } + deviceNetworkRefs := make(map[string][]api.DeviceNetworkAttachmentView, len(devs)) + for _, d := range devs { + nets, nerr := client.ListDeviceNetworks(c.Context, d.ID) + if nerr != nil { + continue + } + deviceNetworkRefs[d.ID] = nets + } + + options, refByOpt := networkMemberEndpointOptions(n, devs, deviceNetworkRefs) + if len(options) == 0 { + fmt.Printf("Network %s has no attached sandboxes or devices.\n", n.Name) + return "", nil + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(title). + Show() + if err != nil { + return "", fmt.Errorf("could not read your selection: %w", err) + } + return refByOpt[picked], nil +} + +func networkMemberEndpointOptions(n *api.SandboxNetwork, devs []api.DeviceView, deviceNetworks map[string][]api.DeviceNetworkAttachmentView) ([]string, map[string]string) { + options := make([]string, 0, len(n.Members)+len(devs)) + refByOpt := make(map[string]string, len(n.Members)+len(devs)) + for _, m := range n.Members { + label := m.SandboxID + if m.Name != "" { + label = m.Name + } + details := fmt.Sprintf("id: %s", m.SandboxID) + if m.Status != "" { + details += ", status: " + m.Status + } + if m.IP != "" { + details += ", ip: " + m.IP + } + opt := fmt.Sprintf("sandbox: %s (%s)", label, details) + options = append(options, opt) + refByOpt[opt] = m.SandboxID + } + for _, d := range devs { + if !deviceAttachedToNetwork(n, deviceNetworks[d.ID]) { + continue + } + opt := fmt.Sprintf("device: %s (%s, id: %s)", d.Name, d.ClientIP, d.ID) + options = append(options, opt) + refByOpt[opt] = d.ID + } + return options, refByOpt +} + +func deviceAttachedToNetwork(n *api.SandboxNetwork, attached []api.DeviceNetworkAttachmentView) bool { + for _, a := range attached { + if a.NetworkID == n.ID || a.NetworkName == n.Name { + return true + } + } + return false +} + // pickNetwork renders a single-select picker over the caller's networks // and returns the picked NAME (the server accepts it wherever an ID // works). Returns "" when the user cancels. @@ -551,10 +633,11 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin pterm.Println(pterm.Gray(" Create one with: createos sandbox network create ")) return "", nil } + deviceCounts := countDevicesByNetwork(c, client) options := make([]string, 0, len(nets)) byOpt := make(map[string]string, len(nets)) for _, n := range nets { - opt := fmt.Sprintf("%s (sandboxes: %d, id: %s)", n.Name, n.MemberCount, n.ID) + opt := networkPickerOption(n, deviceCounts) options = append(options, opt) byOpt[opt] = n.Name } @@ -567,3 +650,35 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin } return byOpt[picked], nil } + +func countDevicesByNetwork(c *cli.Context, client *api.SandboxClient) map[string]int { + counts := make(map[string]int) + devs, err := client.ListDevices(c.Context) + if err != nil { + return counts + } + for _, d := range devs { + nets, nerr := client.ListDeviceNetworks(c.Context, d.ID) + if nerr != nil { + continue + } + for _, n := range nets { + if n.NetworkID != "" { + counts[n.NetworkID]++ + continue + } + if n.NetworkName != "" { + counts[n.NetworkName]++ + } + } + } + return counts +} + +func networkPickerOption(n api.SandboxNetwork, deviceCounts map[string]int) string { + deviceCount := deviceCounts[n.ID] + if deviceCount == 0 { + deviceCount = deviceCounts[n.Name] + } + return fmt.Sprintf("%s (sandboxes: %d, devices: %d, id: %s)", n.Name, n.MemberCount, deviceCount, n.ID) +} diff --git a/cmd/sandbox/network_test.go b/cmd/sandbox/network_test.go new file mode 100644 index 0000000..d616af1 --- /dev/null +++ b/cmd/sandbox/network_test.go @@ -0,0 +1,80 @@ +package sandbox + +import ( + "reflect" + "testing" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func TestLooksLikeSandboxRef(t *testing.T) { + t.Parallel() + + tests := []struct { + ref string + want bool + }{ + {ref: "sb-01m10y7j0qgphydk8awvmnbza3", want: true}, + {ref: "sb_01m10y7j0qgphydk8awvmnbza3", want: true}, + {ref: "bhautikin", want: false}, + {ref: "dev-01m10y7j0qgphydk8awvmnbza3", want: false}, + } + for _, tt := range tests { + if got := looksLikeSandboxRef(tt.ref); got != tt.want { + t.Fatalf("looksLikeSandboxRef(%q) = %v, want %v", tt.ref, got, tt.want) + } + } +} + +func TestNetworkMemberEndpointOptionsOnlyIncludesAttachedMembers(t *testing.T) { + t.Parallel() + + network := &api.SandboxNetwork{ + ID: "net-123", + Name: "bhautikin", + Members: []api.SandboxNetworkMember{{ + SandboxID: "sb-1", + Name: "app", + Status: "running", + IP: "10.0.0.4", + }}, + } + devs := []api.DeviceView{ + {ID: "dev-1", Name: "laptop", ClientIP: "100.64.0.8"}, + {ID: "dev-2", Name: "desktop", ClientIP: "100.64.0.9"}, + } + deviceNetworks := map[string][]api.DeviceNetworkAttachmentView{ + "dev-1": {{NetworkID: "net-123", NetworkName: "bhautikin"}}, + "dev-2": {{NetworkID: "net-other", NetworkName: "other"}}, + } + + options, refs := networkMemberEndpointOptions(network, devs, deviceNetworks) + wantOptions := []string{ + "sandbox: app (id: sb-1, status: running, ip: 10.0.0.4)", + "device: laptop (100.64.0.8, id: dev-1)", + } + if !reflect.DeepEqual(options, wantOptions) { + t.Fatalf("options = %#v, want %#v", options, wantOptions) + } + if refs[options[0]] != "sb-1" { + t.Fatalf("sandbox ref = %q", refs[options[0]]) + } + if refs[options[1]] != "dev-1" { + t.Fatalf("device ref = %q", refs[options[1]]) + } +} + +func TestDeviceAttachedToNetworkMatchesNameOrID(t *testing.T) { + t.Parallel() + + network := &api.SandboxNetwork{ID: "net-123", Name: "bhautikin"} + if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-123"}}) { + t.Fatal("expected ID match") + } + if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkName: "bhautikin"}}) { + t.Fatal("expected name match") + } + if deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-other", NetworkName: "other"}}) { + t.Fatal("unexpected match") + } +} diff --git a/cmd/sandbox/run.go b/cmd/sandbox/run.go new file mode 100644 index 0000000..fef8e5f --- /dev/null +++ b/cmd/sandbox/run.go @@ -0,0 +1,1017 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" +) + +const ( + runSandboxRootfs = "devbox:1" + runDefaultShape = "s-1vcpu-1gb" + runDiskWaitLimit = 45 * time.Second +) + +func newRunCommand() *cli.Command { + return &cli.Command{ + Name: "run", + Usage: "Run a Docker image in a fresh sandbox", + ArgsUsage: " [args...]", + Description: `Create a devbox sandbox, run a Docker image inside it, and optionally +forward a local port to the container. + +Examples: + createos sb run nginx --local 8080 --remote 80 + + createos sb run postgres \ + --disk pg-data,/data:/var/lib/postgresql/data \ + --local 5432 --remote 5432 + +Disk format: + --disk ,: + +The disk is attached to the sandbox at , then passed to Docker as +-v :.`, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "shape", Value: runDefaultShape, Usage: "Sandbox size"}, + &cli.StringFlag{Name: "name", Usage: "Friendly name for the sandbox"}, + &cli.IntFlag{Name: "local", Usage: "Local port to listen on"}, + &cli.IntFlag{Name: "remote", Usage: "Container port to expose through the sandbox"}, + &cli.StringFlag{Name: "bind", Value: "127.0.0.1", Usage: "Local address to bind to"}, + &cli.StringSliceFlag{Name: "network", Aliases: []string{"net"}, Usage: "Private network to join at creation (repeatable): "}, + &cli.GenericFlag{Name: "disk", Value: newRunDiskFlagValues(), Usage: "Disk to attach and mount into Docker (repeatable): ,:"}, + &cli.GenericFlag{Name: "sync", Value: newRunSyncFlagValues(), Usage: "Sync a local directory and mount it into Docker (repeatable): ,:"}, + &cli.StringSliceFlag{Name: "env", Usage: "Docker environment variable (repeatable): KEY=VALUE"}, + &cli.StringFlag{Name: "identity", Aliases: []string{"i"}, Usage: "SSH private key override for --sync"}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}, Value: "root", Usage: "Username inside the sandbox for --sync"}, + &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Skip prompts for explicit SSH key setup"}, + &cli.BoolFlag{Name: "force-sync", Usage: "Bypass local sensitive-path checks for --sync"}, + &cli.StringSliceFlag{Name: "exclude", Usage: "Sync ignore pattern for --sync (repeatable)"}, + &cli.StringFlag{Name: "sync-mode", Value: "two-way", Usage: "Sync direction for --sync: two-way | one-way | mirror"}, + &cli.BoolFlag{Name: "pull", Usage: "Pull the image before running it"}, + &cli.BoolFlag{Name: "push-local", Usage: "Upload a local Docker image into the sandbox before running it"}, + &cli.BoolFlag{Name: "rm", Usage: "Delete the sandbox when the container exits or this command is interrupted"}, + &cli.BoolFlag{Name: "keep-container", Usage: "Do not pass --rm to Docker"}, + &cli.BoolFlag{Name: "no-follow", Usage: "Start the container and print IDs without following output"}, + }, + Action: runDockerImage, + } +} + +type runOptions struct { + image string + imageArgs []string + shape string + name string + local int + remote int + bind string + networks []string + disks []runDiskMount + syncs []runSyncMount + envs []string + identity string + user string + forceSync bool + assumeYes bool + exclude []string + syncMode string + pull bool + pushLocal bool + removeSandbox bool + keepContainer bool + noFollow bool +} + +type runDiskMount struct { + diskID string + sandboxPath string + containerPath string +} + +type runSyncMount struct { + localPath string + sandboxPath string + containerPath string +} + +func runDockerImage(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + opts, err := parseRunArgs(c) + if err != nil { + return err + } + + req := api.SandboxCreateReq{ + Shape: opts.shape, + Name: opts.name, + Rootfs: runSandboxRootfs, + } + for _, n := range opts.networks { + req.Networks = append(req.Networks, api.SandboxNetworkAttach{ID: n}) + } + for _, d := range opts.disks { + req.Disks = append(req.Disks, api.SandboxDiskAttach{ + DiskID: d.diskID, + MountPath: d.sandboxPath, + }) + } + + spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox...") //nolint:errcheck + sb, err := client.CreateSandbox(c.Context, req) + if err != nil { + spinner.Fail("Could not create sandbox") + return err + } + spinner.Success(fmt.Sprintf("Sandbox is ready: %s", refLabel(runSandboxName(sb), sb.ID))) + if opts.removeSandbox { + defer cleanupRunSandbox(sb.ID, client) + } + if len(opts.syncs) > 0 { + cleanupSyncs, syncErr := startRunSyncs(c, client, sb.ID, runSandboxName(sb), opts) + if syncErr != nil { + return syncErr + } + defer cleanupSyncs() + } + + if len(opts.disks) > 0 { + spinner, _ = pterm.DefaultSpinner.Start("Waiting for disk mounts...") //nolint:errcheck + if diskErr := waitForRunDisks(c.Context, client, sb.ID, opts.disks); diskErr != nil { + spinner.Fail("Disk mount failed") + return diskErr + } + spinner.Success("Disks are mounted") + } + + if opts.pull { + if pullErr := runDockerPull(c, client, sb.ID, opts.image); pullErr != nil { + return pullErr + } + } + if opts.pushLocal { + if pushErr := pushLocalDockerImage(c, client, sb.ID, opts.image); pushErr != nil { + return pushErr + } + } + + dockerArgs := buildDockerRunArgs(opts) + spinner, _ = pterm.DefaultSpinner.Start("Starting container...") //nolint:errcheck + proc, err := client.CreateProcess(c.Context, sb.ID, api.ProcessCreateRequest{ + Cmd: "docker", + Args: dockerArgs, + }) + if err != nil { + spinner.Fail("Could not start container") + return err + } + spinner.Success(fmt.Sprintf("Container process started: %s", proc.ProcessID)) + + if opts.noFollow { + output.Render(c, map[string]any{ + "sandbox_id": sb.ID, + "name": runSandboxName(sb), + "process_id": proc.ProcessID, + "image": opts.image, + }, func() { + pterm.Success.Printf("Started %s in %s.\n", proc.ProcessID, refLabel(runSandboxName(sb), sb.ID)) + }) + return nil + } + + ctx, cancel := context.WithCancel(c.Context) + defer cancel() + if opts.remote > 0 { + if opts.local <= 0 { + opts.local = opts.remote + } + if opts.bind == "" { + opts.bind = "127.0.0.1" + } + ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) + if ctrlURL == "" { + ctrlURL = api.DefaultSandboxBaseURL + } + authHeader, token, authErr := sandboxAuth(c) + if authErr != nil { + return authErr + } + tunnelErr := make(chan error, 1) + go func() { + tunnelErr <- serveSandboxTunnel(ctx, tunnelSpec{ + CtrlURL: ctrlURL, + AuthHeader: authHeader, + Token: token, + SandboxID: sb.ID, + Ref: runSandboxName(sb), + Local: opts.local, + Remote: opts.remote, + Bind: opts.bind, + Announce: false, + }) + }() + if portErr := waitForPort(ctx, opts.bind, opts.local, 2*time.Second); portErr != nil { + cancel() + return portErr + } + pterm.Success.Printfln("Forwarding %s → %s:%d", net.JoinHostPort(opts.bind, strconv.Itoa(opts.local)), refLabel(runSandboxName(sb), sb.ID), opts.remote) + pterm.Println(pterm.Gray(fmt.Sprintf(" Open the local address after the container is listening on :%d.", opts.remote))) + select { + case tunnelRunErr := <-tunnelErr: + if tunnelRunErr != nil { + return tunnelRunErr + } + default: + } + } + pterm.Println(pterm.Gray(" Press Ctrl+C to stop the container and tunnel.")) + pterm.Println(pterm.Gray(" Container output:")) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + go func() { + <-sigCh + cancel() + _, _ = client.TerminateProcess(context.Background(), sb.ID, proc.ProcessID, durationMs(time.Second)) //nolint:errcheck + }() + + exitCode, signalName, err := followRunProcess(ctx, client, sb.ID, proc.ProcessID) + cancel() + if err != nil && !strings.Contains(err.Error(), "context canceled") { + return err + } + return exitFromProcess(exitCode, signalName) +} + +func parseRunArgs(c *cli.Context) (runOptions, error) { + opts := runOptions{ + shape: strings.TrimSpace(c.String("shape")), + name: strings.TrimSpace(c.String("name")), + local: c.Int("local"), + remote: c.Int("remote"), + bind: strings.TrimSpace(c.String("bind")), + networks: stringSliceCleanup(c.StringSlice("network")), + envs: c.StringSlice("env"), + identity: strings.TrimSpace(c.String("identity")), + user: strings.TrimSpace(c.String("user")), + forceSync: c.Bool("force-sync"), + assumeYes: c.Bool("yes"), + exclude: append([]string{}, c.StringSlice("exclude")...), + syncMode: c.String("sync-mode"), + pull: c.Bool("pull"), + pushLocal: c.Bool("push-local"), + removeSandbox: c.Bool("rm"), + keepContainer: c.Bool("keep-container"), + noFollow: c.Bool("no-follow"), + } + if opts.shape == "" { + opts.shape = runDefaultShape + } + for _, raw := range runDiskFlagValuesFromContext(c) { + d, err := parseRunDiskFlag(raw) + if err != nil { + return opts, err + } + opts.disks = append(opts.disks, d) + } + for _, raw := range runSyncFlagValuesFromContext(c) { + s, err := parseRunSyncFlag(raw) + if err != nil { + return opts, err + } + opts.syncs = append(opts.syncs, s) + } + + args := c.Args().Slice() + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + if opts.image != "" && i+1 < len(args) { + opts.imageArgs = append(opts.imageArgs, args[i+1:]...) + i = len(args) + } + case a == "--shape" && i+1 < len(args): + opts.shape = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--shape="): + opts.shape = strings.TrimSpace(strings.TrimPrefix(a, "--shape=")) + case a == "--name" && i+1 < len(args): + opts.name = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--name="): + opts.name = strings.TrimSpace(strings.TrimPrefix(a, "--name=")) + case a == "--local" && i+1 < len(args): + opts.local = atoiPort(args[i+1]) + i++ + case strings.HasPrefix(a, "--local="): + opts.local = atoiPort(strings.TrimPrefix(a, "--local=")) + case a == "--remote" && i+1 < len(args): + opts.remote = atoiPort(args[i+1]) + i++ + case strings.HasPrefix(a, "--remote="): + opts.remote = atoiPort(strings.TrimPrefix(a, "--remote=")) + case a == "--bind" && i+1 < len(args): + opts.bind = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--bind="): + opts.bind = strings.TrimSpace(strings.TrimPrefix(a, "--bind=")) + case (a == "--network" || a == "--net") && i+1 < len(args): + opts.networks = append(opts.networks, strings.TrimSpace(args[i+1])) + i++ + case strings.HasPrefix(a, "--network="): + opts.networks = append(opts.networks, strings.TrimSpace(strings.TrimPrefix(a, "--network="))) + case strings.HasPrefix(a, "--net="): + opts.networks = append(opts.networks, strings.TrimSpace(strings.TrimPrefix(a, "--net="))) + case a == "--disk" && i+1 < len(args): + d, err := parseRunDiskFlag(args[i+1]) + if err != nil { + return opts, err + } + opts.disks = append(opts.disks, d) + i++ + case strings.HasPrefix(a, "--disk="): + d, err := parseRunDiskFlag(strings.TrimPrefix(a, "--disk=")) + if err != nil { + return opts, err + } + opts.disks = append(opts.disks, d) + case a == "--sync" && i+1 < len(args): + s, err := parseRunSyncFlag(args[i+1]) + if err != nil { + return opts, err + } + opts.syncs = append(opts.syncs, s) + i++ + case strings.HasPrefix(a, "--sync="): + s, err := parseRunSyncFlag(strings.TrimPrefix(a, "--sync=")) + if err != nil { + return opts, err + } + opts.syncs = append(opts.syncs, s) + case a == "--env" && i+1 < len(args): + opts.envs = append(opts.envs, args[i+1]) + i++ + case strings.HasPrefix(a, "--env="): + opts.envs = append(opts.envs, strings.TrimPrefix(a, "--env=")) + case (a == "--identity" || a == "-i") && i+1 < len(args): + opts.identity = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--identity="): + opts.identity = strings.TrimSpace(strings.TrimPrefix(a, "--identity=")) + case (a == "--user" || a == "-u") && i+1 < len(args): + opts.user = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--user="): + opts.user = strings.TrimSpace(strings.TrimPrefix(a, "--user=")) + case a == "--force-sync": + opts.forceSync = true + case a == "--yes" || a == "-y": + opts.assumeYes = true + case a == "--exclude" && i+1 < len(args): + opts.exclude = append(opts.exclude, args[i+1]) + i++ + case strings.HasPrefix(a, "--exclude="): + opts.exclude = append(opts.exclude, strings.TrimPrefix(a, "--exclude=")) + case a == "--sync-mode" && i+1 < len(args): + opts.syncMode = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(a, "--sync-mode="): + opts.syncMode = strings.TrimSpace(strings.TrimPrefix(a, "--sync-mode=")) + case a == "--pull": + opts.pull = true + case a == "--push-local": + opts.pushLocal = true + case a == "--rm": + opts.removeSandbox = true + case a == "--keep-container": + opts.keepContainer = true + case a == "--no-follow": + opts.noFollow = true + case strings.HasPrefix(a, "-"): + if opts.image != "" { + opts.imageArgs = append(opts.imageArgs, a) + } + default: + if opts.image == "" { + opts.image = strings.TrimSpace(a) + } else { + opts.imageArgs = append(opts.imageArgs, a) + } + } + } + opts.networks = stringSliceCleanup(opts.networks) + if opts.user == "" { + opts.user = "root" + } + if opts.image == "" { + return opts, fmt.Errorf("please provide a Docker image\n\n Example:\n createos sb run nginx --local 8080 --remote 80") + } + if _, err := syncModeToMutagen(opts.syncMode); err != nil { + return opts, err + } + if opts.pull && opts.pushLocal { + return opts, fmt.Errorf("choose either --pull or --push-local, not both") + } + if opts.removeSandbox && opts.noFollow { + return opts, fmt.Errorf("--rm requires foreground mode; remove --no-follow or delete the sandbox manually later") + } + if opts.local > 0 && opts.remote == 0 { + return opts, fmt.Errorf("--remote is required when --local is set") + } + if opts.remote < 0 || opts.remote > 65535 || opts.local < 0 || opts.local > 65535 { + return opts, fmt.Errorf("--local and --remote must be 1-65535") + } + return opts, nil +} + +func parseRunDiskFlag(raw string) (runDiskMount, error) { + raw = strings.TrimSpace(raw) + comma := strings.IndexByte(raw, ',') + colon := strings.LastIndexByte(raw, ':') + if comma <= 0 || colon <= comma+1 || colon == len(raw)-1 { + return runDiskMount{}, fmt.Errorf("--disk %q must be ,:", raw) + } + d := runDiskMount{ + diskID: strings.TrimSpace(raw[:comma]), + sandboxPath: strings.TrimSpace(raw[comma+1 : colon]), + containerPath: strings.TrimSpace(raw[colon+1:]), + } + if d.diskID == "" || d.sandboxPath == "" || d.containerPath == "" { + return runDiskMount{}, fmt.Errorf("--disk %q must be ,:", raw) + } + if !strings.HasPrefix(d.sandboxPath, "/") || !strings.HasPrefix(d.containerPath, "/") { + return runDiskMount{}, fmt.Errorf("--disk paths must be absolute: %q", raw) + } + return d, nil +} + +func parseRunSyncFlag(raw string) (runSyncMount, error) { + raw = strings.TrimSpace(raw) + comma := strings.IndexByte(raw, ',') + colon := strings.LastIndexByte(raw, ':') + if comma <= 0 || colon <= comma+1 || colon == len(raw)-1 { + return runSyncMount{}, fmt.Errorf("--sync %q must be ,:", raw) + } + s := runSyncMount{ + localPath: strings.TrimSpace(raw[:comma]), + sandboxPath: strings.TrimSpace(raw[comma+1 : colon]), + containerPath: strings.TrimSpace(raw[colon+1:]), + } + if s.localPath == "" || s.sandboxPath == "" || s.containerPath == "" { + return runSyncMount{}, fmt.Errorf("--sync %q must be ,:", raw) + } + if !strings.HasPrefix(s.sandboxPath, "/") || !strings.HasPrefix(s.containerPath, "/") { + return runSyncMount{}, fmt.Errorf("--sync sandbox and container paths must be absolute: %q", raw) + } + return s, nil +} + +type runDiskFlagValues struct { + values []string +} + +func newRunDiskFlagValues() *runDiskFlagValues { + return &runDiskFlagValues{} +} + +func (v *runDiskFlagValues) Set(raw string) error { + raw = strings.TrimSpace(raw) + if raw != "" { + v.values = append(v.values, raw) + } + return nil +} + +func (v *runDiskFlagValues) String() string { + if v == nil { + return "" + } + return strings.Join(v.values, ",") +} + +func runDiskFlagValuesFromContext(c *cli.Context) []string { + v, ok := c.Generic("disk").(*runDiskFlagValues) + if !ok || v == nil || len(v.values) == 0 { + return nil + } + return append([]string(nil), v.values...) +} + +type runSyncFlagValues struct { + values []string +} + +func newRunSyncFlagValues() *runSyncFlagValues { + return &runSyncFlagValues{} +} + +func (v *runSyncFlagValues) Set(raw string) error { + raw = strings.TrimSpace(raw) + if raw != "" { + v.values = append(v.values, raw) + } + return nil +} + +func (v *runSyncFlagValues) String() string { + if v == nil { + return "" + } + return strings.Join(v.values, ",") +} + +func runSyncFlagValuesFromContext(c *cli.Context) []string { + v, ok := c.Generic("sync").(*runSyncFlagValues) + if !ok || v == nil || len(v.values) == 0 { + return nil + } + return append([]string(nil), v.values...) +} + +func buildDockerRunArgs(opts runOptions) []string { + args := []string{"run"} + if !opts.keepContainer { + args = append(args, "--rm") + } + if opts.remote > 0 { + port := strconv.Itoa(opts.remote) + args = append(args, "-p", "127.0.0.1:"+port+":"+port) + } + for _, env := range opts.envs { + args = append(args, "-e", env) + } + for _, d := range opts.disks { + args = append(args, "-v", d.sandboxPath+":"+d.containerPath) + } + for _, s := range opts.syncs { + args = append(args, "-v", s.sandboxPath+":"+s.containerPath) + } + args = append(args, opts.image) + args = append(args, opts.imageArgs...) + return args +} + +func runDockerPull(c *cli.Context, client *api.SandboxClient, sandboxID, image string) error { + spinner, _ := pterm.DefaultSpinner.Start("Pulling image...") //nolint:errcheck + exit, err := client.ExecSandboxStream(c.Context, sandboxID, api.SandboxExecReq{ + Cmd: "docker", + Args: []string{"pull", image}, + }, func(ev api.SandboxExecStreamEvent) { + if ev.Stdout != "" { + _, _ = os.Stdout.WriteString(ev.Stdout) //nolint:errcheck + } + if ev.Stderr != "" { + _, _ = os.Stderr.WriteString(ev.Stderr) //nolint:errcheck + } + }) + if err != nil { + spinner.Fail("Image pull failed") + return err + } + if exit != 0 { + spinner.Fail("Image pull failed") + return fmt.Errorf("docker pull exited with code %d", exit) + } + spinner.Success("Image is ready") + return nil +} + +func pushLocalDockerImage(c *cli.Context, client *api.SandboxClient, sandboxID, image string) error { + if err := inspectLocalDockerImage(c.Context, image); err != nil { + return err + } + + tmp, err := os.CreateTemp("", "createos-image-*.tar") + if err != nil { + return fmt.Errorf("create temp image archive: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() //nolint:errcheck + + spinner, _ := pterm.DefaultSpinner.Start("Saving local image...") //nolint:errcheck + var stderr bytes.Buffer + save := exec.CommandContext(c.Context, "docker", "save", image) // #nosec G204 -- image is passed as one docker CLI argument + save.Stdout = tmp + save.Stderr = &stderr + if saveErr := save.Run(); saveErr != nil { + _ = tmp.Close() //nolint:errcheck + spinner.Fail("Could not save local image") + return fmt.Errorf("docker save %s: %w%s", image, saveErr, commandDetail(stderr.String())) + } + if closeErr := tmp.Close(); closeErr != nil { + spinner.Fail("Could not save local image") + return fmt.Errorf("close image archive: %w", closeErr) + } + info, err := os.Stat(tmpPath) + if err != nil { + spinner.Fail("Could not save local image") + return fmt.Errorf("stat image archive: %w", err) + } + spinner.Success(fmt.Sprintf("Saved local image (%s)", humanBytes(info.Size()))) + + f, err := os.Open(tmpPath) // #nosec G304 -- temp path was created by this process + if err != nil { + return fmt.Errorf("open image archive: %w", err) + } + defer func() { _ = f.Close() }() //nolint:errcheck + + remote := "/tmp/createos-images/" + localImageArchiveName(image) + spinner, _ = pterm.DefaultSpinner.Start("Uploading image to sandbox...") //nolint:errcheck + if uploadErr := client.UploadFile(c.Context, sandboxID, remote, f, info.Size()); uploadErr != nil { + spinner.Fail("Could not upload image") + return uploadErr + } + spinner.Success("Uploaded image to sandbox") + + spinner, _ = pterm.DefaultSpinner.Start("Loading image in sandbox...") //nolint:errcheck + exit, err := client.ExecSandboxStream(c.Context, sandboxID, api.SandboxExecReq{ + Cmd: "docker", + Args: []string{"load", "-i", remote}, + }, func(api.SandboxExecStreamEvent) {}) + if err != nil { + spinner.Fail("Could not load image") + return err + } + if exit != 0 { + spinner.Fail("Could not load image") + return fmt.Errorf("docker load exited with code %d", exit) + } + spinner.Success("Loaded image in sandbox") + return nil +} + +func cleanupRunSandbox(sandboxID string, client *api.SandboxClient) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := client.DestroySandbox(ctx, sandboxID); err != nil { + pterm.Warning.Printfln("Could not delete sandbox %s: %s", sandboxID, api.UserMessageVerbose(err)) + return + } + pterm.Success.Printfln("Deleted sandbox %s", sandboxID) +} + +func startRunSyncs(c *cli.Context, client *api.SandboxClient, sandboxID, ref string, opts runOptions) (func(), error) { + mutagenBin, err := ensureMutagen() + if err != nil { + return nil, err + } + privPath, pubBytes, cleanupIdentity, err := resolveRunSyncIdentity(sandboxID, opts.identity) + if err != nil { + return nil, err + } + unlocked, cleanupUnlockedKey, err := unlockSSHKeyIfNeeded(privPath) + if err != nil { + cleanupIdentity() + return nil, err + } + privPath = unlocked + + cleanup := func() { + cleanupUnlockedKey() + cleanupIdentity() + } + user := opts.user + if user == "" { + user = "root" + } + if _, keyErr := client.AddSSHPubkeys(c.Context, sandboxID, []string{strings.TrimSpace(string(pubBytes))}); keyErr != nil { + cleanup() + return nil, fmt.Errorf("could not register sync key with gateway: %w", keyErr) + } + if authErr := ensureAuthorizedKey(c, client, sandboxID, user, ref, pubBytes, true); authErr != nil { + cleanup() + return nil, authErr + } + + remoteDirs := make([]string, 0, len(opts.syncs)) + resolved := make([]runSyncMount, 0, len(opts.syncs)) + for _, s := range opts.syncs { + local, lerr := validateLocalSyncPath(s.localPath, opts.forceSync) + if lerr != nil { + cleanup() + return nil, lerr + } + if rerr := validateRemoteSyncPath(s.sandboxPath); rerr != nil { + cleanup() + return nil, rerr + } + s.localPath = local + resolved = append(resolved, s) + remoteDirs = append(remoteDirs, shellQuote(s.sandboxPath)) + } + + authPath := authorizedKeysPath(user) + prepScript := fmt.Sprintf(` +set -e +if ! [ -x /usr/sbin/sshd ]; then + echo "this image does not ship sshd — use a rootfs that does (e.g. devbox:1)" >&2 + exit 100 +fi +mkdir -p %[1]s /run/sshd %[3]s +chmod 700 %[1]s +chmod 600 %[1]s/authorized_keys +chown -R %[2]s:%[2]s %[1]s 2>/dev/null || true +if ! awk 'NR>1{print $2}' /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -qi ':0016$'; then + /usr/sbin/sshd +fi +`, filepath.Dir(authPath), user, strings.Join(remoteDirs, " ")) + if pre, execErr := client.ExecSandbox(c.Context, sandboxID, api.SandboxExecReq{ + Cmd: "sh", + Args: []string{"-c", prepScript}, + }); execErr != nil { + cleanup() + return nil, fmt.Errorf("could not prepare sshd: %w", execErr) + } else if pre.Result.ExitCode == 100 { + cleanup() + return nil, fmt.Errorf("the sandbox image doesn't have sshd installed — try a rootfs that does (e.g. devbox:1)") + } else if pre.Result.ExitCode != 0 { + cleanup() + return nil, fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(pre.Result.Stderr)) + } + + ctx, cancel := context.WithCancel(c.Context) + bridge, err := startTunnelBridge(ctx, c, sandboxID, 22) + if err != nil { + cancel() + cleanup() + return nil, fmt.Errorf("could not open tunnel to the sandbox: %w", err) + } + cleanup = func() { + bridge.close() + cancel() + cleanupUnlockedKey() + cleanupIdentity() + } + if waitErr := waitForTCP(ctx, bridge.localAddr, 5*time.Second); waitErr != nil { + cleanup() + return nil, fmt.Errorf("sshd did not start in time: %w", waitErr) + } + _, port, _ := net.SplitHostPort(bridge.localAddr) //nolint:errcheck + + wrapperDir, wrapperEnv, err := makeSSHWrapper(privPath) + if err != nil { + cleanup() + return nil, fmt.Errorf("could not set up ssh wrapper: %w", err) + } + cleanup = func() { + _ = os.RemoveAll(wrapperDir) //nolint:errcheck + bridge.close() + cancel() + cleanupUnlockedKey() + cleanupIdentity() + } + _ = runMutagen(ctx, mutagenBin, wrapperEnv, io.Discard, io.Discard, "daemon", "stop") //nolint:errcheck + + syncMode, err := syncModeToMutagen(opts.syncMode) + if err != nil { + cleanup() + return nil, err + } + sessionNames := make([]string, 0, len(resolved)) + for i, s := range resolved { + sessionName := fmt.Sprintf("createos-run-%s-%d-%d", strings.ReplaceAll(sandboxID, "_", "-"), time.Now().Unix(), i) + remoteSpec := fmt.Sprintf("%s@127.0.0.1:%s:%s", user, port, s.sandboxPath) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Syncing %s...", s.localPath)) //nolint:errcheck + createArgs := runMutagenCreateArgs(sessionName, syncMode, s.localPath, remoteSpec, opts.exclude) + var createBuf bytes.Buffer + if err := runMutagen(ctx, mutagenBin, wrapperEnv, &createBuf, &createBuf, createArgs...); err != nil { + spinner.Fail("Could not start sync") + cleanup() + detail := strings.TrimSpace(createBuf.String()) + if detail != "" { + return nil, fmt.Errorf("mutagen sync create failed: %w\n%s", err, detail) + } + return nil, fmt.Errorf("mutagen sync create failed: %w", err) + } + if err := runMutagen(ctx, mutagenBin, wrapperEnv, &createBuf, &createBuf, "sync", "flush", sessionName); err != nil { + spinner.Fail("Could not flush sync") + cleanup() + detail := strings.TrimSpace(createBuf.String()) + if detail != "" { + return nil, fmt.Errorf("mutagen sync flush failed: %w\n%s", err, detail) + } + return nil, fmt.Errorf("mutagen sync flush failed: %w", err) + } + spinner.Success(fmt.Sprintf("Syncing %s -> %s", s.localPath, s.sandboxPath)) + sessionNames = append(sessionNames, sessionName) + } + return func() { + for _, name := range sessionNames { + _ = runMutagen(context.Background(), mutagenBin, wrapperEnv, io.Discard, io.Discard, "sync", "terminate", name) //nolint:errcheck + } + cleanup() + }, nil +} + +func inspectLocalDockerImage(ctx context.Context, image string) error { + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) // #nosec G204 -- image is passed as one docker CLI argument + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("local Docker image %q was not found%s", image, commandDetail(stderr.String())) + } + return nil +} + +func resolveRunSyncIdentity(sandboxID, explicit string) (privPath string, pubBytes []byte, cleanup func(), err error) { + cleanup = func() {} + if strings.TrimSpace(explicit) != "" { + priv, pub, resolveErr := resolveIdentity(explicit) + if resolveErr != nil { + return "", nil, cleanup, resolveErr + } + explicitPubBytes, readErr := os.ReadFile(pub) // #nosec G304 -- pub is paired with the user-provided private key + if readErr != nil { + return "", nil, cleanup, fmt.Errorf("could not read public key %s: %w", pub, readErr) + } + return priv, explicitPubBytes, cleanup, nil + } + + alias := sshAlias(sandboxID) + if !editorAliasRE.MatchString(alias) { + return "", nil, cleanup, fmt.Errorf("refusing to create shell-unsafe SSH alias %q", alias) + } + priv, pubBytes, generated, err := ensureDedicatedKey(alias) + if err != nil { + return "", nil, cleanup, err + } + if generated { + cleanup = func() { + removeDedicatedKey(alias) + } + } + return priv, pubBytes, cleanup, nil +} + +func localImageArchiveName(image string) string { + name := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + default: + return '-' + } + }, image) + name = strings.Trim(name, "-") + if name == "" { + name = "image" + } + return filepath.Base(name) + ".tar" +} + +func commandDetail(stderr string) string { + stderr = strings.TrimSpace(stderr) + if stderr == "" { + return "" + } + return ": " + stderr +} + +func runMutagenCreateArgs(sessionName, syncMode, local, remoteSpec string, exclude []string) []string { + args := mutagenCreateArgs(sessionName, syncMode, local, remoteSpec, true, exclude) + if len(args) < 2 { + return args + } + insert := []string{ + "--default-file-mode-beta=0644", + "--default-directory-mode-beta=0755", + } + out := make([]string, 0, len(args)+len(insert)) + out = append(out, args[:len(args)-2]...) + out = append(out, insert...) + out = append(out, args[len(args)-2:]...) + return out +} + +func waitForRunDisks(ctx context.Context, client *api.SandboxClient, sandboxID string, want []runDiskMount) error { + deadline := time.Now().Add(runDiskWaitLimit) + for { + attached, err := client.ListSandboxDisks(ctx, sandboxID) + if err != nil { + return err + } + pending := make(map[string]runDiskMount, len(want)) + for _, d := range want { + pending[d.sandboxPath] = d + } + for _, a := range attached { + d, ok := pending[a.MountPath] + if !ok { + continue + } + if strings.EqualFold(a.MountStatus, "mounted") { + delete(pending, a.MountPath) + continue + } + if strings.EqualFold(a.MountStatus, "error") || a.MountError != "" { + return fmt.Errorf("disk %s failed to mount at %s: %s", d.diskID, d.sandboxPath, strings.TrimSpace(a.MountError)) + } + } + if len(pending) == 0 { + return nil + } + if time.Now().After(deadline) { + paths := make([]string, 0, len(pending)) + for _, d := range pending { + paths = append(paths, d.diskID+","+d.sandboxPath+":"+d.containerPath) + } + return fmt.Errorf("timed out waiting for disk mounts: %s", strings.Join(paths, ", ")) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } +} + +func followRunProcess(ctx context.Context, client *api.SandboxClient, sandboxID, processID string) (*int, string, error) { + var exitCode *int + var signalName string + err := client.ConnectProcess(ctx, sandboxID, processID, 0, func(ev api.ProcessOutputEvent) { + switch ev.Type { + case "data": + if ev.DataBase64 == "" { + return + } + data, err := base64.StdEncoding.DecodeString(ev.DataBase64) + if err != nil { + return + } + if ev.Stream == "stderr" { + _, _ = os.Stderr.Write(data) //nolint:errcheck + return + } + _, _ = os.Stdout.Write(data) //nolint:errcheck + case "exit": + exitCode = ev.ExitCode + signalName = ev.Signal + case "error": + if ev.Error != "" { + pterm.Error.Println(ev.Error) + } + } + }) + return exitCode, signalName, err +} + +func waitForPort(ctx context.Context, bind string, port int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + addr := net.JoinHostPort(bind, strconv.Itoa(port)) + for { + conn, err := (&net.Dialer{Timeout: 100 * time.Millisecond}).DialContext(ctx, "tcp", addr) + if err == nil { + _ = conn.Close() //nolint:errcheck + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("could not start tunnel on %s: %w", addr, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func runSandboxName(sb *api.SandboxCreateResp) string { + if sb != nil && sb.Name != nil && strings.TrimSpace(*sb.Name) != "" { + return strings.TrimSpace(*sb.Name) + } + return "" +} + +func atoiPort(raw string) int { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0 + } + return n +} diff --git a/cmd/sandbox/run_test.go b/cmd/sandbox/run_test.go new file mode 100644 index 0000000..7874a5b --- /dev/null +++ b/cmd/sandbox/run_test.go @@ -0,0 +1,243 @@ +package sandbox + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/urfave/cli/v2" +) + +func TestParseRunDiskFlag(t *testing.T) { + t.Parallel() + + got, err := parseRunDiskFlag("pg-data,/data:/var/lib/postgresql/data") + if err != nil { + t.Fatalf("parseRunDiskFlag: %v", err) + } + if got.diskID != "pg-data" || got.sandboxPath != "/data" || got.containerPath != "/var/lib/postgresql/data" { + t.Fatalf("parsed disk = %#v", got) + } +} + +func TestParseRunDiskFlagRejectsOldCreateSyntax(t *testing.T) { + t.Parallel() + + if _, err := parseRunDiskFlag("pg-data:/var/lib/postgresql/data"); err == nil { + t.Fatal("expected old create syntax to be rejected") + } +} + +func TestBuildDockerRunArgs(t *testing.T) { + t.Parallel() + + got := buildDockerRunArgs(runOptions{ + image: "postgres", + remote: 5432, + envs: []string{"POSTGRES_PASSWORD=secret"}, + disks: []runDiskMount{{ + diskID: "pg-data", + sandboxPath: "/data", + containerPath: "/var/lib/postgresql/data", + }}, + syncs: []runSyncMount{{ + localPath: "/tmp/app", + sandboxPath: "/workspace", + containerPath: "/app", + }}, + }) + want := []string{ + "run", + "--rm", + "-p", "127.0.0.1:5432:5432", + "-e", "POSTGRES_PASSWORD=secret", + "-v", "/data:/var/lib/postgresql/data", + "-v", "/workspace:/app", + "postgres", + } + if len(got) != len(want) { + t.Fatalf("len(args) = %d, want %d\n got=%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q\n got=%v", i, got[i], want[i], got) + } + } +} + +func TestParseRunSyncFlag(t *testing.T) { + t.Parallel() + + got, err := parseRunSyncFlag("./app,/workspace:/app") + if err != nil { + t.Fatalf("parseRunSyncFlag: %v", err) + } + if got.localPath != "./app" || got.sandboxPath != "/workspace" || got.containerPath != "/app" { + t.Fatalf("parsed sync = %#v", got) + } +} + +func TestParseRunArgsFlagsAfterImage(t *testing.T) { + got := runParseRunArgs(t, + "postgres", + "--disk", "pg-data,/data:/var/lib/postgresql/data", + "--sync", "./app,/workspace:/app", + "--network", "db", + "--local", "5432", + "--remote", "5432", + "--env", "POSTGRES_PASSWORD=secret", + "--", "-c", "shared_buffers=256MB", + ) + if got.image != "postgres" { + t.Fatalf("image = %q, want postgres", got.image) + } + if got.local != 5432 || got.remote != 5432 { + t.Fatalf("ports = %d/%d, want 5432/5432", got.local, got.remote) + } + if !reflect.DeepEqual(got.networks, []string{"db"}) { + t.Fatalf("networks = %v, want [db]", got.networks) + } + if !reflect.DeepEqual(got.envs, []string{"POSTGRES_PASSWORD=secret"}) { + t.Fatalf("envs = %v", got.envs) + } + if !reflect.DeepEqual(got.imageArgs, []string{"-c", "shared_buffers=256MB"}) { + t.Fatalf("imageArgs = %v", got.imageArgs) + } + if len(got.disks) != 1 || got.disks[0].sandboxPath != "/data" || got.disks[0].containerPath != "/var/lib/postgresql/data" { + t.Fatalf("disks = %#v", got.disks) + } + if len(got.syncs) != 1 || got.syncs[0].localPath != "./app" || got.syncs[0].sandboxPath != "/workspace" || got.syncs[0].containerPath != "/app" { + t.Fatalf("syncs = %#v", got.syncs) + } +} + +func TestParseRunArgsDiskBeforeImage(t *testing.T) { + got := runParseRunArgs(t, "--disk", "cache,/cache:/cache", "redis") + if got.image != "redis" { + t.Fatalf("image = %q, want redis", got.image) + } + if len(got.disks) != 1 || got.disks[0].diskID != "cache" { + t.Fatalf("disks = %#v", got.disks) + } +} + +func TestParseRunArgsPushLocal(t *testing.T) { + got := runParseRunArgs(t, "my-app:dev", "--push-local") + if !got.pushLocal { + t.Fatal("pushLocal = false, want true") + } +} + +func TestParseRunArgsRemoveSandbox(t *testing.T) { + got := runParseRunArgs(t, "nginx", "--rm") + if !got.removeSandbox { + t.Fatal("removeSandbox = false, want true") + } +} + +func TestParseRunArgsRejectsPullAndPushLocal(t *testing.T) { + if err := runParseRunArgsErr("nginx", "--pull", "--push-local"); err == nil { + t.Fatal("expected --pull with --push-local to fail") + } +} + +func TestParseRunArgsRejectsRemoveSandboxNoFollow(t *testing.T) { + if err := runParseRunArgsErr("nginx", "--rm", "--no-follow"); err == nil { + t.Fatal("expected --rm with --no-follow to fail") + } +} + +func runParseRunArgsErr(argv ...string) error { + app := &cli.App{ + Commands: []*cli.Command{{ + Name: "run", + Flags: newRunCommand().Flags, + Action: func(c *cli.Context) error { + _, err := parseRunArgs(c) + return err + }, + }}, + } + return app.Run(append([]string{"app", "run"}, argv...)) +} + +func TestLocalImageArchiveName(t *testing.T) { + got := localImageArchiveName("localhost:5000/my-app:dev") + if got != "localhost-5000-my-app-dev.tar" { + t.Fatalf("archive name = %q", got) + } +} + +func TestRunMutagenCreateArgsSetsReadableModes(t *testing.T) { + got := runMutagenCreateArgs("sess1", "two-way-safe", "/local", "root@127.0.0.1:2222:/workspace", []string{"node_modules"}) + wantContains := map[string]bool{ + "--default-file-mode-beta=0644": false, + "--default-directory-mode-beta=0755": false, + "--ignore=node_modules": false, + } + for _, arg := range got { + if _, ok := wantContains[arg]; ok { + wantContains[arg] = true + } + } + for arg, seen := range wantContains { + if !seen { + t.Fatalf("runMutagenCreateArgs missing %q in %v", arg, got) + } + } + if got[len(got)-2] != "/local" || got[len(got)-1] != "root@127.0.0.1:2222:/workspace" { + t.Fatalf("source/target must be last two args, got %v", got) + } +} + +func TestResolveRunSyncIdentityGeneratesDedicatedKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + priv, pubBytes, cleanup, err := resolveRunSyncIdentity("sb-01m10vpsz09ajsyezny04mnvfq", "") + if err != nil { + t.Fatalf("resolveRunSyncIdentity: %v", err) + } + if !strings.HasPrefix(priv, filepath.Join(home, ".config", "createos", "keys")) { + t.Fatalf("private key path = %q, want under ~/.config/createos/keys", priv) + } + if len(pubBytes) == 0 { + t.Fatal("expected public key bytes") + } + if _, err := os.Stat(priv); err != nil { + t.Fatalf("private key not written: %v", err) + } + if _, err := os.Stat(priv + ".pub"); err != nil { + t.Fatalf("public key not written: %v", err) + } + + cleanup() + if _, err := os.Stat(priv); !os.IsNotExist(err) { + t.Fatalf("private key still exists after cleanup: %v", err) + } + if _, err := os.Stat(priv + ".pub"); !os.IsNotExist(err) { + t.Fatalf("public key still exists after cleanup: %v", err) + } +} + +func runParseRunArgs(t *testing.T, argv ...string) runOptions { + t.Helper() + var got runOptions + app := &cli.App{ + Commands: []*cli.Command{{ + Name: "run", + Flags: newRunCommand().Flags, + Action: func(c *cli.Context) error { + var err error + got, err = parseRunArgs(c) + return err + }, + }}, + } + if err := app.Run(append([]string{"app", "run"}, argv...)); err != nil { + t.Fatalf("run %v: %v", argv, err) + } + return got +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 7e7e21c..5353359 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -14,6 +14,7 @@ func NewSandboxCommand() *cli.Command { Aliases: []string{"sb"}, Usage: "Manage sandboxes", Subcommands: []*cli.Command{ + newRunCommand(), newCreateCommand(), newListCommand(), newGetCommand(), diff --git a/cmd/sandbox/tunnel.go b/cmd/sandbox/tunnel.go index a11e211..a24fd8d 100644 --- a/cmd/sandbox/tunnel.go +++ b/cmd/sandbox/tunnel.go @@ -1,6 +1,7 @@ package sandbox import ( + "context" "fmt" "net" "os" @@ -149,17 +150,6 @@ func runTunnel(c *cli.Context) error { bind = "127.0.0.1" } - // 3. Open a TCP listener on (bind:local). Every accepted connection - // opens its own HTTP-Upgrade tunnel through control to the - // sandbox's `remote` port. - listenAddr := net.JoinHostPort(bind, strconv.Itoa(local)) - var lc net.ListenConfig - listener, err := lc.Listen(c.Context, "tcp", listenAddr) - if err != nil { - return fmt.Errorf("could not bind %s: %w", listenAddr, err) - } - defer func() { _ = listener.Close() }() //nolint:errcheck - ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) if ctrlURL == "" { ctrlURL = api.DefaultSandboxBaseURL @@ -169,28 +159,68 @@ func runTunnel(c *cli.Context) error { return err } - pterm.Success.Printfln("Forwarding %s → %s:%d", listenAddr, refLabel(ref, id), remote) - pterm.Println(pterm.Gray(" Press Ctrl+C to stop.")) - // Trap Ctrl+C so we can close cleanly and not leave half-open conns. + ctx, cancel := context.WithCancel(c.Context) + defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigCh) go func() { <-sigCh + cancel() + }() + + return serveSandboxTunnel(ctx, tunnelSpec{ + CtrlURL: ctrlURL, + AuthHeader: authHeader, + Token: token, + SandboxID: id, + Ref: ref, + Local: local, + Remote: remote, + Bind: bind, + Announce: true, + }) +} + +type tunnelSpec struct { + CtrlURL string + AuthHeader string + Token string + SandboxID string + Ref string + Local int + Remote int + Bind string + Announce bool +} + +func serveSandboxTunnel(ctx context.Context, spec tunnelSpec) error { + listenAddr := net.JoinHostPort(spec.Bind, strconv.Itoa(spec.Local)) + var lc net.ListenConfig + listener, err := lc.Listen(ctx, "tcp", listenAddr) + if err != nil { + return fmt.Errorf("could not bind %s: %w", listenAddr, err) + } + defer func() { _ = listener.Close() }() //nolint:errcheck + + go func() { + <-ctx.Done() _ = listener.Close() //nolint:errcheck }() - // 4. Accept loop. Each connection runs in its own goroutine via - // bridgeOne (defined in shell.go) which speaks the same - // HTTP-Upgrade tunnel protocol. + if spec.Announce { + pterm.Success.Printfln("Forwarding %s → %s:%d", listenAddr, refLabel(spec.Ref, spec.SandboxID), spec.Remote) + pterm.Println(pterm.Gray(" Press Ctrl+C to stop.")) + } + for { conn, err := listener.Accept() if err != nil { - // Closed by signal handler or local error → done. + // Closed by context cancellation or local error → done. return nil } - go bridgeOne(c.Context, ctrlURL, authHeader, token, id, remote, conn) + go bridgeOne(ctx, spec.CtrlURL, spec.AuthHeader, spec.Token, spec.SandboxID, spec.Remote, conn) } }