From 228496dadbdd3be710e9d4d4902e983b2b92462c Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Thu, 4 Jun 2026 13:46:44 +0200 Subject: [PATCH 1/4] feat: sandbox --- cmd/root/root.go | 22 + cmd/sandbox/bandwidth.go | 51 +++ cmd/sandbox/catalog.go | 113 +++++ cmd/sandbox/create.go | 283 +++++++++++++ cmd/sandbox/disk.go | 685 ++++++++++++++++++++++++++++++ cmd/sandbox/edit.go | 366 ++++++++++++++++ cmd/sandbox/exec.go | 243 +++++++++++ cmd/sandbox/firewall.go | 235 ++++++++++ cmd/sandbox/fork.go | 116 +++++ cmd/sandbox/get.go | 156 +++++++ cmd/sandbox/list.go | 139 ++++++ cmd/sandbox/mutagen_install.go | 379 +++++++++++++++++ cmd/sandbox/network.go | 486 +++++++++++++++++++++ cmd/sandbox/pause.go | 71 ++++ cmd/sandbox/pull.go | 73 ++++ cmd/sandbox/push.go | 115 +++++ cmd/sandbox/resolve.go | 99 +++++ cmd/sandbox/resume.go | 70 +++ cmd/sandbox/rm.go | 190 +++++++++ cmd/sandbox/sandbox.go | 39 ++ cmd/sandbox/shell.go | 669 +++++++++++++++++++++++++++++ cmd/sandbox/slider.go | 150 +++++++ cmd/sandbox/sync.go | 512 ++++++++++++++++++++++ cmd/sandbox/template.go | 483 +++++++++++++++++++++ cmd/sandbox/tunnel.go | 194 +++++++++ cmd/sandbox/wait.go | 53 +++ cmd/sandbox/wizard.go | 354 ++++++++++++++++ internal/api/sandbox.go | 754 +++++++++++++++++++++++++++++++++ internal/api/sandbox_client.go | 44 ++ internal/api/sandbox_types.go | 330 +++++++++++++++ internal/ui/picker.go | 90 ++++ internal/ui/shape_picker.go | 141 ++++++ 32 files changed, 7705 insertions(+) create mode 100644 cmd/sandbox/bandwidth.go create mode 100644 cmd/sandbox/catalog.go create mode 100644 cmd/sandbox/create.go create mode 100644 cmd/sandbox/disk.go create mode 100644 cmd/sandbox/edit.go create mode 100644 cmd/sandbox/exec.go create mode 100644 cmd/sandbox/firewall.go create mode 100644 cmd/sandbox/fork.go create mode 100644 cmd/sandbox/get.go create mode 100644 cmd/sandbox/list.go create mode 100644 cmd/sandbox/mutagen_install.go create mode 100644 cmd/sandbox/network.go create mode 100644 cmd/sandbox/pause.go create mode 100644 cmd/sandbox/pull.go create mode 100644 cmd/sandbox/push.go create mode 100644 cmd/sandbox/resolve.go create mode 100644 cmd/sandbox/resume.go create mode 100644 cmd/sandbox/rm.go create mode 100644 cmd/sandbox/sandbox.go create mode 100644 cmd/sandbox/shell.go create mode 100644 cmd/sandbox/slider.go create mode 100644 cmd/sandbox/sync.go create mode 100644 cmd/sandbox/template.go create mode 100644 cmd/sandbox/tunnel.go create mode 100644 cmd/sandbox/wait.go create mode 100644 cmd/sandbox/wizard.go create mode 100644 internal/api/sandbox.go create mode 100644 internal/api/sandbox_client.go create mode 100644 internal/api/sandbox_types.go create mode 100644 internal/ui/picker.go create mode 100644 internal/ui/shape_picker.go diff --git a/cmd/root/root.go b/cmd/root/root.go index a937bcc..0f10ee7 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -20,6 +20,7 @@ import ( "github.com/NodeOps-app/createos-cli/cmd/oauth" "github.com/NodeOps-app/createos-cli/cmd/open" "github.com/NodeOps-app/createos-cli/cmd/projects" + "github.com/NodeOps-app/createos-cli/cmd/sandbox" "github.com/NodeOps-app/createos-cli/cmd/scale" "github.com/NodeOps-app/createos-cli/cmd/skills" "github.com/NodeOps-app/createos-cli/cmd/status" @@ -56,6 +57,18 @@ func NewApp() *cli.App { EnvVars: []string{"CREATEOS_API_URL"}, Value: api.DefaultBaseURL, }, + &cli.StringFlag{ + Name: "sandbox-api-url", + Usage: "Override the sandbox (fc-spawn) base URL", + EnvVars: []string{"CREATEOS_SANDBOX_URL"}, + Value: api.DefaultSandboxBaseURL, + }, + &cli.StringFlag{ + Name: "sandbox-gateway", + Usage: "SSH gateway address () used by `sandbox shell`", + EnvVars: []string{"CREATEOS_SANDBOX_GATEWAY"}, + Value: "65.109.104.247:2222", + }, &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, @@ -121,6 +134,11 @@ func NewApp() *cli.App { } client := api.NewClientWithAccessToken(session.AccessToken, c.String("api-url"), c.Bool("debug")) c.App.Metadata[api.ClientKey] = &client + // Sandbox API (fc-spawn) reuses the same access token — + // the token is validated against the shared NodeOps + // auth service on the server side. + sandboxClient := api.NewSandboxClient(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug")) + c.App.Metadata[api.SandboxClientKey] = &sandboxClient return nil } } @@ -132,6 +150,8 @@ func NewApp() *cli.App { } client := api.NewClient(token, c.String("api-url"), c.Bool("debug")) c.App.Metadata[api.ClientKey] = &client + sandboxClient := api.NewSandboxClient(token, c.String("sandbox-api-url"), c.Bool("debug")) + c.App.Metadata[api.SandboxClientKey] = &sandboxClient return nil }, Action: func(_ *cli.Context) error { @@ -151,6 +171,7 @@ func NewApp() *cli.App { fmt.Println(" oauth-clients Manage OAuth clients") fmt.Println(" open Open project URL or dashboard in browser") fmt.Println(" projects Manage projects") + fmt.Println(" sandbox Manage sandboxes") fmt.Println(" scale Adjust replicas and resources") fmt.Println(" skills Manage skills") fmt.Println(" status Show project health and deployment status") @@ -186,6 +207,7 @@ func NewApp() *cli.App { oauth.NewOAuthCommand(), open.NewOpenCommand(), projects.NewProjectsCommand(), + sandbox.NewSandboxCommand(), scale.NewScaleCommand(), skills.NewSkillsCommand(), status.NewStatusCommand(), diff --git a/cmd/sandbox/bandwidth.go b/cmd/sandbox/bandwidth.go new file mode 100644 index 0000000..578d839 --- /dev/null +++ b/cmd/sandbox/bandwidth.go @@ -0,0 +1,51 @@ +package sandbox + +import ( + "fmt" + "strconv" + "strings" +) + +// parseSizeBytes accepts "5GB", "500MB", "1024" etc. (decimal SI units +// — 1 KB = 1000 bytes) plus binary suffixes (KiB/MiB/GiB/TiB). Pure +// digits are treated as raw bytes. Used by `sandbox edit`'s bandwidth +// top-up step and any future caller that needs human size parsing. +func parseSizeBytes(in string) (int64, error) { + s := strings.TrimSpace(strings.ToUpper(in)) + if s == "" { + return 0, fmt.Errorf("empty amount") + } + type unit struct { + suffix string + mul int64 + } + for _, u := range []unit{ + {"TIB", 1 << 40}, + {"GIB", 1 << 30}, + {"MIB", 1 << 20}, + {"KIB", 1 << 10}, + {"TB", 1_000_000_000_000}, + {"GB", 1_000_000_000}, + {"MB", 1_000_000}, + {"KB", 1_000}, + {"T", 1_000_000_000_000}, + {"G", 1_000_000_000}, + {"M", 1_000_000}, + {"K", 1_000}, + {"B", 1}, + } { + if strings.HasSuffix(s, u.suffix) { + num := strings.TrimSpace(strings.TrimSuffix(s, u.suffix)) + f, err := strconv.ParseFloat(num, 64) + if err != nil { + return 0, fmt.Errorf("could not read %q as a number", num) + } + return int64(f * float64(u.mul)), nil + } + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("could not read %q — try a value like 5GB or a raw byte count", in) + } + return n, nil +} diff --git a/cmd/sandbox/catalog.go b/cmd/sandbox/catalog.go new file mode 100644 index 0000000..f59cb33 --- /dev/null +++ b/cmd/sandbox/catalog.go @@ -0,0 +1,113 @@ +package sandbox + +import ( + "fmt" + + "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" +) + +// newShapesCommand lists the static VM size catalog. +func newShapesCommand() *cli.Command { + return &cli.Command{ + Name: "shapes", + Usage: "List the available sandbox sizes (vCPU / RAM / disk)", + Action: runShapes, + } +} + +func runShapes(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") + } + shapes, err := client.ListShapes(c.Context) + if err != nil { + return err + } + output.Render(c, shapes, func() { + if len(shapes) == 0 { + fmt.Println("No sizes available.") + return + } + table := pterm.TableData{{"ID", "vCPU", "RAM", "Default disk"}} + for _, s := range shapes { + table = append(table, []string{ + s.ID, + fmt.Sprintf("%d", s.VCPU), + fmt.Sprintf("%d MB", s.MemMib), + fmt.Sprintf("%d MB", s.DefaultDiskMib), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + pterm.Println() + pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --shape ")) + }) + return nil +} + +// newRootfsCommand lists the built-in rootfs images that any sandbox +// can boot from. User-built templates are not included here — see +// `sandbox template ls`. +func newRootfsCommand() *cli.Command { + return &cli.Command{ + Name: "rootfs", + Aliases: []string{"images"}, + Usage: "List the built-in OS images you can boot a sandbox from", + Action: runRootfs, + } +} + +func runRootfs(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") + } + cat, err := client.ListRootfs(c.Context) + if err != nil { + return err + } + output.Render(c, cat, func() { + if cat == nil || len(cat.Rootfs) == 0 { + fmt.Println("No built-in images available.") + return + } + // Use the per-entry view when the server provides it; otherwise + // just the names. + hasEntries := len(cat.Entries) > 0 + if hasEntries { + table := pterm.TableData{{"Name", "Description", "Status"}} + for _, e := range cat.Entries { + status := "" + switch { + case e.Name == cat.Default: + status = "default" + case e.Deprecated: + status = "deprecated" + if e.Successor != "" { + status += " → " + e.Successor + } + } + table = append(table, []string{e.Name, e.Description, status}) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + } else { + table := pterm.TableData{{"Name", "Default"}} + for _, name := range cat.Rootfs { + def := "" + if name == cat.Default { + def = "yes" + } + table = append(table, []string{name, def}) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + } + pterm.Println() + pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --rootfs ")) + pterm.Println(pterm.Gray(" To list your own custom templates: createos sandbox template ls")) + }) + return nil +} diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go new file mode 100644 index 0000000..069ee5e --- /dev/null +++ b/cmd/sandbox/create.go @@ -0,0 +1,283 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newCreateCommand() *cli.Command { + return &cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a new sandbox", + ArgsUsage: " ", + Description: `Create a new sandbox on fc-spawn. + +Examples: + # Smallest possible sandbox + createos sandbox create --shape s-1vcpu-256mb + + # With a name and an SSH key so you can shell in later + createos sandbox create --shape s-1vcpu-1gb \ + --name my-box --ssh-key ~/.ssh/id_ed25519.pub + + # Public HTTPS URL (great for demos) + createos sandbox create --shape s-1vcpu-1gb --ingress + + # Attach an S3 disk and join a private network + createos sandbox create --shape s-1vcpu-1gb \ + --disk my-bucket:/mnt/data --network my-net`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "shape", + Usage: "Size of the sandbox (run 'createos sandbox shapes' to see options)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Friendly name for the sandbox (one is generated if you skip it)", + }, + &cli.StringFlag{ + Name: "rootfs", + Usage: "Base image or template to start from (defaults to the standard one)", + }, + &cli.Int64Flag{ + Name: "disk-mib", + Usage: "Disk size in MiB (defaults to the shape's standard disk)", + }, + &cli.StringSliceFlag{ + Name: "ssh-key", + Usage: "Path to an SSH public key file so you can sign in (repeatable)", + }, + &cli.StringSliceFlag{ + Name: "egress", + Usage: "Allowed website or address the sandbox can reach (repeatable). When empty the sandbox can reach anything.", + }, + &cli.StringSliceFlag{ + Name: "env", + Usage: "Environment variable available to every exec (repeatable): KEY=VALUE", + }, + &cli.BoolFlag{ + Name: "ingress", + Usage: "Give the sandbox a public HTTPS URL so anyone can reach its HTTP services", + }, + &cli.StringSliceFlag{ + Name: "network", + Aliases: []string{"net"}, + Usage: "Private network to join at creation (repeatable): ", + }, + &cli.StringSliceFlag{ + Name: "disk", + Usage: "S3 disk to mount at creation (repeatable): :/mount/path", + }, + }, + Action: runCreate, + } +} + +func runCreate(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") + } + + shape := strings.TrimSpace(c.String("shape")) + name := strings.TrimSpace(c.String("name")) + rootfs := strings.TrimSpace(c.String("rootfs")) + ingress := c.Bool("ingress") + netIDs := stringSliceCleanup(c.StringSlice("network")) + + // Read --ssh-key files up front so we can both seed the wizard + // (skipping the SSH step when keys are already supplied) and reuse + // the result downstream. + sshKeys, err := readSSHPubkeys(c.StringSlice("ssh-key")) + if err != nil { + return err + } + + // Interactive wizard: only when --shape is missing AND stdout is a TTY. + // Lets users walk through name / rootfs / network / ssh keys without + // remembering every flag. Headless callers continue to get the + // "use --shape" error. + if shape == "" { + w, err := runCreateWizard(c, client, wizardSeed{ + name: name, + rootfs: rootfs, + ingress: ingress, + netIDs: netIDs, + sshKeys: sshKeys, + }) + if err != nil { + return err + } + if w == nil { + // User cancelled mid-wizard — exit quietly, no error. + return nil + } + shape = w.shape + if name == "" { + name = w.name + } + if rootfs == "" { + rootfs = w.rootfs + } + if len(netIDs) == 0 { + netIDs = w.netIDs + } + if len(sshKeys) == 0 { + sshKeys = w.sshKeys + } + ingress = ingress || w.ingress + } + + req := api.SandboxCreateReq{ + Shape: shape, + Name: name, + Rootfs: rootfs, + DiskMib: c.Int64("disk-mib"), + IngressEnabled: ingress, + } + + if envs, err := parseEnvFlags(c.StringSlice("env")); err != nil { + return err + } else if len(envs) > 0 { + req.Envs = envs + } + + if egress := c.StringSlice("egress"); len(egress) > 0 { + req.Egress = egress + } + + if len(sshKeys) > 0 { + req.SSHPubkeys = sshKeys + } + + if len(netIDs) > 0 { + req.Networks = make([]api.SandboxNetworkAttach, 0, len(netIDs)) + for _, n := range netIDs { + req.Networks = append(req.Networks, api.SandboxNetworkAttach{ID: n}) + } + } + + if rawDisks := c.StringSlice("disk"); len(rawDisks) > 0 { + disks, err := parseDiskFlags(rawDisks) + if err != nil { + return err + } + req.Disks = disks + } + + spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") + resp, err := client.CreateSandbox(c.Context, req) + if err != nil { + spinner.Fail("Could not create sandbox") + return err + } + spinner.Success("Sandbox is ready") + + printCreateResult(resp) + return nil +} + +// parseEnvFlags turns --env KEY=VALUE entries into a map. Surfaces +// a friendly error rather than a stack trace on malformed input. +func parseEnvFlags(raw []string) (map[string]string, error) { + if len(raw) == 0 { + return nil, nil + } + out := make(map[string]string, len(raw)) + for _, kv := range raw { + i := strings.IndexByte(kv, '=') + if i <= 0 { + return nil, fmt.Errorf("--env %q is missing '=' (expected KEY=VALUE)", kv) + } + key := strings.TrimSpace(kv[:i]) + if key == "" { + return nil, fmt.Errorf("--env %q has an empty key", kv) + } + out[key] = kv[i+1:] + } + return out, nil +} + +// parseDiskFlags turns --disk :/mount/path entries into the +// API attachment shape. Refuses anything without a mount path. +func parseDiskFlags(raw []string) ([]api.SandboxDiskAttach, error) { + out := make([]api.SandboxDiskAttach, 0, len(raw)) + for _, entry := range raw { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + i := strings.IndexByte(entry, ':') + if i <= 0 || i == len(entry)-1 { + return nil, fmt.Errorf("--disk %q must be :/mount/path", entry) + } + out = append(out, api.SandboxDiskAttach{ + DiskID: entry[:i], + MountPath: entry[i+1:], + }) + } + return out, nil +} + +// readSSHPubkeys reads each path supplied by --ssh-key and returns the +// canonicalised public-key strings. Missing or unreadable files yield +// a friendly error. +func readSSHPubkeys(paths []string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + out := make([]string, 0, len(paths)) + for _, p := range paths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + b, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("could not read SSH public key %s: %w", p, err) + } + out = append(out, strings.TrimSpace(string(b))) + } + return out, nil +} + +// printCreateResult shows the user the new sandbox + how to reach it. +func printCreateResult(resp *api.SandboxCreateResp) { + name := "" + if resp.Name != nil { + name = *resp.Name + } + + pterm.Println() + if name != "" { + pterm.NewStyle(pterm.FgCyan).Printfln(" Sandbox %s (%s)", name, resp.ID) + } else { + pterm.NewStyle(pterm.FgCyan).Printfln(" Sandbox %s", resp.ID) + } + + row := func(label, value string) { + if value == "" { + return + } + fmt.Printf(" %-9s %s\n", label+":", value) + } + row("Size", resp.Shape) + if resp.Rootfs != nil && *resp.Rootfs != "" { + row("Image", *resp.Rootfs) + } + row("IP", resp.IP) + + if resp.IngressURLTemplate != "" { + pterm.Println() + pterm.Success.Println("Reachable from anywhere over HTTPS:") + fmt.Printf(" %s\n", resp.IngressURLTemplate) + pterm.Println(pterm.Gray(" Replace with the port your service is listening on.")) + } +} diff --git a/cmd/sandbox/disk.go b/cmd/sandbox/disk.go new file mode 100644 index 0000000..2d70ee1 --- /dev/null +++ b/cmd/sandbox/disk.go @@ -0,0 +1,685 @@ +package sandbox + +import ( + "context" + "fmt" + "strings" + + "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" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newDiskCommand returns the `sandbox disk` group. Disks are +// user-registered S3-compatible buckets that get mounted into +// sandboxes (at create time or live). +func newDiskCommand() *cli.Command { + return &cli.Command{ + Name: "disk", + Aliases: []string{"disks"}, + Usage: "Manage S3 disks you can mount into your sandboxes", + Subcommands: []*cli.Command{ + newDiskCreateCommand(), + newDiskListCommand(), + newDiskShowCommand(), + newDiskRmCommand(), + newDiskAttachCommand(), + newDiskDetachCommand(), + }, + } +} + +// ── create ─────────────────────────────────────────────────────── + +func newDiskCreateCommand() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Register an S3 bucket as a disk you can mount into sandboxes", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "bucket", Usage: "S3 bucket name"}, + &cli.StringFlag{Name: "endpoint", Usage: "S3 endpoint URL (e.g. https://s3.amazonaws.com, https://your-minio:9000)"}, + &cli.StringFlag{Name: "access-key", Usage: "Access key ID"}, + &cli.StringFlag{Name: "secret-key", Usage: "Secret access key"}, + &cli.StringFlag{Name: "region", Usage: "AWS region (e.g. us-east-1) — optional"}, + &cli.BoolFlag{Name: "path-style", Usage: "Use path-style URLs (needed for MinIO and most self-hosted S3)"}, + }, + Action: runDiskCreate, + } +} + +func runDiskCreate(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") + } + tty := terminal.IsInteractive() + + // urfave/cli v2 stops parsing flags at the first positional, so + // `disk create my-disk --bucket=foo` loses everything after. + // Re-scan args ourselves so flag order doesn't matter. + name, flags := parseDiskCreateArgs(c) + bucket := flags["bucket"] + endpoint := flags["endpoint"] + access := flags["access-key"] + secret := flags["secret-key"] + region := flags["region"] + pathStyle := flags["path-style"] == "true" + + // Interactive: fill in anything missing. + if tty { + if name == "" { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Name this disk (your sandboxes will reference it by this name)"). + Show() + if err != nil { + return fmt.Errorf("could not read name: %w", err) + } + name = strings.TrimSpace(v) + } + if bucket == "" { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Bucket name"). + Show() + if err != nil { + return fmt.Errorf("could not read bucket: %w", err) + } + bucket = strings.TrimSpace(v) + } + if endpoint == "" { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Endpoint URL (e.g. https://s3.amazonaws.com)"). + Show() + if err != nil { + return fmt.Errorf("could not read endpoint: %w", err) + } + endpoint = strings.TrimSpace(v) + } + if access == "" { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Access key"). + Show() + if err != nil { + return fmt.Errorf("could not read access key: %w", err) + } + access = strings.TrimSpace(v) + } + if secret == "" { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Secret key"). + WithMask("*"). + Show() + if err != nil { + return fmt.Errorf("could not read secret key: %w", err) + } + secret = v + } + } + + if name == "" || bucket == "" || endpoint == "" || access == "" || secret == "" { + return fmt.Errorf("missing required values\n\n Need: , --bucket, --endpoint, --access-key, --secret-key\n Optional: --region, --path-style") + } + + spinner, _ := pterm.DefaultSpinner.Start("Checking the bucket…") + d, err := client.CreateDisk(c.Context, api.DiskCreateReq{ + Name: name, + Kind: "s3", + Config: api.DiskConfig{ + Bucket: bucket, + Endpoint: endpoint, + Region: region, + UsePathStyle: pathStyle, + }, + Credentials: api.DiskCredentials{ + AccessKey: access, + SecretKey: secret, + }, + }) + if err != nil { + spinner.Fail("Could not register the disk") + return err + } + spinner.Success(fmt.Sprintf("Registered disk %s (%s)", d.Name, d.ID)) + pterm.Println(pterm.Gray(" Attach it at create time: createos sandbox create --disk " + d.Name + ":/mnt/data")) + pterm.Println(pterm.Gray(" Or live-attach it later: createos sandbox disk attach " + d.Name + " /mnt/data")) + return nil +} + +// ── list ───────────────────────────────────────────────────────── + +func newDiskListCommand() *cli.Command { + return &cli.Command{ + Name: "ls", + Aliases: []string{"list"}, + Usage: "List your disks", + Action: runDiskList, + } +} + +func runDiskList(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") + } + disks, err := client.ListDisks(c.Context) + if err != nil { + return err + } + output.Render(c, disks, func() { + if len(disks) == 0 { + fmt.Println("You don't have any disks yet.") + pterm.Println(pterm.Gray(" Create one with: createos sandbox disk create")) + return + } + table := pterm.TableData{{"Name", "ID", "Kind", "Bucket", "Created"}} + for _, d := range disks { + table = append(table, []string{ + d.Name, d.ID, d.Kind, d.Config.Bucket, + d.CreatedAt.Format("2006-01-02 15:04"), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + }) + return nil +} + +// ── show ───────────────────────────────────────────────────────── + +func newDiskShowCommand() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show details for one disk", + ArgsUsage: "", + Action: runDiskShow, + } +} + +func runDiskShow(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") + } + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a disk name or ID\n\n To see your disks, run:\n createos sandbox disk ls") + } + picked, err := pickDisk(c, client, "Show which disk?") + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled. Nothing to show.") + return nil + } + ref = picked + } + d, err := client.GetDisk(c.Context, ref) + if err != nil { + return err + } + output.Render(c, d, func() { + label := pterm.NewStyle(pterm.FgCyan) + row := func(k, v string) { + if v == "" { + return + } + label.Printf(" %-12s ", k+":") + fmt.Println(v) + } + pterm.Println() + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s (%s)", d.Name, d.ID) + pterm.Println() + row("Kind", d.Kind) + row("Bucket", d.Config.Bucket) + row("Endpoint", d.Config.Endpoint) + row("Region", d.Config.Region) + if d.Config.UsePathStyle { + row("Path style", "yes") + } + row("Created", d.CreatedAt.Format("2006-01-02 15:04:05")) + }) + return nil +} + +// ── rm ─────────────────────────────────────────────────────────── + +func newDiskRmCommand() *cli.Command { + return &cli.Command{ + Name: "rm", + Aliases: []string{"delete"}, + Usage: "Delete one or more disks (each must not be currently mounted)", + ArgsUsage: "[ …]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y", "force"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runDiskRm, + } +} + +func runDiskRm(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") + } + refs, forceFromArgs := splitForceFlag(c.Args().Slice()) + if len(refs) == 0 { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide at least one disk name or ID\n\n To see your disks, run:\n createos sandbox disk ls") + } + picked, err := pickDisksForDelete(c, client) + if err != nil { + return err + } + if len(picked) == 0 { + fmt.Println("Cancelled. Nothing deleted.") + return nil + } + refs = picked + } + force := c.Bool("yes") || forceFromArgs + if !terminal.IsInteractive() && !force { + return fmt.Errorf("non-interactive: pass --yes to confirm deletion") + } + if terminal.IsInteractive() && !force { + prompt := fmt.Sprintf("Permanently delete disk %q? (the bucket itself is not touched)", refs[0]) + if len(refs) > 1 { + prompt = fmt.Sprintf("Permanently delete %d disks? (buckets are not touched)", len(refs)) + } + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(prompt). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + fmt.Println("Cancelled. Nothing deleted.") + return nil + } + } + failed := 0 + for _, ref := range refs { + if err := deleteDiskCascade(c, client, ref); err != nil { + pterm.Error.Printfln("%s: %v", ref, err) + failed++ + continue + } + pterm.Success.Printfln("Deleted disk %s", ref) + } + if failed > 0 { + return fmt.Errorf("%d of %d deletes failed", failed, len(refs)) + } + return nil +} + +// deleteDiskCascade detaches the disk from every sandbox that has it +// mounted, then deletes the disk record. "rm" means "remove it, +// including the wiring". +// +// There's no reverse-lookup endpoint, so we walk the caller's running +// and paused sandboxes and check each one's attachments. N is small in +// practice; this is the same scan `pickSandboxesForDelete` does. +func deleteDiskCascade(c *cli.Context, client *api.SandboxClient, ref string) error { + d, err := client.GetDisk(c.Context, ref) + if err != nil { + return err + } + var sandboxes []api.SandboxView + for _, st := range []string{"running", "paused"} { + page, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{ + Limit: 200, Status: st, + }) + if err != nil { + return fmt.Errorf("list sandboxes: %w", err) + } + sandboxes = append(sandboxes, page...) + } + for _, sb := range sandboxes { + attachments, err := client.ListSandboxDisks(c.Context, sb.ID) + if err != nil { + return fmt.Errorf("list attachments on %s: %w", sb.ID, err) + } + for _, a := range attachments { + if a.DiskID != d.ID { + continue + } + if derr := client.DetachDisk(c.Context, sb.ID, d.ID, a.MountPath); derr != nil { + return fmt.Errorf("detach %s from %s: %w", d.Name, sb.ID, derr) + } + pterm.Println(pterm.Gray(fmt.Sprintf(" detached %s from %s (%s)", d.Name, sb.ID, a.MountPath))) + } + } + return client.DeleteDisk(c.Context, ref) +} + +// pickDisksForDelete renders a multi-select over the caller's disks +// and returns the picked names. Returns nil/empty when the user cancels. +func pickDisksForDelete(c *cli.Context, client *api.SandboxClient) ([]string, error) { + disks, err := client.ListDisks(c.Context) + if err != nil { + return nil, err + } + if len(disks) == 0 { + fmt.Println("You don't have any disks to delete.") + return nil, nil + } + options := make([]string, 0, len(disks)) + byOpt := make(map[string]string, len(disks)) + for _, d := range disks { + opt := fmt.Sprintf("%s (bucket: %s, id: %s)", d.Name, d.Config.Bucket, d.ID) + options = append(options, opt) + byOpt[opt] = d.Name + } + picked, err := multiselect("Pick disks to delete (space = select, enter = confirm)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + out := make([]string, 0, len(picked)) + for _, p := range picked { + if name, ok := byOpt[p]; ok { + out = append(out, name) + } + } + return out, nil +} + +// ── attach ─────────────────────────────────────────────────────── + +func newDiskAttachCommand() *cli.Command { + return &cli.Command{ + Name: "attach", + Usage: "Mount an existing disk into a running sandbox", + ArgsUsage: "[ ]", + Action: runDiskAttach, + } +} + +func runDiskAttach(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") + } + args := c.Args().Slice() + sandboxRef := "" + diskRef := "" + mountPath := "" + if len(args) > 0 { + sandboxRef = args[0] + } + if len(args) > 1 { + diskRef = args[1] + } + if len(args) > 2 { + mountPath = args[2] + } + + tty := terminal.IsInteractive() + if sandboxRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox disk attach ") + } + pickedID, label, err := pickByStatus(c, client, "Attach to which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing attached.") + return nil + } + sandboxRef = label + _ = pickedID + } + sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) + if err != nil { + return err + } + + if diskRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox disk attach ") + } + picked, err := pickDisk(c, client, "Attach which disk?") + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled. Nothing attached.") + return nil + } + diskRef = picked + } + + if mountPath == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox disk attach ") + } + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Where in the sandbox should it mount (absolute path, e.g. /mnt/data)"). + WithDefaultValue("/mnt/" + diskRef). + Show() + if err != nil { + return fmt.Errorf("could not read mount path: %w", err) + } + mountPath = strings.TrimSpace(v) + } + if !strings.HasPrefix(mountPath, "/") { + return fmt.Errorf("mount path must be absolute (start with '/'), got %q", mountPath) + } + + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Attaching %s → %s:%s", diskRef, refLabel(sandboxRef, sandboxID), mountPath)) + err = client.AttachDisk(c.Context, sandboxID, api.DiskAttachReq{ + DiskID: diskRef, + MountPath: mountPath, + }) + if err != nil { + spinner.Fail("Attach failed") + return err + } + spinner.Success(fmt.Sprintf("Attached %s → %s:%s", diskRef, refLabel(sandboxRef, sandboxID), mountPath)) + pterm.Println(pterm.Gray(" The mount appears inside the sandbox within a few seconds.")) + return nil +} + +// ── detach ─────────────────────────────────────────────────────── + +func newDiskDetachCommand() *cli.Command { + return &cli.Command{ + Name: "detach", + Usage: "Unmount a disk from a running sandbox (the bucket itself is untouched)", + ArgsUsage: "[ ]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runDiskDetach, + } +} + +func runDiskDetach(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") + } + args := c.Args().Slice() + sandboxRef, diskRef, mountPath := "", "", "" + if len(args) > 0 { + sandboxRef = args[0] + } + if len(args) > 1 { + diskRef = args[1] + } + if len(args) > 2 { + mountPath = args[2] + } + + tty := terminal.IsInteractive() + + if sandboxRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox disk detach ") + } + pickedID, label, err := pickByStatus(c, client, "Detach from which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + sandboxRef = label + } + sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) + if err != nil { + return err + } + + // On TTY with no disk arg: pick from what's actually attached. + if diskRef == "" || mountPath == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox disk detach ") + } + attached, err := client.ListSandboxDisks(c.Context, sandboxID) + if err != nil { + return err + } + if len(attached) == 0 { + fmt.Println("This sandbox has no disks attached.") + return nil + } + options := make([]string, 0, len(attached)) + byOpt := make(map[string]struct{ disk, mount string }, len(attached)) + for _, a := range attached { + label := fmt.Sprintf("%s @ %s (status: %s)", a.Name, a.MountPath, a.MountStatus) + options = append(options, label) + byOpt[label] = struct{ disk, mount string }{a.DiskID, a.MountPath} + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText("Detach which attachment?"). + Show() + if err != nil { + return fmt.Errorf("could not read your selection: %w", err) + } + v := byOpt[picked] + diskRef, mountPath = v.disk, v.mount + } + + if !strings.HasPrefix(mountPath, "/") { + return fmt.Errorf("mount path must be absolute (start with '/'), got %q", mountPath) + } + + force := c.Bool("yes") + if !tty && !force { + return fmt.Errorf("non-interactive: pass --yes to confirm detach") + } + if tty && !force { + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Unmount %s from %s at %s? (the bucket itself is not touched)", diskRef, refLabel(sandboxRef, sandboxID), mountPath)). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + } + + if err := client.DetachDisk(c.Context, sandboxID, diskRef, mountPath); err != nil { + return err + } + pterm.Success.Printfln("Detached %s from %s at %s", diskRef, refLabel(sandboxRef, sandboxID), mountPath) + return nil +} + +// pickDisk renders a single-select picker over the caller's disks and +// returns the picked NAME (which the server accepts wherever an ID +// does). Returns "" when the user cancels. +func pickDisk(c *cli.Context, client *api.SandboxClient, title string) (string, error) { + disks, err := client.ListDisks(c.Context) + if err != nil { + return "", err + } + if len(disks) == 0 { + fmt.Println("You don't have any disks yet.") + pterm.Println(pterm.Gray(" Create one with: createos sandbox disk create")) + return "", nil + } + options := make([]string, 0, len(disks)) + nameByOpt := make(map[string]string, len(disks)) + for _, d := range disks { + opt := fmt.Sprintf("%s (bucket: %s, id: %s)", d.Name, d.Config.Bucket, d.ID) + options = append(options, opt) + nameByOpt[opt] = d.Name + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(title). + Show() + if err != nil { + return "", fmt.Errorf("could not read your selection: %w", err) + } + return nameByOpt[picked], nil +} + +// parseDiskCreateArgs splits c.Args() so flags can appear in any +// position. urfave/cli's defaults still seed the map for flags that +// appeared BEFORE the first positional; positional re-scan only adds. +// +// Recognises: --bucket / --endpoint / --access-key / --secret-key / +// --region / --path-style (boolean — its mere presence sets "true"). +// Both `--foo=bar` and `--foo bar` forms are accepted. +func parseDiskCreateArgs(c *cli.Context) (name string, flags map[string]string) { + flags = map[string]string{ + "bucket": strings.TrimSpace(c.String("bucket")), + "endpoint": strings.TrimSpace(c.String("endpoint")), + "access-key": strings.TrimSpace(c.String("access-key")), + "secret-key": c.String("secret-key"), + "region": strings.TrimSpace(c.String("region")), + } + if c.Bool("path-style") { + flags["path-style"] = "true" + } + + known := map[string]bool{ + "bucket": true, "endpoint": true, "access-key": true, + "secret-key": true, "region": true, + } + args := c.Args().Slice() + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--path-style": + flags["path-style"] = "true" + case strings.HasPrefix(a, "--path-style="): + flags["path-style"] = strings.TrimPrefix(a, "--path-style=") + case strings.HasPrefix(a, "--"): + // e.g. --foo=bar OR --foo bar + raw := strings.TrimPrefix(a, "--") + key, val := raw, "" + if eq := strings.IndexByte(raw, '='); eq >= 0 { + key, val = raw[:eq], raw[eq+1:] + } else if i+1 < len(args) { + val = args[i+1] + i++ + } + if known[key] { + flags[key] = strings.TrimSpace(val) + } + default: + if name == "" { + name = strings.TrimSpace(a) + } + } + } + return name, flags +} + +// context.Context guard so the import isn't dropped after refactors. +var _ = context.Background diff --git a/cmd/sandbox/edit.go b/cmd/sandbox/edit.go new file mode 100644 index 0000000..29f81cc --- /dev/null +++ b/cmd/sandbox/edit.go @@ -0,0 +1,366 @@ +package sandbox + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newEditCommand returns the `sandbox edit` command. Two ways to use: +// +// 1) Flag form (script-friendly): +// createos sandbox edit --ingress on|off +// createos sandbox edit --add-ssh-key ~/.ssh/id_ed25519.pub +// +// 2) Interactive (TTY, no flags): +// createos sandbox edit +// → menu: toggle public URL / add SSH key / cancel +// +// SSH-key removal is not supported by the server today — once a key is +// on a sandbox you cannot retract it without destroying the sandbox. +func newEditCommand() *cli.Command { + return &cli.Command{ + Name: "edit", + Usage: "Change a sandbox's settings (public URL, SSH keys)", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "ingress", + Usage: "Turn the public HTTPS URL `on` or `off`", + }, + &cli.StringSliceFlag{ + Name: "add-ssh-key", + Usage: "Path to a public-key file to add (repeatable)", + }, + }, + Action: runEdit, + } +} + +func runEdit(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") + } + + // 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 + + // Resolve the sandbox first — either from positional or via picker. + id, label, err := resolveTarget(c, client, ref) + if err != nil { + return err + } + if id == "" { + // Cancelled mid-pick. + fmt.Println("Cancelled. Nothing changed.") + return nil + } + + // Flag mode wins whenever any flag is set — no prompts. We apply + // each requested change and bail with the first error. + if hasFlagChanges { + if ingressFlag != "" { + if err := applyIngressFlag(c, client, label, id, ingressFlag); err != nil { + return err + } + } + if len(sshFiles) > 0 { + if err := applyAddSSHKeys(c, client, label, id, sshFiles); err != nil { + return err + } + } + return nil + } + + // 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 runEditMenu(c, client, label, id) +} + +// parseEditArgs walks c.Args() AND merges anything urfave/cli already +// parsed before the first positional. Pulls out the first positional +// as the sandbox ref, and recognises `--ingress `, +// `--ingress=`, `--add-ssh-key `, `--add-ssh-key=` +// 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. + ingressVal = strings.ToLower(strings.TrimSpace(c.String("ingress"))) + sshPaths = append([]string{}, c.StringSlice("add-ssh-key")...) + + args := c.Args().Slice() + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--ingress": + if i+1 < len(args) { + ingressVal = strings.ToLower(strings.TrimSpace(args[i+1])) + i++ + } + case strings.HasPrefix(a, "--ingress="): + ingressVal = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--ingress="))) + case a == "--add-ssh-key": + if i+1 < len(args) { + sshPaths = append(sshPaths, strings.TrimSpace(args[i+1])) + i++ + } + case strings.HasPrefix(a, "--add-ssh-key="): + sshPaths = append(sshPaths, strings.TrimSpace(strings.TrimPrefix(a, "--add-ssh-key="))) + default: + if ref == "" { + ref = strings.TrimSpace(a) + } + } + } + return ref, ingressVal, sshPaths +} + +// resolveTarget figures out which sandbox the user wants to edit. With +// a positional ref → resolve. Without one, picker on TTY, error otherwise. +func resolveTarget(c *cli.Context, client *api.SandboxClient, ref string) (id, label string, err error) { + if ref != "" { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return "", "", err + } + return resolved, ref, nil + } + if !terminal.IsInteractive() { + return "", "", fmt.Errorf("please provide a sandbox ID or name\n\n To see your sandboxes, run:\n createos sandbox list") + } + return pickByStatus(c, client, "Pick a sandbox to edit", "running") +} + +// runEditMenu is the interactive flow once a sandbox is selected. Pulls +// the current view so the menu shows real state, then loops until the +// user is done. +func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) error { + sb, err := client.GetSandbox(c.Context, id) + if err != nil { + return err + } + // Bandwidth is on a sibling endpoint. Best-effort — a stale/missing + // counter shouldn't block the rest of the edit menu. + bw, _ := client.GetBandwidth(c.Context, id) + + 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)) + if bw != nil { + bwLine := fmt.Sprintf("%s used of %s", humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes)) + if bw.Capped { + bwLine += " (CAPPED)" + } + header += " Bandwidth: " + bwLine + } + pterm.Println(pterm.Gray(header)) + pterm.Println() + + const ( + optIngress = "Toggle public URL" + optSSH = "Add an SSH key" + optBandwidth = "Top up bandwidth" + optDone = "Done" + ) + for { + choice, err := pterm.DefaultInteractiveSelect. + WithOptions([]string{optIngress, optSSH, optBandwidth, optDone}). + WithDefaultText("What would you like to change?"). + Show() + if err != nil { + return fmt.Errorf("could not read your choice: %w", err) + } + switch choice { + case optIngress: + target := !sb.IngressEnabled + confirm := fmt.Sprintf("Turn public URL %s for %s?", onOff(target), refLabel(label, id)) + yes, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(confirm). + WithDefaultValue(true). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !yes { + continue + } + updated, err := client.SetSandboxIngress(c.Context, id, target) + if err != nil { + return err + } + sb = updated + if target { + pterm.Success.Printfln("Public URL is on for %s", refLabel(label, id)) + if updated.IngressURLTemplate != "" { + fmt.Printf(" %s\n", updated.IngressURLTemplate) + } + } else { + pterm.Success.Printfln("Public URL is off for %s", refLabel(label, id)) + } + case optSSH: + path, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Path to your public-key file (e.g. ~/.ssh/id_ed25519.pub)"). + Show() + if err != nil { + return fmt.Errorf("could not read the key path: %w", err) + } + path = strings.TrimSpace(path) + if path == "" { + continue + } + if err := applyAddSSHKeys(c, client, label, id, []string{path}); err != nil { + pterm.Error.Printfln("%v", err) + continue + } + // Refresh so the next pass shows the new count. + if refreshed, err := client.GetSandbox(c.Context, id); err == nil { + sb = refreshed + } + case optBandwidth: + // Show current balance then pop the slider for the amount. + if bw != nil { + pterm.Println(pterm.Gray(fmt.Sprintf(" Current: %s used of %s (%s left%s)", + humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes), + humanBytes(bw.RemainingBytes), + func() string { + if bw.Capped { + return ", CAPPED" + } + return "" + }()))) + } + picked, err := pickRechargeAmountGB(5) + if err != nil { + return fmt.Errorf("could not read amount: %w", err) + } + if picked <= 0 { + continue + } + bytes := int64(picked) << 30 // GiB, matches humanBytes() display + updated, rerr := client.RechargeBandwidth(c.Context, id, bytes) + if rerr != nil { + pterm.Error.Printfln("%v", rerr) + continue + } + bw = updated + pterm.Success.Printfln("Added %s. New balance: %s of %s (%s left)", + humanBytes(bytes), + humanBytes(updated.UsedBytes), humanBytes(updated.QuotaBytes), + humanBytes(updated.RemainingBytes)) + case optDone: + return nil + } + } +} + +// applyIngressFlag honours the --ingress on|off flag. +func applyIngressFlag(c *cli.Context, client *api.SandboxClient, label, id, value string) error { + var target bool + switch value { + case "on", "true", "yes", "enable": + target = true + case "off", "false", "no", "disable": + target = false + default: + return fmt.Errorf("--ingress %q is not a value I understand. Use `on` or `off`.", value) + } + updated, err := client.SetSandboxIngress(c.Context, id, target) + if err != nil { + return err + } + if target { + pterm.Success.Printfln("Public URL is on for %s", refLabel(label, id)) + if updated.IngressURLTemplate != "" { + fmt.Printf(" %s\n", updated.IngressURLTemplate) + pterm.Println(pterm.Gray(" Replace with the port your service is listening on.")) + } + } else { + pterm.Success.Printfln("Public URL is off for %s", refLabel(label, id)) + } + return nil +} + +// applyAddSSHKeys reads each public-key file path and POSTs the bundle. +func applyAddSSHKeys(c *cli.Context, client *api.SandboxClient, label, id string, paths []string) error { + keys, err := readSSHPubkeys(paths) + if err != nil { + return err + } + if len(keys) == 0 { + return fmt.Errorf("no SSH keys to add — every path was empty") + } + count, err := client.AddSSHPubkeys(c.Context, id, keys) + if err != nil { + return err + } + pterm.Success.Printfln("Added %d SSH key(s) to %s — total now %d", len(keys), refLabel(label, id), count) + return nil +} + +// onOff renders true/false as the verb the user typed mentally. +func onOff(v bool) string { + if v { + return "on" + } + return "off" +} + +// refLabel renders both the friendly name (if the user typed one) and +// the resolved id so the user sees what was actually touched. +func refLabel(ref, id string) string { + if ref == id || ref == "" { + return id + } + return fmt.Sprintf("%s (%s)", ref, id) +} + +// pickByStatus shows a single-select picker filtered to one status +// (running, paused, etc). Reused by edit / pause / resume / fork. +func pickByStatus(c *cli.Context, client *api.SandboxClient, title, status string) (id, label string, err error) { + rows, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{ + Limit: 200, Status: status, + }) + if err != nil { + return "", "", err + } + if len(rows) == 0 { + if status == "" { + fmt.Println("You don't have any sandboxes yet.") + } else { + fmt.Printf("You have no %s sandboxes.\n", status) + } + return "", "", nil + } + options := make([]string, 0, len(rows)) + idByOpt := make(map[string]string, len(rows)) + labelByOpt := make(map[string]string, len(rows)) + for _, r := range rows { + lbl := r.ID + if r.Name != nil && *r.Name != "" { + lbl = *r.Name + } + opt := fmt.Sprintf("%s (id: %s)", lbl, r.ID) + options = append(options, opt) + idByOpt[opt] = r.ID + labelByOpt[opt] = lbl + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(title). + Show() + if err != nil { + return "", "", fmt.Errorf("could not read your selection: %w", err) + } + return idByOpt[picked], labelByOpt[picked], nil +} diff --git a/cmd/sandbox/exec.go b/cmd/sandbox/exec.go new file mode 100644 index 0000000..702d845 --- /dev/null +++ b/cmd/sandbox/exec.go @@ -0,0 +1,243 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newExecCommand() *cli.Command { + return &cli.Command{ + Name: "exec", + Usage: "Run a command inside a sandbox", + ArgsUsage: " -- [args…]", + Description: `Run a one-shot command inside a sandbox. Anything after the literal +'--' becomes the command. The default is a buffered exec — output +arrives all at once when the command finishes. Pass --stream to see +stdout/stderr live as it happens. + +Examples: + createos sandbox exec my-box -- uname -a + createos sandbox exec my-box -- python3 -c 'print("hi")' + createos sandbox exec my-box --stream -- pip install requests + createos sandbox exec my-box -- bash -c "echo $USER && date" + +The command's exit code is preserved — if the program inside the +sandbox exits with 1, this CLI also exits with 1.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "stream", + Aliases: []string{"s"}, + Usage: "Show output live as the command runs", + }, + &cli.StringSliceFlag{ + Name: "env", + Usage: "Override an environment variable for this exec (repeatable): KEY=VALUE. " + + "The KEY must have been declared at sandbox create time " + + "(with `createos sandbox create --env KEY=value`). To add a fresh var " + + "inline, prefix your command: `bash -c 'FOO=bar mycmd'`.", + }, + }, + Action: runExec, + } +} + +func runExec(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") + } + + ref, cmd, args := parseExecArgs(c) + + // Resolve / pick the sandbox first. + var id string + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n Example:\n createos sandbox exec my-box -- ls -la") + } + pickedID, label, perr := pickByStatus(c, client, "Run a command in which sandbox?", "running") + if perr != nil { + return perr + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing ran.") + return nil + } + id = pickedID + ref = label + } else { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + } + + // Then ask for the command if we don't have one yet. + if cmd == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please pass the command to run after '--'\n\n Example:\n createos sandbox exec %s -- ls -la", ref) + } + line, perr := pterm.DefaultInteractiveTextInput. + WithDefaultText(fmt.Sprintf("Command to run in %s (e.g. `ls -la`)", refLabel(ref, id))). + Show() + if perr != nil { + return fmt.Errorf("could not read your command: %w", perr) + } + line = strings.TrimSpace(line) + if line == "" { + fmt.Println("Cancelled. Nothing ran.") + return nil + } + fields := strings.Fields(line) + cmd = fields[0] + if len(fields) > 1 { + args = fields[1:] + } + } + + envs, err := parseEnvFlags(c.StringSlice("env")) + if err != nil { + return err + } + req := api.SandboxExecReq{ + Cmd: cmd, + Args: args, + Env: envs, + } + + if c.Bool("stream") { + return runExecStream(c, client, id, req) + } + return runExecBuffered(c, client, id, req) +} + +func runExecBuffered(c *cli.Context, client *api.SandboxClient, id string, req api.SandboxExecReq) error { + resp, err := client.ExecSandbox(c.Context, id, req) + if err != nil { + return err + } + if resp.Result.Stdout != "" { + fmt.Print(resp.Result.Stdout) + if !strings.HasSuffix(resp.Result.Stdout, "\n") { + fmt.Println() + } + } + if resp.Result.Stderr != "" { + fmt.Fprint(os.Stderr, resp.Result.Stderr) + if !strings.HasSuffix(resp.Result.Stderr, "\n") { + fmt.Fprintln(os.Stderr) + } + } + if resp.Result.Error != "" { + pterm.Error.Println(resp.Result.Error) + } + if resp.Result.ExitCode != 0 { + // Preserve the inner command's exit code so scripts can check $?. + os.Exit(resp.Result.ExitCode) + } + return nil +} + +func runExecStream(c *cli.Context, client *api.SandboxClient, id string, req api.SandboxExecReq) error { + exit, err := client.ExecSandboxStream(c.Context, id, req, func(ev api.SandboxExecStreamEvent) { + switch { + case ev.Stdout != "": + _, _ = io.WriteString(os.Stdout, ev.Stdout) + case ev.Stderr != "": + _, _ = io.WriteString(os.Stderr, ev.Stderr) + case ev.Error != "": + pterm.Error.Println(ev.Error) + } + // HB / exit_code frames are not user-visible. + }) + if err != nil { + return err + } + if exit > 0 { + os.Exit(exit) + } + return nil +} + +// parseExecArgs splits the positional args at the literal '--'. +// Everything before `--` is the sandbox ref (zero or one token); +// everything after is cmd + args. +// +// Both forms work: +// +// createos sandbox exec my-box -- ls -la # explicit separator +// createos sandbox exec my-box ls -la # implicit (first token = ref) +// createos sandbox exec -- ls -la # no ref, picker on TTY +// createos sandbox exec # nothing — picker + prompt +// +// urfave/cli v2 strips a LEADING `--` (it interprets that as +// "end-of-flags" and consumes the token). To distinguish +// `exec -- ls` (no ref, cmd=ls) from `exec my-box ls` (ref=my-box, +// cmd=ls), we re-scan os.Args ourselves and look for a `--` between +// the literal `exec` token and the first positional. +func parseExecArgs(c *cli.Context) (ref, cmd string, args []string) { + all := c.Args().Slice() + if len(all) == 0 { + return "", "", nil + } + + // First: did the user write `... exec -- …`? Scan os.Args. + leadingDoubleDash := false + for i, a := range os.Args { + if a == "exec" && i+1 < len(os.Args) && os.Args[i+1] == "--" { + leadingDoubleDash = true + break + } + } + + // Find any `--` that urfave passed through (will be present when it + // sits between positionals, e.g. `exec my-box -- ls`). + sep := -1 + for i, a := range all { + if a == "--" { + sep = i + break + } + } + + switch { + case leadingDoubleDash: + // `exec -- cmd args…` → no ref; everything is cmd+args. + cmd = all[0] + if len(all) > 1 { + args = all[1:] + } + case sep >= 0: + // `exec ref -- cmd args…` + if sep > 0 { + ref = strings.TrimSpace(all[0]) + } + rest := all[sep+1:] + if len(rest) > 0 { + cmd = rest[0] + if len(rest) > 1 { + args = rest[1:] + } + } + default: + // `exec ref [cmd args…]` + ref = strings.TrimSpace(all[0]) + rest := all[1:] + if len(rest) > 0 { + cmd = rest[0] + if len(rest) > 1 { + args = rest[1:] + } + } + } + return ref, cmd, args +} diff --git a/cmd/sandbox/firewall.go b/cmd/sandbox/firewall.go new file mode 100644 index 0000000..6443157 --- /dev/null +++ b/cmd/sandbox/firewall.go @@ -0,0 +1,235 @@ +package sandbox + +import ( + "fmt" + "strings" + + "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" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newFirewallCommand wires up `createos sandbox firewall`. +// +// Wraps the server's egress-rules endpoint with friendlier +// terminology. "firewall show / set / clear" reads more naturally to +// non-network engineers than "egress allowlist". +func newFirewallCommand() *cli.Command { + return &cli.Command{ + Name: "firewall", + Aliases: []string{"fw"}, + Usage: "Control what a sandbox can reach on the internet", + Subcommands: []*cli.Command{ + newFirewallShowCommand(), + newFirewallSetCommand(), + newFirewallClearCommand(), + }, + } +} + +func newFirewallShowCommand() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show what sites/IPs the sandbox is allowed to reach", + ArgsUsage: "[]", + Action: runFirewallShow, + } +} + +func runFirewallShow(c *cli.Context) error { + client, id, _, err := requireRunningSandbox(c, "Show firewall for which sandbox?") + if err != nil || id == "" { + return err + } + rules, err := client.GetEgress(c.Context, id) + if err != nil { + return err + } + output.Render(c, struct { + Rules []string `json:"rules"` + }{rules}, func() { + if len(rules) == 0 { + pterm.Success.Println("Firewall is open — all outbound traffic is allowed.") + pterm.Println(pterm.Gray(" Lock it down with: createos sandbox firewall set example.com 1.1.1.1")) + return + } + pterm.Println() + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Println(" Allowed outbound destinations:") + for _, r := range rules { + pterm.Printfln(" • %s", r) + } + pterm.Println() + pterm.Println(pterm.Gray(" Everything else is blocked at the host firewall.")) + }) + return nil +} + +func newFirewallSetCommand() *cli.Command { + return &cli.Command{ + Name: "set", + Usage: "Replace the allowlist with a new set of hosts / IPs", + ArgsUsage: " […]", + Description: `Lock the sandbox's outbound traffic to the given destinations. +Rules can be: + • a DNS name (pypi.org, github.com) + • a host:port (1.1.1.1:53) + • an IP literal (8.8.8.8) + +Examples: + createos sandbox firewall set my-box pypi.org github.com + createos sandbox firewall set my-box 1.1.1.1:53 example.com`, + Action: runFirewallSet, + } +} + +func runFirewallSet(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") + } + args := c.Args().Slice() + if len(args) < 2 { + if !terminal.IsInteractive() { + return fmt.Errorf("usage: createos sandbox firewall set […]") + } + // Sandbox picker, then prompt for rules — pre-filled with the + // current allowlist so "edit" works (add, remove, or tweak) + // instead of forcing the user to retype everything. + pickedID, label, err := pickByStatus(c, client, "Set firewall on which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled.") + return nil + } + current, _ := client.GetEgress(c.Context, pickedID) + prefill := strings.Join(current, ", ") + if len(current) == 0 { + pterm.Println(pterm.Gray(" Firewall is currently open. Enter destinations to lock it down, or leave empty to cancel.")) + } else { + pterm.Println(pterm.Gray(" Edit the current rules below. Clear to leave the firewall open (use 'firewall clear' instead).")) + } + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Allowed destinations (comma-separated)"). + WithDefaultValue(prefill). + Show() + if err != nil { + return fmt.Errorf("could not read rules: %w", err) + } + rules := splitRules(v) + if len(rules) == 0 { + fmt.Println("Cancelled. Rules unchanged.") + return nil + } + return applyFirewall(c, client, pickedID, label, rules) + } + sandboxRef, raw := args[0], args[1:] + id, err := resolveSandboxRef(c.Context, client, sandboxRef) + if err != nil { + return err + } + rules := make([]string, 0, len(raw)) + for _, r := range raw { + rules = append(rules, splitRules(r)...) + } + return applyFirewall(c, client, id, sandboxRef, rules) +} + +func newFirewallClearCommand() *cli.Command { + return &cli.Command{ + Name: "clear", + Usage: "Open the firewall — let the sandbox reach anywhere on the internet", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y", "force"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runFirewallClear, + } +} + +func runFirewallClear(c *cli.Context) error { + client, id, ref, err := requireRunningSandbox(c, "Clear firewall on which sandbox?") + if err != nil || id == "" { + return err + } + force := c.Bool("yes") + if terminal.IsInteractive() && !force { + ok, perr := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Open firewall on %s? All outbound traffic will be allowed.", refLabel(ref, id))). + WithDefaultValue(false). + Show() + if perr != nil { + return fmt.Errorf("could not read confirmation: %w", perr) + } + if !ok { + fmt.Println("Cancelled.") + return nil + } + } + if _, err := client.SetEgress(c.Context, id, []string{}); err != nil { + return err + } + pterm.Success.Printfln("Firewall cleared on %s — all outbound traffic allowed.", refLabel(ref, id)) + return nil +} + +// applyFirewall PUTs the rules and reports the new state. +func applyFirewall(c *cli.Context, client *api.SandboxClient, id, ref string, rules []string) error { + stored, err := client.SetEgress(c.Context, id, rules) + if err != nil { + return err + } + pterm.Success.Printfln("Firewall updated on %s — %d rule(s) active.", refLabel(ref, id), len(stored)) + for _, r := range stored { + pterm.Println(pterm.Gray(" • " + r)) + } + return nil +} + +// splitRules splits a comma- or whitespace-separated string into trimmed +// non-empty tokens. +func splitRules(s string) []string { + fields := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if t := strings.TrimSpace(f); t != "" { + out = append(out, t) + } + } + return out +} + +// requireRunningSandbox resolves the first positional arg (or prompts +// on TTY) into a sandbox id. Returns id="" and nil error on cancel. +func requireRunningSandbox(c *cli.Context, prompt string) (*api.SandboxClient, string, string, error) { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return nil, "", "", fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return client, "", "", fmt.Errorf("please provide a sandbox ID or name") + } + pickedID, label, err := pickByStatus(c, client, prompt, "running") + if err != nil { + return client, "", "", err + } + if pickedID == "" { + fmt.Println("Cancelled.") + return client, "", "", nil + } + return client, pickedID, label, nil + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return client, "", "", err + } + return client, id, ref, nil +} diff --git a/cmd/sandbox/fork.go b/cmd/sandbox/fork.go new file mode 100644 index 0000000..6cfe59a --- /dev/null +++ b/cmd/sandbox/fork.go @@ -0,0 +1,116 @@ +package sandbox + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newForkCommand() *cli.Command { + return &cli.Command{ + Name: "fork", + Usage: "Clone a paused sandbox into a brand-new one", + ArgsUsage: "[]", + Description: `Fork copies a paused sandbox's snapshot into a new sandbox ID. By +default the fork auto-resumes; pass --paused to keep it paused so you +can fork again or attach things first. + +Run with no argument on a terminal to pick from your paused sandboxes.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "paused", + Usage: "Leave the new sandbox paused instead of auto-resuming", + }, + &cli.StringSliceFlag{ + Name: "ssh-key", + Usage: "Override SSH public-key file for the fork (repeatable)", + }, + &cli.StringSliceFlag{ + Name: "egress", + Usage: "Override egress allowlist for the fork (repeatable)", + }, + }, + Action: runFork, + } +} + +func runFork(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") + } + + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name to fork from\n\n To see your paused sandboxes, run:\n createos sandbox list --status paused") + } + id, label, err := pickByStatus(c, client, "Pick a sandbox to fork", "paused") + if err != nil { + return err + } + if id == "" { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + return runForkByID(c, client, label, id) + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + return runForkByID(c, client, ref, id) +} + +func runForkByID(c *cli.Context, client *api.SandboxClient, ref, srcID string) error { + req := api.SandboxForkReq{ + StartPaused: c.Bool("paused"), + } + if keys, err := readSSHPubkeys(c.StringSlice("ssh-key")); err != nil { + return err + } else if len(keys) > 0 { + req.SSHPubkeys = keys + } + if egress := c.StringSlice("egress"); len(egress) > 0 { + req.Egress = egress + } + + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Forking %s…", refLabel(ref, srcID))) + view, err := client.ForkSandbox(c.Context, srcID, req) + if err != nil { + spinner.Fail("Fork failed") + return err + } + + target := "running" + if req.StartPaused { + target = "paused" + } + sb, err := waitForStatus(c.Context, client, view.ID, target) + if err != nil { + spinner.Fail("Fork did not finish") + return err + } + if sb.Status != target { + spinner.Fail(fmt.Sprintf("Fork ended in %q", sb.Status)) + return fmt.Errorf("sandbox %s is %s — see `createos sandbox get %s` for details", sb.ID, sb.Status, sb.ID) + } + + name := "" + if sb.Name != nil { + name = *sb.Name + } + spinner.Success(fmt.Sprintf("Forked into %s", refLabel(name, sb.ID))) + if sb.IP != nil && *sb.IP != "" { + fmt.Printf(" IP: %s\n", *sb.IP) + } + if sb.IngressURLTemplate != "" { + fmt.Printf(" URL: %s\n", sb.IngressURLTemplate) + } + return nil +} diff --git a/cmd/sandbox/get.go b/cmd/sandbox/get.go new file mode 100644 index 0000000..21d1774 --- /dev/null +++ b/cmd/sandbox/get.go @@ -0,0 +1,156 @@ +package sandbox + +import ( + "fmt" + "strings" + "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" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newGetCommand() *cli.Command { + return &cli.Command{ + Name: "get", + Usage: "Show details for one sandbox", + ArgsUsage: "", + Description: `Show every detail for a sandbox you own. + + createos sandbox get sb-01k...`, + Action: runGet, + } +} + +func runGet(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") + } + + ref := strings.TrimSpace(c.Args().First()) + var id string + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n To see your sandboxes, run:\n createos sandbox list") + } + pickedID, label, err := pickByStatus(c, client, "Show details for which sandbox?", "") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled.") + return nil + } + id, ref = pickedID, label + } else { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + } + + sb, err := client.GetSandbox(c.Context, id) + if err != nil { + return err + } + + // Bandwidth lives on a sibling endpoint. Best-effort: don't fail the + // whole get just because the counter is unavailable (e.g. destroyed + // sandbox where the meter is gone). + var bw *api.BandwidthView + if sb.Status == "running" || sb.Status == "paused" { + if v, berr := client.GetBandwidth(c.Context, id); berr == nil { + bw = v + } + } + + output.Render(c, struct { + *api.SandboxView + Bandwidth *api.BandwidthView `json:"bandwidth,omitempty"` + }{sb, bw}, func() { printSandbox(sb, bw) }) + return nil +} + +func printSandbox(s *api.SandboxView, bw *api.BandwidthView) { + label := pterm.NewStyle(pterm.FgCyan) + row := func(k, v string) { + if v == "" { + return + } + label.Printf(" %-15s ", k+":") + fmt.Println(v) + } + rowTime := func(k string, t *time.Time) { + if t == nil { + return + } + row(k, t.Format("2006-01-02 15:04:05")) + } + + // Header + pterm.Println() + if s.Name != nil && *s.Name != "" { + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s (%s)", *s.Name, s.ID) + } else { + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s", s.ID) + } + pterm.Println() + + row("Status", s.Status) + row("Size", s.Shape) + if s.Rootfs != nil && *s.Rootfs != "" { + row("Image", *s.Rootfs) + } + row("VCPU", fmt.Sprintf("%d", s.VCPU)) + row("RAM", fmt.Sprintf("%d MB", s.MemMib)) + row("Disk", fmt.Sprintf("%d MB", s.DiskMib)) + if s.IP != nil { + row("IP", *s.IP) + } + row("Region", s.Region) + + if s.IngressEnabled { + if s.IngressURLTemplate != "" { + row("Public URL", s.IngressURLTemplate) + } else { + row("Public URL", "on") + } + } else { + row("Public URL", "off") + } + + if len(s.Egress) > 0 { + row("Firewall", strings.Join(s.Egress, ", ")) + } else { + row("Firewall", "open (all outbound traffic allowed)") + } + + if bw != nil { + bwLine := fmt.Sprintf("%s used of %s", + humanBytes(bw.UsedBytes), humanBytes(bw.QuotaBytes)) + if bw.Capped { + bwLine += " (CAPPED — top up with 'sandbox edit')" + } else if bw.RemainingBytes < bw.QuotaBytes/10 { + bwLine += fmt.Sprintf(" (%s left)", humanBytes(bw.RemainingBytes)) + } + row("Bandwidth", bwLine) + } + if len(s.Envs) > 0 { + row("Env keys", strings.Join(s.Envs, ", ")) + } + + rowTime("Created", &s.CreatedAt) + rowTime("Running since", s.RunningAt) + rowTime("Paused at", s.PausedAt) + rowTime("Last resumed", s.LastResumedAt) + rowTime("Destroyed at", s.DestroyedAt) + + if s.ForkedFrom != nil && *s.ForkedFrom != "" { + row("Forked from", *s.ForkedFrom) + } +} diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go new file mode 100644 index 0000000..4a26052 --- /dev/null +++ b/cmd/sandbox/list.go @@ -0,0 +1,139 @@ +package sandbox + +import ( + "fmt" + "sort" + + "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" +) + +func newListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List your sandboxes", + Description: `Show your sandboxes. By default only running ones are shown. + +Examples: + # Running sandboxes + createos sandbox list + + # Every sandbox, including paused / destroyed / failed rows + createos sandbox list --all + + # Just one status + createos sandbox list --status paused + + # Pipe-friendly: IDs only + createos sandbox list --quiet`, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Value: 50, + Usage: "How many sandboxes to show", + }, + &cli.IntFlag{ + Name: "offset", + Value: 0, + Usage: "Skip the first N sandboxes (for paging)", + }, + &cli.StringFlag{ + Name: "status", + Usage: "Show only sandboxes in this state (running | creating | paused | destroyed | failed)", + }, + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Show sandboxes in every state, not just running ones", + }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Show only the IDs (great for scripting)", + }, + }, + Action: runList, + } +} + +func runList(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") + } + + // Default: running only. --all clears the filter. --status overrides both. + status := "running" + if explicit := c.String("status"); explicit != "" { + status = explicit + } else if c.Bool("all") { + status = "" + } + + rows, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{ + Limit: c.Int("limit"), + Offset: c.Int("offset"), + Status: status, + }) + if err != nil { + return err + } + + sort.SliceStable(rows, func(i, j int) bool { + return rows[i].CreatedAt.After(rows[j].CreatedAt) + }) + + // --quiet always wins, regardless of TTY / --output. Scripts that + // pipe `createos sandbox list --quiet | xargs ...` get plain IDs. + if c.Bool("quiet") { + for _, r := range rows { + fmt.Println(r.ID) + } + return nil + } + + output.Render(c, rows, func() { + if len(rows) == 0 { + if status == "" { + fmt.Println("You don't have any sandboxes yet.") + } else { + fmt.Printf("You don't have any %s sandboxes.\n", status) + } + fmt.Println() + pterm.Println(pterm.Gray(" Create one with: createos sandbox create")) + return + } + tableData := pterm.TableData{ + {"ID", "Name", "Status", "Size", "IP", "Created"}, + } + for _, r := range rows { + tableData = append(tableData, []string{ + r.ID, + strOrDash(r.Name), + r.Status, + r.Shape, + ptrOrDash(r.IP), + r.CreatedAt.Format("2006-01-02 15:04"), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + }) + return nil +} + +// strOrDash collapses a nullable pointer to "-" when empty so the +// table doesn't show ugly blank cells. +func strOrDash(s *string) string { + if s == nil || *s == "" { + return "-" + } + return *s +} + +// ptrOrDash mirrors strOrDash for any *string field. Kept as a +// separate name so the call sites read clearly. +func ptrOrDash(s *string) string { return strOrDash(s) } diff --git a/cmd/sandbox/mutagen_install.go b/cmd/sandbox/mutagen_install.go new file mode 100644 index 0000000..e2068c5 --- /dev/null +++ b/cmd/sandbox/mutagen_install.go @@ -0,0 +1,379 @@ +package sandbox + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// Mutagen version pinned by createos. Bump this when we want a newer +// engine across the install base; the on-disk cached binary at +// localMutagenPath() will be re-downloaded the next time someone runs +// `createos sync` without an upgraded binary on PATH. +const mutagenVersion = "v0.18.1" + +// mutagenSHA256 pins the expected sha256 of each release archive we +// might download. Mirrors the official SHA256SUMS at +// https://github.com/mutagen-io/mutagen/releases/download//SHA256SUMS. +// Without this we'd run whatever the CDN happens to serve — a MITM / +// account-takeover / cache-poison attacker could swap in a malicious +// binary and we'd execute it on the user's laptop. Keyed by the asset +// filename (NOT the full URL) so it's easy to diff against upstream. +// +// Bump mutagenVersion → also refresh these. +var mutagenSHA256 = map[string]string{ + "mutagen_linux_amd64_v0.18.1.tar.gz": "7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f", + "mutagen_linux_arm64_v0.18.1.tar.gz": "bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a", + "mutagen_darwin_amd64_v0.18.1.tar.gz": "7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf", + "mutagen_darwin_arm64_v0.18.1.tar.gz": "6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2", + "mutagen_windows_amd64_v0.18.1.zip": "76f8223d5e6b607efdd9516473669ae5492e4f142887352d59bc6934d1f07a2d", + "mutagen_windows_arm64_v0.18.1.zip": "d0dd95b60f6077f0c02baee3128f754c1507bc4abfa63ae0bcae12e01a3d45f1", +} + +// ensureMutagen returns an absolute path to a working `mutagen` +// binary. Resolution order: +// +// 1. mutagen already on $PATH — use it (lets users override the +// pinned version with their own install, including newer / dev +// builds). +// 2. mutagen previously downloaded by createos, sitting under +// ~/.config/createos/bin (or ~/.createos/bin on Windows) — reuse it +// without a network round-trip. +// 3. Otherwise: download the pinned version from GitHub releases for +// the current GOOS/GOARCH, extract the binary, chmod 0755, cache +// under (2)'s path, and return that. +// +// Side effect: prints a one-line progress note to stderr when it has +// to download, so the user knows what the brief delay is. +func ensureMutagen() (string, error) { + if p, err := exec.LookPath("mutagen"); err == nil { + return p, nil + } + dir, err := mutagenCacheDir() + if err != nil { + return "", err + } + target := filepath.Join(dir, mutagenBinaryName()) + if _, err := os.Stat(target); err == nil { + return target, nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create mutagen cache dir: %w", err) + } + url, ext, err := mutagenReleaseURL() + if err != nil { + return "", err + } + assetName := filepath.Base(url) + expectedHash := mutagenSHA256[assetName] + // First-time install message. Explains the why (we use mutagen as + // the sync engine), the where (github release URL), and the + // security check (sha256 pinned in source). One-time per host + // across the user's lifetime of createos — after this the binary is + // cached, no further downloads. + fmt.Fprintln(os.Stderr, "createos sync uses Mutagen (https://mutagen.io) as the bidirectional sync engine.") + fmt.Fprintf(os.Stderr, "First-time setup: downloading %s (%s/%s)\n", mutagenVersion, runtime.GOOS, runtime.GOARCH) + fmt.Fprintf(os.Stderr, " from: %s\n", url) + fmt.Fprintf(os.Stderr, " verifying against pinned sha256: %s…\n", expectedHash[:16]) + fmt.Fprintf(os.Stderr, " caching at: %s\n", target) + if err := downloadAndExtractMutagen(url, ext, target); err != nil { + return "", err + } + fmt.Fprintln(os.Stderr, "createos sync: mutagen installed.") + return target, nil +} + +// mutagenReleaseURL returns the GitHub-release tarball URL for the +// current host and the archive extension ("tar.gz" / "zip"). +func mutagenReleaseURL() (url, ext string, err error) { + goos, goarch := runtime.GOOS, runtime.GOARCH + supported := map[string]map[string]bool{ + "linux": {"amd64": true, "arm64": true}, + "darwin": {"amd64": true, "arm64": true}, + "windows": {"amd64": true, "arm64": true}, + } + if !supported[goos][goarch] { + return "", "", fmt.Errorf("no mutagen build for %s/%s — install it manually from https://mutagen.io", + goos, goarch) + } + ext = "tar.gz" + if goos == "windows" { + ext = "zip" + } + url = fmt.Sprintf( + "https://github.com/mutagen-io/mutagen/releases/download/%s/mutagen_%s_%s_%s.%s", + mutagenVersion, goos, goarch, mutagenVersion, ext) + return url, ext, nil +} + +// mutagenCacheDir is where createos drops the downloaded binary. We +// prefer XDG_CONFIG_HOME on unix and APPDATA on Windows; fall back to +// $HOME/.createos when neither is set (uncommon but happens in CI). +func mutagenCacheDir() (string, error) { + if runtime.GOOS == "windows" { + if appdata := os.Getenv("APPDATA"); appdata != "" { + return filepath.Join(appdata, "createos", "bin"), nil + } + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "createos", "bin"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "createos", "bin"), nil +} + +func mutagenBinaryName() string { + if runtime.GOOS == "windows" { + return "mutagen.exe" + } + return "mutagen" +} + +// downloadAndExtractMutagen pulls the release archive, unpacks just +// the `mutagen` binary, and writes it to target with 0755. Streams +// through memory rather than to disk — the binary is ~30 MB +// compressed which is fine for a one-time download. +// mutagenAgentBundleName is the file mutagen searches for in its +// binary's directory to find per-platform remote agents. Without this +// alongside `mutagen` itself, sync sessions fail at +// "unable to locate agent bundle" right after the daemon dials the +// remote SSH endpoint. +const mutagenAgentBundleName = "mutagen-agents.tar.gz" + +func downloadAndExtractMutagen(url, ext, target string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetch %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode) + } + // Stream the body through a progress reporter — prints bytes / total + // to stderr on a throttled cadence so the user sees something moving + // during the ~10-30 MiB download. Falls back to "X MiB" with no + // percentage when Content-Length is unset. + totalBytes := resp.ContentLength + pr := &progressReader{r: resp.Body, total: totalBytes, label: " downloading"} + body, err := io.ReadAll(io.LimitReader(pr, 200<<20)) // 200 MiB ceiling + pr.done() + if err != nil { + return fmt.Errorf("read body: %w", err) + } + // Integrity check: refuse to extract / install a binary that doesn't + // match the pinned sha256 for this version+platform. Without this, + // a compromised CDN or a MITM could swap the archive for something + // arbitrary and we'd happily run it on the user's laptop. + assetName := filepath.Base(url) + expected, ok := mutagenSHA256[assetName] + if !ok { + return fmt.Errorf("no pinned sha256 for %s — refusing to install (bump mutagenSHA256 in mutagen_install.go)", assetName) + } + sum := sha256.Sum256(body) + got := hex.EncodeToString(sum[:]) + if got != expected { + return fmt.Errorf("sha256 mismatch for %s: expected %s, got %s — refusing to install (possible CDN compromise or version drift)", + assetName, expected, got) + } + // Pull both the mutagen binary AND the agent bundle in one pass. + // The bundle must live next to the binary; without it sync sessions + // fail at "unable to locate agent bundle" the moment the daemon + // tries to push the remote agent. + var ( + bin, bundle []byte + extractErr error + ) + switch ext { + case "tar.gz": + bin, bundle, extractErr = extractMutagenFromTarGz(body, mutagenBinaryName(), mutagenAgentBundleName) + case "zip": + bin, bundle, extractErr = extractMutagenFromZip(body, mutagenBinaryName(), mutagenAgentBundleName) + default: + extractErr = fmt.Errorf("unsupported archive ext %q", ext) + } + if extractErr != nil { + return extractErr + } + if len(bin) == 0 { + return fmt.Errorf("mutagen binary not found inside archive at %s", url) + } + if len(bundle) == 0 { + return fmt.Errorf("%s not found inside archive at %s", mutagenAgentBundleName, url) + } + if err := os.WriteFile(target, bin, 0o755); err != nil { + return fmt.Errorf("write %s: %w", target, err) + } + bundlePath := filepath.Join(filepath.Dir(target), mutagenAgentBundleName) + if err := os.WriteFile(bundlePath, bundle, 0o644); err != nil { + return fmt.Errorf("write %s: %w", bundlePath, err) + } + return nil +} + +// progressReader wraps an io.Reader, counts bytes through, and emits a +// throttled stderr line so the user sees download progress. Carriage- +// return rewrites the same line; done() prints a final newline. +// +// Throttle to one repaint per 100ms — fast enough to feel live, slow +// enough not to flood logs when stderr is a pipe. +type progressReader struct { + r io.Reader + total int64 // -1 if unknown + read int64 + label string + lastAt time.Time + started bool +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + now := time.Now() + if !p.started { + p.started = true + p.lastAt = now + p.print() + } else if now.Sub(p.lastAt) >= 100*time.Millisecond { + p.lastAt = now + p.print() + } + return n, err +} + +func (p *progressReader) print() { + mib := func(b int64) float64 { return float64(b) / (1 << 20) } + if p.total > 0 { + pct := float64(p.read) * 100 / float64(p.total) + bar := renderBar(pct, 30) + fmt.Fprintf(os.Stderr, "\r%s %s %5.1f%% %.1f / %.1f MiB", + p.label, bar, pct, mib(p.read), mib(p.total)) + } else { + fmt.Fprintf(os.Stderr, "\r%s %.1f MiB", p.label, mib(p.read)) + } +} + +func (p *progressReader) done() { + if !p.started { + return + } + p.print() + fmt.Fprintln(os.Stderr) +} + +// renderBar returns a Unicode progress bar of the given width filled +// proportionally to pct (0..100). Uses block glyphs that align in +// monospace fonts on every terminal we ship to. +func renderBar(pct float64, width int) string { + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + filled := int(pct * float64(width) / 100) + if filled > width { + filled = width + } + bar := make([]byte, 0, width+2) + bar = append(bar, '[') + for i := 0; i < width; i++ { + if i < filled { + bar = append(bar, '#') + } else { + bar = append(bar, '.') + } + } + bar = append(bar, ']') + return string(bar) +} + +// extractMutagenFromTarGz walks the tarball once and returns both the +// mutagen binary and the agent bundle as raw bytes. Single pass keeps +// the entire archive in memory only briefly. +func extractMutagenFromTarGz(blob []byte, binName, bundleName string) (bin, bundle []byte, err error) { + gz, err := gzip.NewReader(bytes.NewReader(blob)) + if err != nil { + return nil, nil, fmt.Errorf("gzip open: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return bin, bundle, nil + } + if err != nil { + return nil, nil, fmt.Errorf("tar read: %w", err) + } + name := filepath.Base(h.Name) + switch name { + case binName: + bin, err = io.ReadAll(tr) + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", name, err) + } + case bundleName: + bundle, err = io.ReadAll(tr) + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", name, err) + } + } + if bin != nil && bundle != nil { + return bin, bundle, nil + } + } +} + +func extractMutagenFromZip(blob []byte, binName, bundleName string) (bin, bundle []byte, err error) { + zr, err := zip.NewReader(bytes.NewReader(blob), int64(len(blob))) + if err != nil { + return nil, nil, fmt.Errorf("zip open: %w", err) + } + for _, f := range zr.File { + name := filepath.Base(f.Name) + var dst *[]byte + switch name { + case binName: + dst = &bin + case bundleName: + dst = &bundle + default: + continue + } + rc, err := f.Open() + if err != nil { + return nil, nil, fmt.Errorf("zip entry open: %w", err) + } + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", name, err) + } + *dst = buf + if bin != nil && bundle != nil { + break + } + } + return bin, bundle, nil +} diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go new file mode 100644 index 0000000..13fcc4a --- /dev/null +++ b/cmd/sandbox/network.go @@ -0,0 +1,486 @@ +package sandbox + +import ( + "fmt" + "strings" + + "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" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newNetworkCommand returns the `sandbox network` group. Networks are +// private overlays that let one sandbox reach another by name or IP. +// Same shape as `sandbox disk`: create / ls / show / rm / attach / detach. +func newNetworkCommand() *cli.Command { + return &cli.Command{ + Name: "network", + Aliases: []string{"net", "networks"}, + Usage: "Manage private networks your sandboxes can talk over", + Subcommands: []*cli.Command{ + newNetworkCreateCommand(), + newNetworkListCommand(), + newNetworkShowCommand(), + newNetworkRmCommand(), + newNetworkAttachCommand(), + newNetworkDetachCommand(), + }, + } +} + +// ── create ─────────────────────────────────────────────────────── + +func newNetworkCreateCommand() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a new private network", + ArgsUsage: "[]", + Action: runNetworkCreate, + } +} + +func runNetworkCreate(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") + } + name := strings.TrimSpace(c.Args().First()) + if name == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please give the network a name\n\n Example:\n createos sandbox network create my-app") + } + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Name this network (your sandboxes will reach each other on it)"). + Show() + if err != nil { + return fmt.Errorf("could not read name: %w", err) + } + name = strings.TrimSpace(v) + if name == "" { + fmt.Println("Cancelled. No network created.") + return nil + } + } + n, err := client.CreateNetwork(c.Context, name) + if err != nil { + return err + } + pterm.Success.Printfln("Created network %s (%s)", n.Name, n.ID) + pterm.Println(pterm.Gray(" Attach at create time: createos sandbox create --network " + n.Name)) + pterm.Println(pterm.Gray(" Or live-attach later: createos sandbox network attach " + n.Name)) + return nil +} + +// ── list ───────────────────────────────────────────────────────── + +func newNetworkListCommand() *cli.Command { + return &cli.Command{ + Name: "ls", + Aliases: []string{"list"}, + Usage: "List your networks", + Action: runNetworkList, + } +} + +func runNetworkList(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") + } + nets, err := client.ListNetworks(c.Context) + if err != nil { + return err + } + output.Render(c, nets, func() { + if len(nets) == 0 { + fmt.Println("You don't have any networks yet.") + pterm.Println(pterm.Gray(" Create one with: createos sandbox network create ")) + return + } + table := pterm.TableData{{"Name", "ID", "Sandboxes", "Created"}} + for _, n := range nets { + table = append(table, []string{ + n.Name, n.ID, + fmt.Sprintf("%d", n.MemberCount), + n.CreatedAt.Format("2006-01-02 15:04"), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + }) + return nil +} + +// ── show ───────────────────────────────────────────────────────── + +func newNetworkShowCommand() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show one network's details (including attached sandboxes)", + ArgsUsage: "[]", + Action: runNetworkShow, + } +} + +func runNetworkShow(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") + } + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a network name or ID\n\n To see your networks, run:\n createos sandbox network ls") + } + picked, err := pickNetwork(c, client, "Show which network?") + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled. Nothing to show.") + return nil + } + ref = picked + } + n, err := client.GetNetwork(c.Context, ref) + if err != nil { + return err + } + output.Render(c, n, func() { + label := pterm.NewStyle(pterm.FgCyan) + row := func(k, v string) { + if v == "" { + return + } + label.Printf(" %-12s ", k+":") + fmt.Println(v) + } + pterm.Println() + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s (%s)", n.Name, n.ID) + pterm.Println() + row("Created", n.CreatedAt.Format("2006-01-02 15:04:05")) + row("Sandboxes", fmt.Sprintf("%d", n.MemberCount)) + + if len(n.Members) > 0 { + pterm.Println() + pterm.Println(pterm.Gray(" Attached sandboxes:")) + table := pterm.TableData{{"Sandbox", "Name", "Status", "IP", "Reachable as"}} + for _, m := range n.Members { + reachable := m.SandboxID + if m.Name != "" { + reachable = m.Name + "." + n.Name + ".fc.local" + } + table = append(table, []string{m.SandboxID, m.Name, m.Status, m.IP, reachable}) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + pterm.Println(pterm.Gray(" Tip: inside any of these sandboxes you can `ping ` or curl by name.")) + } + }) + return nil +} + +// ── rm ─────────────────────────────────────────────────────────── + +func newNetworkRmCommand() *cli.Command { + return &cli.Command{ + Name: "rm", + Aliases: []string{"delete"}, + Usage: "Delete one or more networks (each must have no live members)", + ArgsUsage: "[ …]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y", "force"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runNetworkRm, + } +} + +func runNetworkRm(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") + } + // Collect refs from positionals; accept --yes/-y/--force after positionals too. + refs, forceFromArgs := splitForceFlag(c.Args().Slice()) + if len(refs) == 0 { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide at least one network name or ID") + } + picked, err := pickNetworksForDelete(c, client) + if err != nil { + return err + } + if len(picked) == 0 { + fmt.Println("Cancelled.") + return nil + } + refs = picked + } + force := c.Bool("yes") || forceFromArgs + if !terminal.IsInteractive() && !force { + return fmt.Errorf("non-interactive: pass --yes to confirm deletion") + } + if terminal.IsInteractive() && !force { + prompt := fmt.Sprintf("Permanently delete network %q?", refs[0]) + if len(refs) > 1 { + prompt = fmt.Sprintf("Permanently delete %d networks?", len(refs)) + } + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(prompt). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + fmt.Println("Cancelled.") + return nil + } + } + failed := 0 + for _, ref := range refs { + if err := deleteNetworkCascade(c, client, ref); err != nil { + pterm.Error.Printfln("%s: %v", ref, err) + failed++ + continue + } + pterm.Success.Printfln("Deleted network %s", ref) + } + if failed > 0 { + return fmt.Errorf("%d of %d deletes failed", failed, len(refs)) + } + return nil +} + +// deleteNetworkCascade detaches any attached sandboxes, then deletes +// the network. "rm" means "remove it, including the wiring" — users +// shouldn't have to detach manually first. +func deleteNetworkCascade(c *cli.Context, client *api.SandboxClient, ref string) error { + n, err := client.GetNetwork(c.Context, ref) + if err != nil { + return err + } + for _, m := range n.Members { + if m.SandboxID == "" { + continue + } + if derr := client.DetachNetwork(c.Context, m.SandboxID, n.ID); derr != nil { + return fmt.Errorf("detach %s: %w", m.SandboxID, derr) + } + pterm.Println(pterm.Gray(fmt.Sprintf(" detached %s from %s", m.SandboxID, n.Name))) + } + return client.DeleteNetwork(c.Context, ref) +} + +// pickNetworksForDelete renders a multi-select over the caller's networks +// and returns the picked names. Returns nil/empty when the user cancels. +func pickNetworksForDelete(c *cli.Context, client *api.SandboxClient) ([]string, error) { + nets, err := client.ListNetworks(c.Context) + if err != nil { + return nil, err + } + if len(nets) == 0 { + fmt.Println("You don't have any networks to delete.") + return nil, nil + } + 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) + options = append(options, opt) + byOpt[opt] = n.Name + } + picked, err := multiselect("Pick networks to delete (space = select, enter = confirm)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + out := make([]string, 0, len(picked)) + for _, p := range picked { + if name, ok := byOpt[p]; ok { + out = append(out, name) + } + } + return out, nil +} + +// ── attach ─────────────────────────────────────────────────────── + +func newNetworkAttachCommand() *cli.Command { + return &cli.Command{ + Name: "attach", + Usage: "Add a sandbox to a network", + ArgsUsage: "[ ]", + Action: runNetworkAttach, + } +} + +func runNetworkAttach(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") + } + args := c.Args().Slice() + sandboxRef, netRef := "", "" + if len(args) > 0 { + sandboxRef = args[0] + } + if len(args) > 1 { + netRef = args[1] + } + tty := terminal.IsInteractive() + if sandboxRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox network attach ") + } + pickedID, label, err := pickByStatus(c, client, "Attach which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled.") + return nil + } + sandboxRef = label + } + sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) + if err != nil { + return err + } + if netRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox network attach ") + } + picked, err := pickNetwork(c, client, "Attach to which network?") + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled.") + return nil + } + netRef = picked + } + if err := client.AttachNetwork(c.Context, sandboxID, netRef); err != nil { + return err + } + pterm.Success.Printfln("Attached %s → network %s", refLabel(sandboxRef, sandboxID), netRef) + pterm.Println(pterm.Gray(" Other sandboxes on this network can now reach this one by name.")) + return nil +} + +// ── detach ─────────────────────────────────────────────────────── + +func newNetworkDetachCommand() *cli.Command { + return &cli.Command{ + Name: "detach", + Usage: "Remove a sandbox from a network", + ArgsUsage: "[ ]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runNetworkDetach, + } +} + +func runNetworkDetach(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") + } + args := c.Args().Slice() + sandboxRef, netRef := "", "" + if len(args) > 0 { + sandboxRef = args[0] + } + if len(args) > 1 { + netRef = args[1] + } + tty := terminal.IsInteractive() + if sandboxRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox network detach ") + } + pickedID, label, err := pickByStatus(c, client, "Detach from which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled.") + return nil + } + sandboxRef = label + } + sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) + if err != nil { + return err + } + if netRef == "" { + if !tty { + return fmt.Errorf("usage: createos sandbox network detach ") + } + picked, err := pickNetwork(c, client, "Detach from which network?") + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled.") + return nil + } + netRef = picked + } + force := c.Bool("yes") + if !tty && !force { + return fmt.Errorf("non-interactive: pass --yes to confirm detach") + } + if tty && !force { + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Remove %s from network %s?", refLabel(sandboxRef, sandboxID), netRef)). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + fmt.Println("Cancelled.") + return nil + } + } + if err := client.DetachNetwork(c.Context, sandboxID, netRef); err != nil { + return err + } + pterm.Success.Printfln("Detached %s from network %s", refLabel(sandboxRef, sandboxID), netRef) + return nil +} + +// 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. +func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (string, error) { + nets, err := client.ListNetworks(c.Context) + if err != nil { + return "", err + } + if len(nets) == 0 { + fmt.Println("You don't have any networks yet.") + pterm.Println(pterm.Gray(" Create one with: createos sandbox network create ")) + return "", nil + } + 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) + options = append(options, opt) + byOpt[opt] = n.Name + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(title). + Show() + if err != nil { + return "", fmt.Errorf("could not read your selection: %w", err) + } + return byOpt[picked], nil +} diff --git a/cmd/sandbox/pause.go b/cmd/sandbox/pause.go new file mode 100644 index 0000000..06ab047 --- /dev/null +++ b/cmd/sandbox/pause.go @@ -0,0 +1,71 @@ +package sandbox + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newPauseCommand() *cli.Command { + return &cli.Command{ + Name: "pause", + Usage: "Snapshot a running sandbox so you can resume it later", + ArgsUsage: "[]", + Description: `Pause snapshots the sandbox to durable storage and tears down +the live VM. Resume restores it (possibly on a different machine). +The sandbox keeps its name, ID, disks, networks, and stored env vars. + +Run with no argument on a terminal to pick from your running sandboxes.`, + Action: runPause, + } +} + +func runPause(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") + } + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n To see your running sandboxes, run:\n createos sandbox list") + } + id, label, err := pickByStatus(c, client, "Pick a sandbox to pause", "running") + if err != nil { + return err + } + if id == "" { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + return runPauseByID(c, client, label, id) + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + return runPauseByID(c, client, ref, id) +} + +func runPauseByID(c *cli.Context, client *api.SandboxClient, ref, id string) error { + if _, err := client.PauseSandbox(c.Context, id); err != nil { + return err + } + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Pausing %s…", refLabel(ref, id))) + sb, err := waitForStatus(c.Context, client, id, "paused") + if err != nil { + spinner.Fail("Pause did not complete") + return err + } + if sb.Status != "paused" { + spinner.Fail(fmt.Sprintf("Pause ended in %q", sb.Status)) + return fmt.Errorf("sandbox %s is %s — see `createos sandbox get %s` for details", refLabel(ref, id), sb.Status, id) + } + spinner.Success(fmt.Sprintf("Paused %s", refLabel(ref, id))) + return nil +} diff --git a/cmd/sandbox/pull.go b/cmd/sandbox/pull.go new file mode 100644 index 0000000..6ef561d --- /dev/null +++ b/cmd/sandbox/pull.go @@ -0,0 +1,73 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newPullCommand() *cli.Command { + return &cli.Command{ + Name: "pull", + Aliases: []string{"download", "cp-down"}, + Usage: "Copy a file out of a sandbox", + ArgsUsage: " ", + Description: `Download a file from a sandbox to your machine. +A local path of "-" streams the bytes to stdout — useful in pipes. + +Examples: + # Write to a real file + createos sandbox pull my-box /workspace/result.csv ./result.csv + + # Stream to stdout + createos sandbox pull my-box /workspace/result.csv - | head -5`, + Action: runPull, + } +} + +func runPull(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") + } + args := c.Args().Slice() + if len(args) < 3 { + return fmt.Errorf("please provide \n\n Example:\n createos sandbox pull my-box /workspace/result.csv ./result.csv") + } + ref, remote, local := strings.TrimSpace(args[0]), args[1], args[2] + if !strings.HasPrefix(remote, "/") { + return fmt.Errorf("remote path must be absolute (got %q)\n\n Example: /workspace/result.csv", remote) + } + + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + + // "-" writes to stdout. Anything else is a real file we create. + if local == "-" { + _, err := client.DownloadFile(c.Context, id, remote, os.Stdout) + return err + } + + f, err := os.Create(local) + if err != nil { + return fmt.Errorf("could not create %s: %w", local, err) + } + defer f.Close() + + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading %s:%s → %s", refLabel(ref, id), remote, local)) + n, err := client.DownloadFile(c.Context, id, remote, f) + if err != nil { + spinner.Fail("Download failed") + _ = os.Remove(local) // don't leave a half-written file behind + return err + } + spinner.Success(fmt.Sprintf("Downloaded %s:%s → %s (%s)", refLabel(ref, id), remote, local, humanBytes(n))) + return nil +} diff --git a/cmd/sandbox/push.go b/cmd/sandbox/push.go new file mode 100644 index 0000000..b260cb7 --- /dev/null +++ b/cmd/sandbox/push.go @@ -0,0 +1,115 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newPushCommand() *cli.Command { + return &cli.Command{ + Name: "push", + Aliases: []string{"upload", "cp-up"}, + Usage: "Copy a local file into a sandbox", + ArgsUsage: " ", + Description: `Upload a file from your machine into a sandbox. + +Examples: + # Single file + createos sandbox push my-box ./main.py /workspace/main.py + + # Stream a tarball from a directory; unpack inside the sandbox afterwards + tar -c mydir | createos sandbox push my-box - /tmp/bundle.tar + +Max 500 MB per file. The remote path must be absolute. Parent +directories are created automatically.`, + Action: runPush, + } +} + +func runPush(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") + } + args := c.Args().Slice() + if len(args) < 3 { + return fmt.Errorf("please provide \n\n Example:\n createos sandbox push my-box ./main.py /workspace/main.py") + } + ref, local, remote := strings.TrimSpace(args[0]), args[1], args[2] + if !strings.HasPrefix(remote, "/") { + return fmt.Errorf("remote path must be absolute (got %q)\n\n Example: /workspace/main.py", remote) + } + + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + + // Open the source: a real file (we know its size for Content-Length) + // or stdin ("-") for piped uploads. + var ( + src interface { + Read(p []byte) (int, error) + } + size int64 + label string + closer func() error + ) + if local == "-" { + src = os.Stdin + size = 0 + label = "(stdin)" + closer = func() error { return nil } + } else { + f, err := os.Open(local) + if err != nil { + return fmt.Errorf("could not open %s: %w", local, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return fmt.Errorf("could not stat %s: %w", local, err) + } + if info.IsDir() { + _ = f.Close() + return fmt.Errorf("%s is a directory — push handles single files. Tar it first:\n tar -c %s | createos sandbox push %s - /tmp/bundle.tar", local, local, ref) + } + src = f + size = info.Size() + label = local + closer = f.Close + } + defer func() { _ = closer() }() + + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Uploading %s → %s:%s", label, refLabel(ref, id), remote)) + if err := client.UploadFile(c.Context, id, remote, src, size); err != nil { + spinner.Fail("Upload failed") + return err + } + if size > 0 { + spinner.Success(fmt.Sprintf("Uploaded %s → %s:%s (%s)", label, refLabel(ref, id), remote, humanBytes(size))) + } else { + spinner.Success(fmt.Sprintf("Uploaded %s → %s:%s", label, refLabel(ref, id), remote)) + } + return nil +} + +// humanBytes renders a size like "4.2 MB" — small, no units library. +func humanBytes(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1f GB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f kB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} diff --git a/cmd/sandbox/resolve.go b/cmd/sandbox/resolve.go new file mode 100644 index 0000000..355424c --- /dev/null +++ b/cmd/sandbox/resolve.go @@ -0,0 +1,99 @@ +package sandbox + +import ( + "context" + "fmt" + "sort" + "strings" + + "atomicgo.dev/keyboard/keys" + "github.com/pterm/pterm" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +// multiselect returns a pterm multiselect printer configured with +// space-to-select / enter-to-confirm, which matches what every other +// modern CLI (gum, huh, fzf) uses. pterm's default is enter-to-select +// / tab-to-confirm, which most users find unintuitive. Filter is off +// because Space conflicts with the filter input. +func multiselect(title string) *pterm.InteractiveMultiselectPrinter { + return pterm.DefaultInteractiveMultiselect. + WithDefaultText(title). + WithFilter(false). + WithKeySelect(keys.Space). + WithKeyConfirm(keys.Enter). + WithCheckmark(&pterm.Checkmark{Checked: "x", Unchecked: " "}) +} + +// sandboxIDPrefix is the literal prefix every fc-spawn sandbox id +// starts with. Anything that already carries the prefix is treated as +// an id and returned without a list-and-match round trip. +const sandboxIDPrefix = "sb-" + +// splitForceFlag separates positional refs from --force/--yes/-y tokens. +// urfave/cli v2 stops flag parsing at the first positional, so `rm A B +// --yes` would otherwise treat `--yes` as another ref. Returns the +// stripped positional list and whether a force flag appeared anywhere. +func splitForceFlag(args []string) (refs []string, force bool) { + refs = make([]string, 0, len(args)) + for _, a := range args { + a = strings.TrimSpace(a) + switch a { + case "": + continue + case "--yes", "-y", "--force", "-yes": + force = true + continue + } + refs = append(refs, a) + } + return refs, force +} + +// resolveSandboxRef resolves a sandbox identifier supplied on the CLI. +// The user can pass either a raw id (`sb-`) 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. +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 { + return "", err + } + matches := make([]api.SandboxView, 0) + for _, r := range rows { + if r.Name != nil && *r.Name == ref { + matches = append(matches, r) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("no sandbox named %q\n\n To see your sandboxes, run:\n createos sandbox list", ref) + } + // 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) + }) + return matches[0].ID, nil +} diff --git a/cmd/sandbox/resume.go b/cmd/sandbox/resume.go new file mode 100644 index 0000000..81b6c68 --- /dev/null +++ b/cmd/sandbox/resume.go @@ -0,0 +1,70 @@ +package sandbox + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newResumeCommand() *cli.Command { + return &cli.Command{ + Name: "resume", + Usage: "Bring a paused sandbox back to life", + ArgsUsage: "[]", + Description: `Resume restores a paused sandbox — possibly on a different machine. +The sandbox keeps its name, ID, disks, networks, and stored env vars. + +Run with no argument on a terminal to pick from your paused sandboxes.`, + Action: runResume, + } +} + +func runResume(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") + } + ref := strings.TrimSpace(c.Args().First()) + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n To see your paused sandboxes, run:\n createos sandbox list --status paused") + } + id, label, err := pickByStatus(c, client, "Pick a sandbox to resume", "paused") + if err != nil { + return err + } + if id == "" { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + return runResumeByID(c, client, label, id) + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + return runResumeByID(c, client, ref, id) +} + +func runResumeByID(c *cli.Context, client *api.SandboxClient, ref, id string) error { + if _, err := client.ResumeSandbox(c.Context, id); err != nil { + return err + } + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Resuming %s…", refLabel(ref, id))) + sb, err := waitForStatus(c.Context, client, id, "running") + if err != nil { + spinner.Fail("Resume did not complete") + return err + } + if sb.Status != "running" { + spinner.Fail(fmt.Sprintf("Resume ended in %q", sb.Status)) + return fmt.Errorf("sandbox %s is %s — see `createos sandbox get %s` for details", refLabel(ref, id), sb.Status, id) + } + spinner.Success(fmt.Sprintf("Resumed %s", refLabel(ref, id))) + return nil +} diff --git a/cmd/sandbox/rm.go b/cmd/sandbox/rm.go new file mode 100644 index 0000000..d0d8cf0 --- /dev/null +++ b/cmd/sandbox/rm.go @@ -0,0 +1,190 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newRmCommand() *cli.Command { + return &cli.Command{ + Name: "rm", + Aliases: []string{"delete", "destroy"}, + Usage: "Delete one or more sandboxes", + ArgsUsage: "[ …]", + Description: `Delete one or more sandboxes. Teardown is irreversible. + +Examples: + # Delete two specific sandboxes + createos sandbox rm sb-01k... sb-01k... + + # Pipe IDs from list + createos sandbox list --quiet --status failed | xargs createos sandbox rm --force + + # In a script — non-interactive, must pass --force + createos sandbox rm sb-01k... --force`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"y", "yes"}, + Usage: "Skip the confirmation prompt (required in non-interactive mode)", + }, + }, + Action: runRm, + } +} + +func runRm(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") + } + + // Collect ids from positionals AND pick up the --force/-y flag even + // when it lands after a positional (urfave/cli v2 stops flag parsing + // at the first positional, so `rm SB1 --force` wouldn't otherwise + // be honoured). Strip recognised flag tokens from the id list. + args := c.Args().Slice() + ids := make([]string, 0, len(args)) + forceFromArgs := false + for _, a := range args { + a = strings.TrimSpace(a) + switch a { + case "": + continue + case "--force", "-y", "--yes", "-yes": + forceFromArgs = true + continue + } + ids = append(ids, a) + } + + if len(ids) == 0 { + // Interactive: let the user pick from their running + paused + // sandboxes. Non-interactive: error early so scripts don't hang. + if !terminal.IsInteractive() { + return fmt.Errorf("please provide at least one sandbox ID\n\n To see your sandboxes and their IDs, run:\n createos sandbox list") + } + picked, err := pickSandboxesForDelete(c, client) + if err != nil { + return err + } + ids = picked + if len(ids) == 0 { + fmt.Println("Cancelled. No sandboxes were deleted.") + return nil + } + } + + force := c.Bool("force") || forceFromArgs + if !terminal.IsInteractive() && !force { + return fmt.Errorf("non-interactive mode: use --force to confirm deletion\n\n Example:\n createos sandbox rm %s --force", ids[0]) + } + + if terminal.IsInteractive() && !force { + confirm, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(confirmText(ids)). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !confirm { + fmt.Println("Cancelled. No sandboxes were deleted.") + return nil + } + } + + // Resolve any names to IDs before deleting. Resolution failures + // are reported per-ref so a typo in one name doesn't kill the rest + // of the batch. + failed := 0 + for _, ref := range ids { + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + pterm.Error.Printfln("%s: %v", ref, err) + failed++ + continue + } + if err := client.DestroySandbox(c.Context, id); err != nil { + pterm.Error.Printfln("%s: %v", ref, err) + failed++ + continue + } + // Echo the friendly ref the user typed; if it was already an + // id this reads the same, if it was a name they see what was + // actually removed. + if id != ref { + pterm.Success.Printfln("Deleted %s (%s)", ref, id) + } else { + pterm.Success.Printfln("Deleted %s", id) + } + } + if failed > 0 { + // Non-zero exit so scripts can tell something went wrong. + os.Exit(1) + } + return nil +} + +// confirmText keeps the prompt short for one sandbox and explicit for many. +func confirmText(ids []string) string { + if len(ids) == 1 { + return fmt.Sprintf("Permanently delete sandbox %s?", ids[0]) + } + return fmt.Sprintf("Permanently delete %d sandboxes?", len(ids)) +} + +// pickSandboxesForDelete shows a checkbox list of the user's running + +// paused sandboxes and returns the picked IDs. Headless callers never +// reach this — the caller bails earlier. +func pickSandboxesForDelete(c *cli.Context, client *api.SandboxClient) ([]string, error) { + // Pull both states; the API only takes one ?status= at a time so do two calls. + var rows []api.SandboxView + for _, st := range []string{"running", "paused"} { + page, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{ + Limit: 200, Status: st, + }) + if err != nil { + return nil, err + } + rows = append(rows, page...) + } + if len(rows) == 0 { + fmt.Println("You don't have any running or paused sandboxes to delete.") + return nil, nil + } + + options := make([]string, 0, len(rows)) + idByOption := make(map[string]string, len(rows)) + for _, r := range rows { + label := r.ID + if r.Name != nil && *r.Name != "" { + label = *r.Name + " " + r.ID + " " + r.Status + } else { + label = r.ID + " " + r.Status + } + options = append(options, label) + idByOption[label] = r.ID + } + + picked, err := multiselect("Pick sandboxes to delete (space = select, enter = confirm)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + out := make([]string, 0, len(picked)) + for _, p := range picked { + if id, ok := idByOption[p]; ok { + out = append(out, id) + } + } + return out, nil +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go new file mode 100644 index 0000000..b4704fd --- /dev/null +++ b/cmd/sandbox/sandbox.go @@ -0,0 +1,39 @@ +// Package sandbox holds the `createos sandbox` command tree — +// lifecycle, exec, files, networking, and disk management against the +// fc-spawn backend. +package sandbox + +import ( + "github.com/urfave/cli/v2" +) + +// NewSandboxCommand returns the parent `sandbox` group. +func NewSandboxCommand() *cli.Command { + return &cli.Command{ + Name: "sandbox", + Aliases: []string{"sb"}, + Usage: "Manage sandboxes", + Subcommands: []*cli.Command{ + newCreateCommand(), + newListCommand(), + newGetCommand(), + newRmCommand(), + newEditCommand(), + newPauseCommand(), + newResumeCommand(), + newForkCommand(), + newExecCommand(), + newPushCommand(), + newPullCommand(), + newShellCommand(), + newSyncCommand(), + newTunnelCommand(), + newDiskCommand(), + newNetworkCommand(), + newFirewallCommand(), + newTemplateCommand(), + newShapesCommand(), + newRootfsCommand(), + }, + } +} diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go new file mode 100644 index 0000000..a098020 --- /dev/null +++ b/cmd/sandbox/shell.go @@ -0,0 +1,669 @@ +package sandbox + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "net" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + "golang.org/x/term" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/config" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +func newShellCommand() *cli.Command { + return &cli.Command{ + Name: "shell", + Aliases: []string{"sh"}, + Usage: "Open an interactive shell inside a sandbox", + ArgsUsage: "[]", + Description: `Open a real terminal session inside a sandbox. +Works with tools that need a TTY — vim, htop, bash prompts. + +By default this opens a PTY directly through the control plane — no +SSH keys, no sshd setup. Your existing API token is the only auth. + +Pass --ssh (or -i ) to use the SSH path instead: that pushes your +public key into the sandbox, starts sshd, opens a tunnel, and hands +you off to system 'ssh'. Useful if you want OpenSSH features (agent +forwarding, ProxyJump, etc.). + +Examples: + createos sandbox shell # pick from list, keyless PTY + createos sandbox shell my-box # keyless PTY + createos sandbox shell my-box --ssh # SSH path (auto-detect ~/.ssh) + createos sandbox shell my-box -i ~/.ssh/id # SSH with explicit key + createos sandbox shell my-box --user app # log in as a non-root user`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "ssh", + Usage: "Use the SSH path instead of the keyless API PTY (also implied by -i)", + }, + &cli.StringFlag{ + Name: "identity", + Aliases: []string{"i"}, + Usage: "Path to your SSH private key (only used with --ssh; defaults to ~/.ssh/id_ed25519, then id_rsa, then id_ecdsa)", + }, + &cli.StringFlag{ + Name: "user", + Aliases: []string{"u"}, + Value: "root", + Usage: "Username inside the sandbox (SSH path only — the keyless PTY always runs as the sandbox's default user)", + }, + }, + Action: runShell, + } +} + +func runShell(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") + } + ref := strings.TrimSpace(c.Args().First()) + var id string + if ref == "" { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide a sandbox ID or name\n\n Example:\n createos sandbox shell my-box") + } + pickedID, label, err := pickByStatus(c, client, "Shell into which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing happened.") + return nil + } + id = pickedID + ref = label + } else { + resolvedID, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolvedID + } + + // Default to the keyless PTY path — no SSH key, no sshd setup, just + // the user's API token. The SSH-based path is opt-in: explicitly via + // --ssh, or implicitly when the user names a key with -i. + useSSH := c.Bool("ssh") || strings.TrimSpace(c.String("identity")) != "" + if !useSSH { + return runShellPTY(c, id, ref) + } + return runShellSSH(c, client, id, ref) +} + +// runShellSSH installs an SSH key into the sandbox, starts sshd, +// tunnels through the control plane, and hands control to system 'ssh' +// for a real PTY. Opt-in path: only used when --ssh or -i is set. +func runShellSSH(c *cli.Context, client *api.SandboxClient, id, ref string) error { + privPath, pubPath, err := resolveIdentity(c.String("identity")) + if err != nil { + return err + } + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + return fmt.Errorf("could not read public key %s: %w", pubPath, err) + } + + user := strings.TrimSpace(c.String("user")) + if user == "" { + user = "root" + } + + // 1. Drop the pubkey into the sandbox's authorized_keys via the + // file API. sshd refuses keys unless ~/.ssh is 0700 and the + // file is 0600 — we chmod in the next step. + authPath := authorizedKeysPath(user) + if err := client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { + return fmt.Errorf("could not install your SSH key: %w", err) + } + + // 2. Make the modes right + start sshd. The script tolerates the + // "Address already in use" exit when sshd is already running, + // and recreates /run/sshd which is on tmpfs and disappears + // each boot. + 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 +chmod 700 %[1]s +chmod 600 %[1]s/authorized_keys +chown -R %[2]s:%[2]s %[1]s 2>/dev/null || true +# Start sshd if :22 isn't already bound. /proc/net/tcp is everywhere; +# ss/netstat aren't. +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) + resp, err := client.ExecSandbox(c.Context, id, api.SandboxExecReq{ + Cmd: "sh", + Args: []string{"-c", prepScript}, + }) + if err != nil { + return fmt.Errorf("could not prepare sshd: %w", err) + } + if resp.Result.ExitCode == 100 { + return fmt.Errorf("the sandbox image doesn't have sshd installed — try a different rootfs (e.g. `--rootfs devbox:1`)") + } + if resp.Result.ExitCode != 0 { + return fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(resp.Result.Stderr)) + } + + // 3. Open a local TCP listener that bridges every accepted + // connection through the control plane to the sandbox's :22. + ctx, cancel := context.WithCancel(c.Context) + defer cancel() + bridge, err := startTunnelBridge(ctx, c, id, 22) + if err != nil { + return fmt.Errorf("could not open tunnel to the sandbox: %w", err) + } + defer bridge.close() + + if err := waitForTCP(bridge.localAddr, 5*time.Second); err != nil { + return fmt.Errorf("sshd did not start in time: %w", err) + } + + // 4. Hand off to system ssh through the local tunnel for a real PTY. + _, port, _ := net.SplitHostPort(bridge.localAddr) + pterm.Println(pterm.Gray(fmt.Sprintf(" connecting to %s as %s…", refLabel(ref, id), user))) + sshCmd := exec.Command( + "ssh", + "-p", port, + "-i", privPath, + // IdentitiesOnly stops ssh from offering keys from the agent; + // IdentityAgent=none stops it from loading default identity + // files (~/.ssh/id_ed25519 etc.) which may be passphrase- + // protected and prompt on a TTY. Together they pin auth to + // exactly the -i key we installed on the sandbox. + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-t", + "-l", user, + "127.0.0.1", + ) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + err = sshCmd.Run() + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err +} + +// ── Keyless PTY path ────────────────────────────────────────────── +// +// Talks to POST /v1/sandboxes/:id/shell. The server upgrades the HTTP +// connection to a raw byte pipe to the sandbox's PTY listener. We frame +// our half: 0x00 (data) + 4-byte BE length + bytes, or 0x01 (resize) + +// 4-byte BE length + u16 rows + u16 cols. The agent's stdout/stderr +// come back unframed. + +const ( + ptyFrameData = 0 + ptyFrameResize = 1 +) + +// runShellPTY puts the local terminal in raw mode and pumps a real PTY +// session that runs inside the sandbox. Auth is the API token only. +func runShellPTY(c *cli.Context, id, ref string) error { + stdinFd := int(os.Stdin.Fd()) + if !term.IsTerminal(stdinFd) { + return fmt.Errorf("shell needs a real terminal — re-run interactively, or pass --ssh for the SSH path") + } + + ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) + if ctrlURL == "" { + ctrlURL = api.DefaultSandboxBaseURL + } + token, err := loadAPIToken() + if err != nil { + return err + } + + conn, err := dialControlUpgrade(c.Context, ctrlURL, token, "/v1/sandboxes/"+id+"/shell") + if err != nil { + return err + } + defer conn.Close() + + pterm.Println(pterm.Gray(fmt.Sprintf(" connecting to %s…", refLabel(ref, id)))) + + oldState, err := term.MakeRaw(stdinFd) + if err != nil { + return fmt.Errorf("could not switch terminal to raw mode: %w", err) + } + defer func() { _ = term.Restore(stdinFd, oldState) }() + + // frameMu serialises writes to conn — the stdin pump and the + // SIGWINCH handler both emit frames, and an interleaved write would + // corrupt one of them. + var frameMu sync.Mutex + sendResize(conn, &frameMu, stdinFd) + + winch := make(chan os.Signal, 1) + signal.Notify(winch, syscall.SIGWINCH) + defer signal.Stop(winch) + go func() { + for range winch { + sendResize(conn, &frameMu, stdinFd) + } + }() + + done := make(chan struct{}, 2) + // remote → local screen + go func() { + _, _ = io.Copy(os.Stdout, conn) + done <- struct{}{} + }() + // local keystrokes → framed stdin + go func() { + buf := make([]byte, 4096) + for { + n, rerr := os.Stdin.Read(buf) + if n > 0 { + if werr := writeFrame(conn, &frameMu, ptyFrameData, buf[:n]); werr != nil { + break + } + } + if rerr != nil { + break + } + } + done <- struct{}{} + }() + <-done + return nil +} + +// sendResize reads the current terminal size and emits a resize frame. +func sendResize(conn io.Writer, mu *sync.Mutex, fd int) { + cols, rows, err := term.GetSize(fd) + if err != nil { + return + } + var p [4]byte + binary.BigEndian.PutUint16(p[0:2], uint16(rows)) + binary.BigEndian.PutUint16(p[2:4], uint16(cols)) + _ = writeFrame(conn, mu, ptyFrameResize, p[:]) +} + +// writeFrame emits one [type:1][len:4 BE][payload] frame under mu. +func writeFrame(w io.Writer, mu *sync.Mutex, typ byte, payload []byte) error { + var hdr [5]byte + hdr[0] = typ + binary.BigEndian.PutUint32(hdr[1:5], uint32(len(payload))) + mu.Lock() + defer mu.Unlock() + if _, err := w.Write(hdr[:]); err != nil { + return err + } + if len(payload) > 0 { + if _, err := w.Write(payload); err != nil { + return err + } + } + return nil +} + +// dialControlUpgrade dials the control plane, performs an HTTP/1.1 +// Upgrade handshake, and returns the raw connection on a 101 reply. +// Used by the keyless PTY path — same wire shape as the tunnel bridge +// but with a different target path. +func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.Conn, error) { + u, err := url.Parse(ctrlURL) + if err != nil { + return nil, fmt.Errorf("bad sandbox URL %q: %w", ctrlURL, err) + } + host := u.Host + var conn net.Conn + d := &net.Dialer{Timeout: 10 * time.Second} + if u.Scheme == "https" { + if !strings.Contains(host, ":") { + host += ":443" + } + sni, _, _ := net.SplitHostPort(host) + conn, err = tls.DialWithDialer(d, "tcp", host, &tls.Config{ + ServerName: sni, + NextProtos: []string{"http/1.1"}, + }) + } else { + if !strings.Contains(host, ":") { + host += ":80" + } + conn, err = d.DialContext(ctx, "tcp", host) + } + if err != nil { + return nil, fmt.Errorf("dial %s: %w", host, err) + } + + req := "POST " + path + " HTTP/1.1\r\n" + + "Host: " + u.Host + "\r\n" + + "X-Api-Key: " + token + "\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: tcp-tunnel\r\n" + + "Content-Length: 0\r\n\r\n" + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, err + } + + br := bufio.NewReader(conn) + status, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read upgrade response: %w", err) + } + if !strings.Contains(status, " 101 ") { + // Read up to a few KB so we can show the server's error message. + body, _ := io.ReadAll(io.LimitReader(br, 4096)) + conn.Close() + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = strings.TrimSpace(status) + } + return nil, fmt.Errorf("could not open shell: %s", msg) + } + for { + line, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read upgrade headers: %w", err) + } + if line == "\r\n" || line == "\n" { + break + } + } + return &bufferedConn{Conn: conn, r: br}, nil +} + +// authorizedKeysPath gives the user's authorized_keys location. +// Special-cases root because /root != /home/root. +func authorizedKeysPath(user string) string { + if user == "" || user == "root" { + return "/root/.ssh/authorized_keys" + } + return "/home/" + user + "/.ssh/authorized_keys" +} + +// ── Local TCP listener that bridges through the control plane ──── +// +// fcctl calls this OpenTunnelsHTTP; same idea here, slimmed for our +// single use case (one local listener, one remote port). + +type tunnelBridge struct { + localAddr string // 127.0.0.1: + listener net.Listener + stop context.CancelFunc +} + +func (b *tunnelBridge) close() { + if b == nil { + return + } + if b.stop != nil { + b.stop() + } + if b.listener != nil { + _ = b.listener.Close() + } +} + +func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, remotePort int) (*tunnelBridge, error) { + ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) + if ctrlURL == "" { + ctrlURL = api.DefaultSandboxBaseURL + } + token, err := loadAPIToken() + if err != nil { + return nil, err + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("local listen: %w", err) + } + ctx, cancel := context.WithCancel(parent) + b := &tunnelBridge{ + localAddr: l.Addr().String(), + listener: l, + stop: cancel, + } + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + go bridgeOne(ctx, ctrlURL, token, sandboxID, remotePort, conn) + } + }() + return b, nil +} + +// loadAPIToken pulls the user's token the same way the root Before +// hook does — OAuth session if present, else the static api-key file. +// We re-read because the Resty client doesn't expose the raw value. +func loadAPIToken() (string, error) { + if config.HasOAuthSession() { + sess, err := config.LoadOAuthSession() + if err == nil && sess != nil && sess.AccessToken != "" { + return sess.AccessToken, nil + } + } + return config.LoadToken() +} + +// bridgeOne handles a single accepted local connection: it opens an +// HTTP Upgrade to the sandbox-tunnel endpoint and splices bytes both +// ways until both directions have closed. We must wait for BOTH — +// not just one — because SSH negotiation interleaves writes from both +// peers, and closing early on a transient half-drain truncates the +// handshake mid-flight and looks like an auth failure. +func bridgeOne(ctx context.Context, ctrlURL, token, id string, port int, local net.Conn) { + defer local.Close() + remote, err := dialControlTunnel(ctx, ctrlURL, token, id, port) + if err != nil { + return + } + defer remote.Close() + // closeWrite when one direction reaches EOF so the peer sees a + // proper half-close instead of either side hanging on the read. + // We still wait for the other goroutine before returning. + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, _ = io.Copy(remote, local) + if cw, ok := remote.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + }() + go func() { + defer wg.Done() + _, _ = io.Copy(local, remote) + if cw, ok := local.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + }() + doneCh := make(chan struct{}) + go func() { wg.Wait(); close(doneCh) }() + select { + case <-doneCh: + case <-ctx.Done(): + } +} + +// dialControlTunnel speaks the control plane's HTTP/1.1 Upgrade +// protocol by hand: POST `/v1/sandboxes/:id/tunnel/:port` with the +// Upgrade headers, watch for 101 Switching Protocols, then return a +// net.Conn that carries only tunnel bytes from then on. +func dialControlTunnel(ctx context.Context, ctrlURL, token, id string, port int) (net.Conn, error) { + u, err := url.Parse(ctrlURL) + if err != nil { + return nil, fmt.Errorf("bad sandbox URL %q: %w", ctrlURL, err) + } + host := u.Host + var conn net.Conn + d := &net.Dialer{Timeout: 10 * time.Second} + if u.Scheme == "https" { + if !strings.Contains(host, ":") { + host += ":443" + } + sni, _, _ := net.SplitHostPort(host) + // Force HTTP/1.1 via ALPN. HTTP/2 doesn't expose the + // hop-by-hop `Upgrade` header we rely on; if the server picks + // h2 the tunnel handshake silently falls apart. + conn, err = tls.DialWithDialer(d, "tcp", host, &tls.Config{ + ServerName: sni, + NextProtos: []string{"http/1.1"}, + }) + } else { + if !strings.Contains(host, ":") { + host += ":80" + } + conn, err = d.DialContext(ctx, "tcp", host) + } + if err != nil { + return nil, fmt.Errorf("dial %s: %w", host, err) + } + + req := fmt.Sprintf("POST /v1/sandboxes/%s/tunnel/%d HTTP/1.1\r\n"+ + "Host: %s\r\nX-Api-Key: %s\r\n"+ + "Connection: Upgrade\r\nUpgrade: tcp-tunnel\r\nContent-Length: 0\r\n\r\n", + id, port, u.Host, token) + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, err + } + + br := bufio.NewReader(conn) + status, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read tunnel response: %w", err) + } + if !strings.Contains(status, " 101 ") { + conn.Close() + return nil, fmt.Errorf("server rejected the tunnel: %s", strings.TrimSpace(status)) + } + for { + line, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read tunnel headers: %w", err) + } + if line == "\r\n" || line == "\n" { + break + } + } + return &bufferedConn{Conn: conn, r: br}, nil +} + +// bufferedConn lets us hand back a conn whose first bytes were buffered +// by the bufio.Reader during the HTTP Upgrade handshake. Without this, +// any tunnel bytes pulled into the buffer get lost. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) } + +// ── tiny helpers ──────────────────────────────────────────────────── + +func waitForTCP(addr string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + if err == nil { + _ = c.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("%s did not start listening within %s", addr, timeout) +} + +// bytesReader avoids dragging bytes.NewReader into every call site. +func bytesReader(b []byte) io.Reader { + return &byteSliceReader{data: b} +} + +type byteSliceReader struct { + data []byte + pos int +} + +func (r *byteSliceReader) Read(p []byte) (int, error) { + if r.pos >= len(r.data) { + return 0, io.EOF + } + n := copy(p, r.data[r.pos:]) + r.pos += n + return n, nil +} + +// resolveIdentity returns (private-key path, public-key path). If the +// user passed --identity we use that and look for a `.pub` next to it. +// Otherwise we try the canonical files in `~/.ssh/` in order. +func resolveIdentity(explicit string) (priv, pub string, err error) { + if explicit != "" { + priv = expandHome(explicit) + pub = priv + ".pub" + if _, e := os.Stat(priv); e != nil { + return "", "", fmt.Errorf("could not find SSH private key %s", priv) + } + if _, e := os.Stat(pub); e != nil { + return "", "", fmt.Errorf("could not find public-key file %s (expected next to the private key)", pub) + } + return priv, pub, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("could not resolve $HOME: %w", err) + } + for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} { + p := filepath.Join(home, ".ssh", name) + pp := p + ".pub" + if _, e := os.Stat(p); e == nil { + if _, e := os.Stat(pp); e == nil { + return p, pp, nil + } + } + } + return "", "", fmt.Errorf("no SSH key found in ~/.ssh/ — generate one with `ssh-keygen -t ed25519`, or pass --identity ") +} + +// expandHome turns a leading "~" into the user's home directory. +func expandHome(p string) string { + if strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, p[2:]) + } + } + return p +} diff --git a/cmd/sandbox/slider.go b/cmd/sandbox/slider.go new file mode 100644 index 0000000..cf0f278 --- /dev/null +++ b/cmd/sandbox/slider.go @@ -0,0 +1,150 @@ +package sandbox + +import ( + "fmt" + "strings" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + "github.com/pterm/pterm" +) + +// pickRechargeAmountGB renders an in-terminal slider keyed by ← / → +// (1 GB steps) and ⇧← / ⇧→ (10 GB steps). Returns the picked size in +// gigabytes, or 0 on cancel (Esc / Ctrl+C / q). +// +// The slider is bounded — min 1 GB, max 100 GB — to keep accidental +// hold-down-arrow from blowing through user wallets. Operators who +// want bigger top-ups can still pass the amount as a positional +// (e.g. `bandwidth-recharge my-box 500GB`). +func pickRechargeAmountGB(initialGB int) (int, error) { + const ( + minGB = 1 + maxGB = 100 + ) + value := initialGB + if value < minGB { + value = minGB + } + if value > maxGB { + value = maxGB + } + + pterm.Println() + pterm.Println(pterm.Gray(" ← / → 1 GB ↑ / ↓ 10 GB enter to confirm esc to cancel")) + + // Reserve two lines that we redraw in place. The cursor goes back + // up two before each redraw so the slider feels alive instead of + // scrolling the terminal on every keystroke. + fmt.Println() + fmt.Println() + cancelled := false + committed := false + + render := func() { + // Move up 2 lines, erase, redraw. + fmt.Print("\033[2A\033[J") + bar := renderSliderBar(value, minGB, maxGB, 40) + pterm.Printfln(" %s %d GB", bar, value) + pterm.Println(pterm.Gray(" (use the arrow keys; enter confirms)")) + } + render() + + err := keyboard.Listen(func(key keys.Key) (stop bool, err error) { + switch key.Code { + case keys.RuneKey: + switch strings.ToLower(string(key.Runes)) { + case "q": + cancelled = true + return true, nil + case "+": + if value < maxGB { + value++ + } + case "-": + if value > minGB { + value-- + } + default: + return false, nil + } + case keys.Right: + step := 1 + if key.AltPressed { + step = 10 + } + value += step + if value > maxGB { + value = maxGB + } + case keys.Left: + step := 1 + if key.AltPressed { + step = 10 + } + value -= step + if value < minGB { + value = minGB + } + case keys.Up: + value += 10 + if value > maxGB { + value = maxGB + } + case keys.Down: + value -= 10 + if value < minGB { + value = minGB + } + case keys.Enter: + committed = true + return true, nil + case keys.Esc, keys.CtrlC: + cancelled = true + return true, nil + default: + return false, nil + } + render() + return false, nil + }) + if err != nil { + return 0, fmt.Errorf("could not read your input: %w", err) + } + if cancelled || !committed { + return 0, nil + } + return value, nil +} + +// renderSliderBar draws a width-character bar with a marker at the +// current value's position. +func renderSliderBar(value, min, max, width int) string { + if width < 3 { + width = 3 + } + if value < min { + value = min + } + if value > max { + value = max + } + pos := 0 + if max > min { + pos = ((value - min) * (width - 1)) / (max - min) + } + var b strings.Builder + b.WriteString("[") + for i := 0; i < width; i++ { + switch { + case i == pos: + b.WriteString("●") + case i < pos: + b.WriteString("─") + default: + b.WriteString("·") + } + } + b.WriteString("]") + return b.String() +} diff --git a/cmd/sandbox/sync.go b/cmd/sandbox/sync.go new file mode 100644 index 0000000..e886ac0 --- /dev/null +++ b/cmd/sandbox/sync.go @@ -0,0 +1,512 @@ +package sandbox + +import ( + "context" + "encoding/pem" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + "golang.org/x/crypto/ssh" + "golang.org/x/term" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newSyncCommand wires up `createos sandbox sync` — a foreground +// bidirectional file sync between your laptop and a sandbox. Built on +// the same SSH path as `sandbox shell --ssh`: install the user's +// public key, start sshd, tunnel through the control plane to VM:22, +// then drive a Mutagen (https://mutagen.io) session over that. +// +// Lifecycle is tied to this process: Ctrl+C / EOF / sandbox crash +// all terminate the session. No daemon, no global state to clean up. +func newSyncCommand() *cli.Command { + return &cli.Command{ + Name: "sync", + Usage: "Two-way file sync between your laptop and a sandbox (foreground; Ctrl+C to stop)", + ArgsUsage: "[]", + Description: `Mirrors a local directory with one inside a running sandbox. Changes +on either side propagate to the other. + +The first run downloads Mutagen — the sync engine we shell out to — +into the createos-cli cache, verified against a pinned sha256 hash. + +Examples: + createos sandbox sync my-box --local ~/work/project --remote /root/work + createos sandbox sync my-box -i ~/.ssh/id_ed25519 + +Safety: refuses to sync from $HOME directly, /, or known sensitive +paths (.ssh, .aws, etc.). Refuses to sync TO system dirs inside the +sandbox (/etc, /usr, /bin …). Pass --force to bypass the local check +(the remote check stays enforced).`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "local", + Usage: "Local directory to sync (asks interactively if omitted)", + }, + &cli.StringFlag{ + Name: "remote", + Usage: "Remote directory inside the sandbox (asks interactively if omitted; absolute path)", + }, + &cli.StringFlag{ + Name: "identity", + Aliases: []string{"i"}, + Usage: "Path to your SSH private key (default: ~/.ssh/id_ed25519, then id_rsa, then id_ecdsa)", + }, + &cli.StringFlag{ + Name: "user", + Aliases: []string{"u"}, + Value: "root", + Usage: "Username inside the sandbox", + }, + &cli.DurationFlag{ + Name: "sshd-wait", + Value: 5 * time.Second, + Usage: "How long to wait for sshd to bind :22 inside the sandbox", + }, + &cli.BoolFlag{ + Name: "force", + Usage: "Bypass the local sensitive-path check (still requires non-/ paths)", + }, + }, + Action: runSync, + } +} + +func runSync(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") + } + tty := terminal.IsInteractive() + + // 1. Pick / resolve the sandbox. + ref := strings.TrimSpace(c.Args().First()) + var id string + if ref == "" { + if !tty { + return fmt.Errorf("please provide a sandbox ID or name\n\n Example:\n createos sandbox sync my-box --local ~/work --remote /root/work") + } + pickedID, label, err := pickByStatus(c, client, "Sync with which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing synced.") + return nil + } + id, ref = pickedID, label + } else { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + } + + // 2. Local + remote paths. Prompt on TTY when missing; default the + // local side to the user's current directory (the common case — + // "sync this project I'm sitting in"). + localArg := strings.TrimSpace(c.String("local")) + if localArg == "" { + if !tty { + return errors.New("--local is required (no terminal for interactive prompt)") + } + cwd, _ := os.Getwd() + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Local directory to sync (enter for current directory)"). + WithDefaultValue(cwd). + Show() + if err != nil { + return fmt.Errorf("could not read local path: %w", err) + } + localArg = strings.TrimSpace(v) + if localArg == "" { + localArg = cwd + } + } + remote := strings.TrimSpace(c.String("remote")) + if remote == "" { + if !tty { + return errors.New("--remote is required (no terminal for interactive prompt)") + } + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Remote directory inside the sandbox (e.g. /root/work)"). + WithDefaultValue("/root/sync"). + Show() + if err != nil { + return fmt.Errorf("could not read remote path: %w", err) + } + remote = strings.TrimSpace(v) + } + + local, err := validateLocalSyncPath(localArg, c.Bool("force")) + if err != nil { + return err + } + if err := validateRemoteSyncPath(remote); err != nil { + return err + } + + mutagenBin, err := ensureMutagen() + if err != nil { + return err + } + + // 3. Resolve the SSH identity. Same auto-detect as `shell --ssh`. + privPath, pubPath, err := resolveIdentity(c.String("identity")) + if err != nil { + return err + } + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + return fmt.Errorf("could not read public key %s: %w", pubPath, err) + } + + // 3b. Mutagen forks ssh from its background daemon and proxies any + // passphrase prompt through an RPC channel that doesn't survive + // this command — so a passphrase-protected key fails the + // handshake silently. Decrypt up front and point the ssh + // wrapper at the cleartext copy in a tempfile (mode 0600, + // deleted on exit). + unlocked, cleanup, err := unlockSSHKeyIfNeeded(privPath) + if err != nil { + return err + } + defer cleanup() + privPath = unlocked + + user := strings.TrimSpace(c.String("user")) + if user == "" { + user = "root" + } + + // 4. Install authorized_keys + start sshd. Mirror of the SSH-shell + // path so sync gets the same modes/sshd setup. + authPath := authorizedKeysPath(user) + if err := client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { + return fmt.Errorf("could not install your SSH key: %w", err) + } + 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 +chmod 700 %[1]s +chmod 600 %[1]s/authorized_keys +chown -R %[2]s:%[2]s %[1]s 2>/dev/null || true +# Also create the remote target so mutagen has a place to land. +mkdir -p %[3]s +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, shellQuote(remote)) + if pre, err := client.ExecSandbox(c.Context, id, api.SandboxExecReq{ + Cmd: "sh", + Args: []string{"-c", prepScript}, + }); err != nil { + return fmt.Errorf("could not prepare sshd: %w", err) + } else if pre.Result.ExitCode == 100 { + return 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 { + return fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(pre.Result.Stderr)) + } + + // 5. Tunnel through control to VM:22 — same bridge as `shell --ssh`. + ctx, cancel := context.WithCancel(c.Context) + defer cancel() + bridge, err := startTunnelBridge(ctx, c, id, 22) + if err != nil { + return fmt.Errorf("could not open tunnel to the sandbox: %w", err) + } + defer bridge.close() + if err := waitForTCP(bridge.localAddr, c.Duration("sshd-wait")); err != nil { + return fmt.Errorf("sshd did not start in time: %w", err) + } + _, port, _ := net.SplitHostPort(bridge.localAddr) + + // 6. Create the mutagen session. + // Mutagen's URL parser dislikes `ssh://user@host:port/path` — + // `user@host:port:path` (triple-colon) works reliably. + sessionName := fmt.Sprintf("createos-%s-%d", strings.ReplaceAll(id, "_", "-"), time.Now().Unix()) + remoteSpec := fmt.Sprintf("%s@127.0.0.1:%s:%s", user, port, remote) + + wrapperDir, wrapperEnv, err := makeSSHWrapper(privPath) + if err != nil { + return fmt.Errorf("could not set up ssh wrapper: %w", err) + } + defer os.RemoveAll(wrapperDir) + + // Mutagen runs ssh from its long-lived daemon, not from this + // process. Stop the daemon so the next `create` auto-starts it + // under our env, picking up the wrapper PATH. + _ = runMutagen(ctx, mutagenBin, wrapperEnv, "daemon", "stop") + + pterm.Println(pterm.Gray(fmt.Sprintf(" syncing %s ⇄ %s:%s", local, refLabel(ref, id), remote))) + createArgs := []string{ + "sync", "create", + "--name=" + sessionName, + "--ignore-vcs", + local, + remoteSpec, + } + if err := runMutagen(ctx, mutagenBin, wrapperEnv, createArgs...); err != nil { + return fmt.Errorf("mutagen sync create failed: %w", err) + } + // Best-effort cleanup on exit. We can't always rely on context + // cancellation propagating before we exit. + defer func() { + bg := context.Background() + _ = runMutagen(bg, mutagenBin, wrapperEnv, "sync", "terminate", sessionName) + }() + + pterm.Success.Println("Sync running. Press Ctrl+C to stop.") + + // 7. Monitor the session in the foreground. `mutagen sync monitor` + // streams status lines until the session is terminated or the + // process exits. + mon := exec.CommandContext(ctx, mutagenBin, "sync", "monitor", sessionName) + mon.Env = wrapperEnv + mon.Stdout = os.Stdout + mon.Stderr = os.Stderr + if err := mon.Run(); err != nil && ctx.Err() == nil { + return fmt.Errorf("mutagen monitor exited: %w", err) + } + pterm.Println("Sync stopped.") + return nil +} + +// runMutagen runs `mutagen ` with our shadowed PATH env. +// stdout/stderr are forwarded so the user sees mutagen's progress. +func runMutagen(ctx context.Context, bin string, env []string, args ...string) error { + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = env + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// makeSSHWrapper creates a tempdir with `ssh` AND `scp` shims that +// forward to the real binaries while injecting `-i ` and the +// right host-key flags. Returns (dir, env) where env's PATH has dir +// prepended. Caller is responsible for `os.RemoveAll(dir)`. +// +// Why this exists: mutagen 0.18 has no per-session SSH flag passthrough. +// It invokes whichever `ssh` is on PATH for the control channel AND +// forks `scp` directly to push its agent binary. Both need the same +// key + host-key policy, so we shadow both. +func makeSSHWrapper(privPath string) (string, []string, error) { + realSSH, err := exec.LookPath("ssh") + if err != nil { + return "", nil, fmt.Errorf("system ssh not found: %w", err) + } + realSCP, err := exec.LookPath("scp") + if err != nil { + return "", nil, fmt.Errorf("system scp not found: %w", err) + } + dir, err := os.MkdirTemp("", "createos-ssh-wrapper-*") + if err != nil { + return "", nil, err + } + knownHosts := filepath.Join(dir, "known_hosts") + commonOpts := fmt.Sprintf( + "-i %s -o IdentitiesOnly=yes -o IdentityAgent=none "+ + "-o StrictHostKeyChecking=accept-new "+ + "-o UserKnownHostsFile=%s -o LogLevel=ERROR", + shellQuote(privPath), shellQuote(knownHosts)) + + if err := os.WriteFile( + filepath.Join(dir, "ssh"), + []byte(fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", realSSH, commonOpts)), + 0o755, + ); err != nil { + _ = os.RemoveAll(dir) + return "", nil, err + } + if err := os.WriteFile( + filepath.Join(dir, "scp"), + []byte(fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", realSCP, commonOpts)), + 0o755, + ); err != nil { + _ = os.RemoveAll(dir) + return "", nil, err + } + + // Prepend the wrapper dir to PATH for the spawned mutagen process. + env := os.Environ() + for i, e := range env { + if strings.HasPrefix(e, "PATH=") { + env[i] = "PATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(e, "PATH=") + return dir, env, nil + } + } + env = append(env, "PATH="+dir) + return dir, env, nil +} + +// shellQuote single-quotes a string for safe inclusion in /bin/sh. +// Embedded single quotes are escaped via the standard close-escape-open +// dance: 'foo'\''bar' decodes to foo'bar. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// ── path validators ─────────────────────────────────────────────── + +// sensitiveLocalDirs is the set of directory NAMES we refuse to sync +// from. Anything whose first path component AFTER $HOME is in this set +// (or anything BENEATH it) is rejected unless --force is set. +var sensitiveLocalDirs = map[string]struct{}{ + ".ssh": {}, ".gnupg": {}, ".aws": {}, ".config": {}, ".docker": {}, + ".kube": {}, ".gcloud": {}, ".azure": {}, +} + +// validateLocalSyncPath verifies that `p` is a real directory under +// $HOME or /tmp, isn't $HOME itself, and isn't (or is under) a known +// sensitive directory. --force bypasses the sensitive check. +func validateLocalSyncPath(p string, force bool) (string, error) { + if p == "" { + return "", errors.New("local path is required") + } + // Expand ~ to $HOME. + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + p = filepath.Join(home, strings.TrimPrefix(p, "~/")) + } else if p == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + p = home + } + abs, err := filepath.Abs(p) + if err != nil { + return "", fmt.Errorf("resolve %q: %w", p, err) + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("could not stat %s: %w", abs, err) + } + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", abs) + } + if abs == "/" { + return "", fmt.Errorf("refusing to sync from %q — pick a specific subdirectory", abs) + } + home, _ := os.UserHomeDir() + if abs == home { + return "", fmt.Errorf("refusing to sync from $HOME itself — pick a subdirectory like ~/work") + } + allowed := false + if home != "" && strings.HasPrefix(abs+"/", home+"/") { + allowed = true + if !force { + // First component under $HOME — refuse known-sensitive ones. + rel := strings.TrimPrefix(strings.TrimPrefix(abs, home), "/") + first := strings.SplitN(rel, "/", 2)[0] + if _, bad := sensitiveLocalDirs[first]; bad { + return "", fmt.Errorf("refusing to sync from %s (sensitive directory). Pass --force if you really mean it.", abs) + } + } + } + if strings.HasPrefix(abs+"/", "/tmp/") { + allowed = true + } + if !allowed { + return "", fmt.Errorf("local path must be under $HOME or /tmp (got %s)", abs) + } + return abs, nil +} + +// reservedRemoteDirs are remote first-path-components we refuse to +// sync TO. System dirs that mutagen would happily overwrite. +var reservedRemoteDirs = map[string]struct{}{ + "": {}, "/": {}, "etc": {}, "usr": {}, "bin": {}, "sbin": {}, + "lib": {}, "lib64": {}, "boot": {}, "proc": {}, "sys": {}, "dev": {}, + "run": {}, +} + +// validateRemoteSyncPath checks the sandbox-side target. Must be +// absolute and not a system directory. +func validateRemoteSyncPath(p string) error { + p = strings.TrimSpace(p) + if p == "" { + return errors.New("remote path is required") + } + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("remote path must be absolute (got %q)", p) + } + if p == "/" { + return fmt.Errorf("refusing to sync to %q", p) + } + clean := strings.TrimPrefix(filepath.Clean(p), "/") + first := strings.SplitN(clean, "/", 2)[0] + if _, bad := reservedRemoteDirs[first]; bad { + return fmt.Errorf("refusing to sync to %s (system path)", p) + } + return nil +} + +// unlockSSHKeyIfNeeded reads the private key at path. If it's a +// passphrase-protected OpenSSH key, prompts the user once for the +// passphrase, decrypts it, and writes the cleartext form to a fresh +// 0600 file in a tempdir. Returns the path to use for ssh and a +// cleanup function that removes the tempdir. If the key is already +// unencrypted, returns the original path and a no-op cleanup. +// +// The decrypted file is only as safe as the user's tmp dir — but it +// never touches a shared location and is removed on `defer cleanup()` +// before this command returns. +func unlockSSHKeyIfNeeded(path string) (string, func(), error) { + noop := func() {} + raw, err := os.ReadFile(path) + if err != nil { + return "", noop, fmt.Errorf("could not read SSH key %s: %w", path, err) + } + if _, perr := ssh.ParseRawPrivateKey(raw); perr == nil { + // Already unencrypted, use as-is. + return path, noop, nil + } + if !terminal.IsInteractive() { + return "", noop, fmt.Errorf("the SSH key %s is passphrase-protected — pass --identity or run from a terminal that can prompt for the passphrase", path) + } + fmt.Printf("Enter passphrase for %s: ", path) + pw, perr := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if perr != nil { + return "", noop, fmt.Errorf("could not read passphrase: %w", perr) + } + key, perr := ssh.ParseRawPrivateKeyWithPassphrase(raw, pw) + if perr != nil { + return "", noop, fmt.Errorf("could not decrypt SSH key %s — wrong passphrase?", path) + } + block, perr := ssh.MarshalPrivateKey(key, "") + if perr != nil { + return "", noop, fmt.Errorf("could not re-encode SSH key: %w", perr) + } + + dir, derr := os.MkdirTemp("", "createos-key-*") + if derr != nil { + return "", noop, derr + } + out := filepath.Join(dir, "id_unlocked") + if werr := os.WriteFile(out, pem.EncodeToMemory(block), 0o600); werr != nil { + _ = os.RemoveAll(dir) + return "", noop, fmt.Errorf("could not write unlocked key: %w", werr) + } + return out, func() { _ = os.RemoveAll(dir) }, nil +} diff --git a/cmd/sandbox/template.go b/cmd/sandbox/template.go new file mode 100644 index 0000000..90e0315 --- /dev/null +++ b/cmd/sandbox/template.go @@ -0,0 +1,483 @@ +package sandbox + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "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" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newTemplateCommand wires up `createos sandbox template`. +// +// Templates are user-built sandbox images: submit a Dockerfile, get a +// rootfs the sandbox API can spawn from. The build runs async in our +// build cluster; this command group submits, lists, follows logs, and +// removes them. +func newTemplateCommand() *cli.Command { + return &cli.Command{ + Name: "template", + Aliases: []string{"templates", "tpl"}, + Usage: "Build your own sandbox images from a Dockerfile", + Subcommands: []*cli.Command{ + newTemplateSubmitCommand(), + newTemplateListCommand(), + newTemplateShowCommand(), + newTemplateLogsCommand(), + newTemplateRmCommand(), + }, + } +} + +// ── submit ─────────────────────────────────────────────────────── + +func newTemplateSubmitCommand() *cli.Command { + return &cli.Command{ + Name: "submit", + Aliases: []string{"build", "create"}, + Usage: "Send a Dockerfile to be built into a sandbox image", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Value: "Dockerfile", Usage: "Path to your Dockerfile"}, + &cli.BoolFlag{Name: "no-follow", Usage: "Submit and exit; don't wait for the build"}, + }, + Action: runTemplateSubmit, + } +} + +func runTemplateSubmit(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") + } + name, path, follow := parseTemplateSubmitArgs(c) + if name == "" { + return fmt.Errorf("template name required\n\n Example:\n createos sandbox template submit my-rails -f Dockerfile") + } + body, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if len(body) == 0 { + return fmt.Errorf("Dockerfile %s is empty", path) + } + + view, err := client.CreateTemplate(c.Context, api.TemplateCreateReq{ + Name: name, + Dockerfile: string(body), + }) + if err != nil { + return err + } + pterm.Success.Printfln("Submitted template %s (status: %s)", view.Name, view.Status) + if !follow { + pterm.Println(pterm.Gray(fmt.Sprintf(" Watch progress with: createos sandbox template logs %s --follow", view.Name))) + return nil + } + return streamTemplateLogs(c, client, view.ID) +} + +// parseTemplateSubmitArgs is forgiving about flag placement — +// urfave/cli v2 stops flag parsing at the first positional, so +// `submit my-rails -f Dockerfile` would silently keep -f at its +// default. Rescan c.Args() to recover late-positioned flags. +func parseTemplateSubmitArgs(c *cli.Context) (name, path string, follow bool) { + path = c.String("file") + follow = !c.Bool("no-follow") + args := c.Args().Slice() + var positionals []string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-f" || a == "--file": + if i+1 < len(args) { + path = args[i+1] + i++ + } + case strings.HasPrefix(a, "-f=") || strings.HasPrefix(a, "--file="): + path = a[strings.Index(a, "=")+1:] + case a == "--no-follow": + follow = false + default: + positionals = append(positionals, a) + } + } + if len(positionals) > 0 { + name = positionals[0] + } + return +} + +// ── list ───────────────────────────────────────────────────────── + +func newTemplateListCommand() *cli.Command { + return &cli.Command{ + Name: "ls", + Aliases: []string{"list"}, + Usage: "List your templates", + Action: runTemplateList, + } +} + +func runTemplateList(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") + } + tpls, err := client.ListTemplates(c.Context) + if err != nil { + return err + } + sort.SliceStable(tpls, func(i, j int) bool { + return tpls[i].CreatedAt.After(tpls[j].CreatedAt) + }) + output.Render(c, tpls, func() { + if len(tpls) == 0 { + fmt.Println("You don't have any templates yet.") + pterm.Println(pterm.Gray(" Build one with: createos sandbox template submit -f Dockerfile")) + return + } + table := pterm.TableData{{"Name", "ID", "Status", "Size", "Created"}} + for _, t := range tpls { + table = append(table, []string{ + t.Name, t.ID, t.Status, + humanBytes(t.Ext4SizeBytes), + t.CreatedAt.Format("2006-01-02 15:04"), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + pterm.Println() + pterm.Println(pterm.Gray(" Spawn from a ready template: createos sandbox create --rootfs ")) + }) + return nil +} + +// ── show ───────────────────────────────────────────────────────── + +func newTemplateShowCommand() *cli.Command { + return &cli.Command{ + Name: "show", + Aliases: []string{"get"}, + Usage: "Show one template's details", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "dockerfile", Usage: "Also print the submitted Dockerfile"}, + }, + Action: runTemplateShow, + } +} + +func runTemplateShow(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") + } + ref, err := resolveTemplateRefArg(c, client, "Show which template?") + if err != nil || ref == "" { + return err + } + withDockerfile := c.Bool("dockerfile") + t, err := client.GetTemplate(c.Context, ref, withDockerfile) + if err != nil { + return err + } + output.Render(c, t, func() { + label := pterm.NewStyle(pterm.FgCyan) + row := func(k, v string) { + if v == "" { + return + } + label.Printf(" %-12s ", k+":") + fmt.Println(v) + } + pterm.Println() + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s (%s)", t.Name, t.ID) + pterm.Println() + row("Status", t.Status) + row("Base", t.Base) + if t.Ext4SizeBytes > 0 { + row("Size", humanBytes(t.Ext4SizeBytes)) + } + row("Created", t.CreatedAt.Format("2006-01-02 15:04:05")) + if t.BuiltAt != nil { + row("Built", t.BuiltAt.Format("2006-01-02 15:04:05")) + } + if t.Status == "failed" { + pterm.Println() + pterm.Println(pterm.Gray(fmt.Sprintf(" Build failed. See logs: createos sandbox template logs %s", t.Name))) + } else if t.Status == "ready" { + pterm.Println() + pterm.Println(pterm.Gray(fmt.Sprintf(" Spawn from it: createos sandbox create --rootfs %s", t.Name))) + } + if withDockerfile && t.Dockerfile != "" { + pterm.Println() + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Println(" Dockerfile:") + fmt.Println(indent(t.Dockerfile, " ")) + } + }) + return nil +} + +// ── logs ───────────────────────────────────────────────────────── + +func newTemplateLogsCommand() *cli.Command { + return &cli.Command{ + Name: "logs", + Usage: "Show the build output for a template", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "follow", Aliases: []string{"f"}, Usage: "Keep showing output until the build finishes"}, + }, + Action: runTemplateLogs, + } +} + +func runTemplateLogs(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") + } + ref, err := resolveTemplateRefArg(c, client, "Show logs for which template?") + if err != nil || ref == "" { + return err + } + if c.Bool("follow") { + return streamTemplateLogs(c, client, ref) + } + resp, err := client.StreamTemplateLogs(c.Context, ref, false) + if err != nil { + return err + } + defer resp.RawBody().Close() + _, _ = io.Copy(os.Stdout, resp.RawBody()) + return nil +} + +// streamTemplateLogs follows the NDJSON log stream until {"final":true}. +// A spinner covers the pending → claimed → pod-scheduled silence; it +// drops as soon as the first real line lands. +func streamTemplateLogs(c *cli.Context, client *api.SandboxClient, ref string) error { + resp, err := client.StreamTemplateLogs(c.Context, ref, true) + if err != nil { + return err + } + defer resp.RawBody().Close() + + var spinner *pterm.SpinnerPrinter + if terminal.IsInteractive() { + spinner, _ = pterm.DefaultSpinner.Start("template build queued…") + } + stopSpinner := func() { + if spinner != nil { + _ = spinner.Stop() + spinner = nil + } + } + defer stopSpinner() + + scanner := bufio.NewScanner(resp.RawBody()) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + raw := scanner.Bytes() + if len(raw) == 0 { + continue + } + var ev api.TemplateLogEvent + if err := json.Unmarshal(raw, &ev); err != nil { + continue + } + if ev.Final { + stopSpinner() + fmt.Println() + switch ev.Status { + case "ready": + pterm.Success.Println("Build succeeded.") + case "failed": + pterm.Error.Println("Build failed.") + os.Exit(1) + default: + pterm.Println(pterm.Gray("(stream ended)")) + } + return nil + } + if ev.Line != "" { + stopSpinner() + os.Stdout.WriteString(ev.Line) + os.Stdout.WriteString("\n") + } + } + return scanner.Err() +} + +// ── rm ─────────────────────────────────────────────────────────── + +func newTemplateRmCommand() *cli.Command { + return &cli.Command{ + Name: "rm", + Aliases: []string{"delete"}, + Usage: "Delete one or more templates", + ArgsUsage: "[ …]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "yes", Aliases: []string{"y", "force"}, Usage: "Skip the confirmation prompt"}, + }, + Action: runTemplateRm, + } +} + +func runTemplateRm(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") + } + refs, forceFromArgs := splitForceFlag(c.Args().Slice()) + if len(refs) == 0 { + if !terminal.IsInteractive() { + return fmt.Errorf("please provide at least one template name or ID") + } + picked, err := pickTemplatesForDelete(c, client) + if err != nil { + return err + } + if len(picked) == 0 { + fmt.Println("Cancelled.") + return nil + } + refs = picked + } + force := c.Bool("yes") || forceFromArgs + if !terminal.IsInteractive() && !force { + return fmt.Errorf("non-interactive: pass --yes to confirm deletion") + } + if terminal.IsInteractive() && !force { + prompt := fmt.Sprintf("Permanently delete template %q?", refs[0]) + if len(refs) > 1 { + prompt = fmt.Sprintf("Permanently delete %d templates?", len(refs)) + } + pterm.Println(pterm.Gray(" (Paused sandboxes that were built from these can still be resumed.)")) + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText(prompt). + WithDefaultValue(false). + Show() + if err != nil { + return fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + fmt.Println("Cancelled.") + return nil + } + } + failed := 0 + for _, ref := range refs { + if err := client.DeleteTemplate(c.Context, ref); err != nil { + pterm.Error.Printfln("%s: %v", ref, err) + failed++ + continue + } + pterm.Success.Printfln("Deleted template %s", ref) + } + if failed > 0 { + return fmt.Errorf("%d of %d deletes failed", failed, len(refs)) + } + return nil +} + +// ── helpers ────────────────────────────────────────────────────── + +// resolveTemplateRefArg returns the first positional arg or — on a +// TTY when none is given — pops a single-select picker. Returns +// ref="" and nil error when the user cancels the picker. +func resolveTemplateRefArg(c *cli.Context, client *api.SandboxClient, prompt string) (string, error) { + ref := strings.TrimSpace(c.Args().First()) + if ref != "" { + return ref, nil + } + if !terminal.IsInteractive() { + return "", fmt.Errorf("please provide a template name or ID\n\n To see your templates, run:\n createos sandbox template ls") + } + tpls, err := client.ListTemplates(c.Context) + if err != nil { + return "", err + } + if len(tpls) == 0 { + fmt.Println("You don't have any templates yet.") + pterm.Println(pterm.Gray(" Build one with: createos sandbox template submit -f Dockerfile")) + return "", nil + } + sort.SliceStable(tpls, func(i, j int) bool { + return tpls[i].CreatedAt.After(tpls[j].CreatedAt) + }) + options := make([]string, 0, len(tpls)) + byOpt := make(map[string]string, len(tpls)) + for _, t := range tpls { + opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Format("2006-01-02 15:04")) + options = append(options, opt) + byOpt[opt] = t.Name + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(prompt). + Show() + if err != nil { + return "", fmt.Errorf("could not read your selection: %w", err) + } + return byOpt[picked], nil +} + +func pickTemplatesForDelete(c *cli.Context, client *api.SandboxClient) ([]string, error) { + tpls, err := client.ListTemplates(c.Context) + if err != nil { + return nil, err + } + if len(tpls) == 0 { + fmt.Println("You don't have any templates to delete.") + return nil, nil + } + sort.SliceStable(tpls, func(i, j int) bool { + return tpls[i].CreatedAt.After(tpls[j].CreatedAt) + }) + options := make([]string, 0, len(tpls)) + byOpt := make(map[string]string, len(tpls)) + for _, t := range tpls { + opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Format("2006-01-02 15:04")) + options = append(options, opt) + byOpt[opt] = t.Name + } + picked, err := multiselect("Pick templates to delete (space = select, enter = confirm)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + out := make([]string, 0, len(picked)) + for _, p := range picked { + if name, ok := byOpt[p]; ok { + out = append(out, name) + } + } + return out, nil +} + +// indent prefixes every line in s with the given prefix. +func indent(s, prefix string) string { + if s == "" { + return "" + } + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, ln := range lines { + lines[i] = prefix + ln + } + return strings.Join(lines, "\n") +} + +// satisfy time import for go-imports — already used via TemplateView fields above. +var _ = time.Time{} diff --git a/cmd/sandbox/tunnel.go b/cmd/sandbox/tunnel.go new file mode 100644 index 0000000..96ab4e2 --- /dev/null +++ b/cmd/sandbox/tunnel.go @@ -0,0 +1,194 @@ +package sandbox + +import ( + "fmt" + "net" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// newTunnelCommand wires up `createos sandbox tunnel`. +// +// Forwards a local TCP port to a port inside a sandbox via the control +// plane's tunnel endpoint. No SSH keys, no gateway hop — your API token +// is the only auth. +// +// Example: a web server inside the sandbox bound to :8000 becomes +// reachable from your laptop at http://127.0.0.1:8080 with +// +// createos sandbox tunnel my-box --local 8080 --remote 8000 +func newTunnelCommand() *cli.Command { + return &cli.Command{ + Name: "tunnel", + Aliases: []string{"tun"}, + Usage: "Forward a local port into a port inside a sandbox", + ArgsUsage: "[]", + Description: `Forwards a TCP port on your laptop to a port inside the sandbox. +Useful for reaching loopback-only services (e.g. a dev server bound to +127.0.0.1:3000) without an SSH key. + +Examples: + # Reach a Python http.server inside the sandbox at http://localhost:8080 + createos sandbox tunnel my-box --local 8080 --remote 8000 + + # Default --local to the same as --remote + createos sandbox tunnel my-box --remote 5432 + + # Pick a sandbox interactively, then prompt for ports + createos sandbox tunnel + + # Bind to 0.0.0.0 so other machines on the LAN can reach the tunnel + createos sandbox tunnel my-box --remote 80 --bind 0.0.0.0 + +Press Ctrl+C to stop. The tunnel only lives for the lifetime of this +command — no daemon, no global state.`, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "local", + Usage: "Local port to listen on (defaults to --remote)", + }, + &cli.IntFlag{ + Name: "remote", + Usage: "Port inside the sandbox to forward to", + }, + &cli.StringFlag{ + Name: "bind", + Value: "127.0.0.1", + Usage: "Local address to bind to (use 0.0.0.0 to expose on the LAN)", + }, + }, + Action: runTunnel, + } +} + +func runTunnel(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") + } + tty := terminal.IsInteractive() + + // 1. Sandbox. + ref := strings.TrimSpace(c.Args().First()) + var id string + if ref == "" { + if !tty { + return fmt.Errorf("please provide a sandbox ID or name\n\n Example:\n createos sandbox tunnel my-box --local 8080 --remote 8000") + } + pickedID, label, err := pickByStatus(c, client, "Tunnel to which sandbox?", "running") + if err != nil { + return err + } + if pickedID == "" { + fmt.Println("Cancelled. Nothing happened.") + return nil + } + id, ref = pickedID, label + } else { + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + } + + // 2. Ports. Prompt on TTY when missing. + remote := c.Int("remote") + if remote <= 0 { + if !tty { + return fmt.Errorf("--remote is required\n\n Example:\n createos sandbox tunnel %s --local 8080 --remote 8000", ref) + } + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Remote port inside the sandbox"). + Show() + if err != nil { + return fmt.Errorf("could not read remote port: %w", err) + } + p, perr := strconv.Atoi(strings.TrimSpace(v)) + if perr != nil || p <= 0 || p > 65535 { + return fmt.Errorf("remote port must be 1–65535") + } + remote = p + } + local := c.Int("local") + if local <= 0 { + // On TTY ask; non-TTY just mirror the remote port. + if tty { + v, err := pterm.DefaultInteractiveTextInput. + WithDefaultText(fmt.Sprintf("Local port to listen on (enter for %d)", remote)). + WithDefaultValue(strconv.Itoa(remote)). + Show() + if err != nil { + return fmt.Errorf("could not read local port: %w", err) + } + v = strings.TrimSpace(v) + if v == "" { + local = remote + } else { + p, perr := strconv.Atoi(v) + if perr != nil || p <= 0 || p > 65535 { + return fmt.Errorf("local port must be 1–65535") + } + local = p + } + } else { + local = remote + } + } + bind := strings.TrimSpace(c.String("bind")) + if bind == "" { + 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)) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return fmt.Errorf("could not bind %s: %w", listenAddr, err) + } + defer listener.Close() + + ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) + if ctrlURL == "" { + ctrlURL = api.DefaultSandboxBaseURL + } + token, err := loadAPIToken() + if err != nil { + 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. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + go func() { + <-sigCh + _ = listener.Close() + }() + + // 4. Accept loop. Each connection runs in its own goroutine via + // bridgeOne (defined in shell.go) which speaks the same + // HTTP-Upgrade tunnel protocol. + for { + conn, err := listener.Accept() + if err != nil { + // Closed by signal handler or local error → done. + return nil + } + go bridgeOne(c.Context, ctrlURL, token, id, remote, conn) + } +} diff --git a/cmd/sandbox/wait.go b/cmd/sandbox/wait.go new file mode 100644 index 0000000..d314a78 --- /dev/null +++ b/cmd/sandbox/wait.go @@ -0,0 +1,53 @@ +package sandbox + +import ( + "context" + "fmt" + "time" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +// Default polling cadence + timeout. Tuned for snapshot/restore which +// typically completes in seconds for cached bundles, up to ~30 s on a +// cold R2 fetch. +const ( + pollInterval = 2 * time.Second + pollTimeout = 5 * time.Minute +) + +// waitForStatus polls GET /v1/sandboxes/:id until the sandbox lands in +// one of the target statuses or the timeout fires. Returns the final +// view. The set of "failed" statuses always counts as terminal so we +// don't spin forever after a backend error. +func waitForStatus(ctx context.Context, client *api.SandboxClient, id string, targets ...string) (*api.SandboxView, error) { + deadline := time.Now().Add(pollTimeout) + wanted := make(map[string]struct{}, len(targets)) + for _, t := range targets { + wanted[t] = struct{}{} + } + // Always treat "failed" as terminal — the operation is done, even + // if not the way the caller hoped. + wanted["failed"] = struct{}{} + + for { + if ctx.Err() != nil { + return nil, ctx.Err() + } + sb, err := client.GetSandbox(ctx, id) + if err != nil { + return nil, err + } + if _, hit := wanted[sb.Status]; hit { + return sb, nil + } + if time.Now().After(deadline) { + return sb, fmt.Errorf("sandbox stuck in %q after %s — check `createos sandbox get %s`", sb.Status, pollTimeout, id) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + } +} diff --git a/cmd/sandbox/wizard.go b/cmd/sandbox/wizard.go new file mode 100644 index 0000000..2e2fd15 --- /dev/null +++ b/cmd/sandbox/wizard.go @@ -0,0 +1,354 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/terminal" + "github.com/NodeOps-app/createos-cli/internal/ui" +) + +// wizardSeed lets the caller pre-fill some answers so already-supplied +// flags aren't asked for again. +type wizardSeed struct { + name string + rootfs string + ingress bool + netIDs []string + sshKeys []string // canonicalised key strings (NOT paths) +} + +// wizardResult is what runCreateWizard returns when the user finished. +// nil result = user cancelled (caller exits quietly). +type wizardResult struct { + shape string + name string + rootfs string + ingress bool + netIDs []string + sshKeys []string // canonicalised key strings (NOT paths) +} + +// runCreateWizard walks the user through shape → name → rootfs → ingress +// → networks. Each step lets the user cancel (q / esc / ctrl+c) and +// exit cleanly. Returns nil on cancel. +// +// Behavior: +// - On non-TTY this should never be reached — caller guards with the +// terminal.IsInteractive() check. +// - Each step that was already supplied via a flag is skipped. +// - Rootfs / network steps may fail to fetch (API error) — they fall +// through with a warning rather than aborting the whole wizard. +func runCreateWizard(c *cli.Context, client *api.SandboxClient, seed wizardSeed) (*wizardResult, error) { + if !terminal.IsInteractive() { + return nil, fmt.Errorf("please choose a size with --shape\n\n To see the options, run:\n createos sandbox shapes") + } + out := &wizardResult{ + name: seed.name, + rootfs: seed.rootfs, + ingress: seed.ingress, + netIDs: append([]string{}, seed.netIDs...), + sshKeys: append([]string{}, seed.sshKeys...), + } + + // ── 1. Shape (required) ───────────────────────────────────────── + shape, err := wizardPickShape(c, client) + if err != nil { + return nil, err + } + if shape == "" { + return nil, nil + } + out.shape = shape + + // ── 2. Name (optional; default = server-generated) ────────────── + if out.name == "" { + nameInput, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Name for your sandbox (leave empty to auto-generate)"). + Show() + if err != nil { + return nil, fmt.Errorf("could not read sandbox name: %w", err) + } + out.name = strings.TrimSpace(nameInput) + } + + // ── 3. Rootfs (optional; default = host default) ──────────────── + if out.rootfs == "" { + picked, err := wizardPickRootfs(c, client) + if err != nil { + // Non-fatal — log and continue with the server default. + pterm.Println(pterm.Gray(" Could not load image list — using the default.")) + } else if picked == "" { + // User cancelled the rootfs step specifically — keep going + // with the default rather than aborting the whole wizard. + } else { + out.rootfs = picked + } + } + + // ── 4. Public URL? ───────────────────────────────────────────── + if !out.ingress { + yes, err := pterm.DefaultInteractiveConfirm. + WithDefaultText("Give this sandbox a public HTTPS URL?"). + WithDefaultValue(false). + Show() + if err != nil { + return nil, fmt.Errorf("could not read confirmation: %w", err) + } + out.ingress = yes + } + + // ── 5. Attach to private networks (optional; skip if user has none) ── + if len(out.netIDs) == 0 { + picked, err := wizardPickNetworks(c, client) + if err != nil { + pterm.Println(pterm.Gray(" Could not load networks — skipping.")) + } else { + out.netIDs = picked + } + } + + // ── 6. SSH keys (optional; pick from ~/.ssh/, fall back to a path prompt) ── + if len(out.sshKeys) == 0 { + picked, err := wizardPickSSHKeys() + if err != nil { + pterm.Println(pterm.Gray(fmt.Sprintf(" SSH key step skipped (%v).", err))) + } else { + out.sshKeys = picked + } + } + + return out, nil +} + +// wizardPickShape — bubbletea picker over GET /v1/shapes. +func wizardPickShape(c *cli.Context, client *api.SandboxClient) (string, error) { + spinner, _ := pterm.DefaultSpinner.Start("Loading sizes…") + shapes, err := client.ListShapes(c.Context) + spinner.Stop() + if err != nil { + return "", err + } + if len(shapes) == 0 { + return "", fmt.Errorf("no sandbox sizes are available right now") + } + return ui.PickShape(shapes) +} + +// wizardPickRootfs — bubbletea picker over the union of built-in +// images (GET /v1/rootfs) AND the user's own ready templates +// (GET /v1/templates, status=ready). Empty return = user cancelled +// the step; caller falls back to the default. +func wizardPickRootfs(c *cli.Context, client *api.SandboxClient) (string, error) { + spinner, _ := pterm.DefaultSpinner.Start("Loading images…") + cat, err := client.ListRootfs(c.Context) + if err != nil { + spinner.Stop() + return "", err + } + // Templates are best-effort: a fetch failure shouldn't kill the + // create flow. Same forgiveness the UI shows. + tpls, _ := client.ListTemplates(c.Context) + spinner.Stop() + if cat == nil || len(cat.Rootfs) == 0 { + return "", nil + } + items := make([]ui.PickerItem, 0, len(cat.Rootfs)+len(tpls)+1) + // First option is "default" so users can punt without thinking. + items = append(items, ui.PickerItem{ + Title: "(use the default)", + Subtitle: "→ " + cat.Default, + Value: "", + }) + descByName := make(map[string]string) + for _, e := range cat.Entries { + descByName[e.Name] = e.Description + } + for _, name := range cat.Rootfs { + sub := "built-in image" + if d := descByName[name]; d != "" { + sub = d + } + items = append(items, ui.PickerItem{ + Title: name, + Subtitle: sub, + Value: name, + }) + } + // Append the user's own templates. Only ready ones can boot a + // sandbox; others are filtered out so we don't show un-bootable + // entries. Names go through verbatim — server resolves them. + for _, t := range tpls { + if t.Status != "ready" { + continue + } + items = append(items, ui.PickerItem{ + Title: t.Name, + Subtitle: "your template", + Value: t.Name, + }) + } + return ui.Pick("Pick a base image", items) +} + +// wizardPickNetworks — multi-select over GET /v1/networks. Returns [] +// empty when the user has no networks or skips the prompt. +func wizardPickNetworks(c *cli.Context, client *api.SandboxClient) ([]string, error) { + spinner, _ := pterm.DefaultSpinner.Start("Loading networks…") + nets, err := client.ListNetworks(c.Context) + spinner.Stop() + if err != nil { + return nil, err + } + if len(nets) == 0 { + return nil, nil + } + options := make([]string, 0, len(nets)) + idByOption := make(map[string]string, len(nets)) + for _, n := range nets { + label := n.Name + " " + n.ID + options = append(options, label) + idByOption[label] = n.ID + } + picked, err := multiselect("Attach to which networks? (space = pick, enter = confirm, leave none to skip)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + out := make([]string, 0, len(picked)) + for _, p := range picked { + if id, ok := idByOption[p]; ok { + out = append(out, id) + } + } + return out, nil +} + +// wizardPickSSHKeys scans the user's `~/.ssh/` for likely public-key +// files, lets them multi-select which to install, and optionally lets +// them paste a custom path. Returns the canonicalised key strings (not +// paths) so the caller can drop them straight into SandboxCreateReq. +// +// Behavior: +// - No `~/.ssh/` or no candidates → offer a path prompt (still +// skippable with empty input). +// - Returns nil (no error) if the user picks nothing — SSH access is +// optional for a sandbox. +func wizardPickSSHKeys() ([]string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("could not resolve $HOME: %w", err) + } + sshDir := filepath.Join(home, ".ssh") + candidates := discoverSSHPubkeys(sshDir) + + // Brief explainer — users coming from a hosted-PaaS background may + // not know why SSH keys would be involved with a sandbox. + pterm.Println() + pterm.NewStyle(pterm.FgCyan).Println(" SSH keys") + pterm.Println(pterm.Gray(" Installing a public key lets you sign into the sandbox with")) + pterm.Println(pterm.Gray(" `createos sandbox shell` and forward ports (e.g. open a web")) + pterm.Println(pterm.Gray(" server inside the sandbox in your local browser). Skip this")) + pterm.Println(pterm.Gray(" step if you only need `exec`, files, or the public URL.")) + + if len(candidates) == 0 { + // Nothing auto-detected. Offer a one-shot manual path entry. + path, err := pterm.DefaultInteractiveTextInput. + WithDefaultText("Path to a public-key file to install (leave empty to skip)"). + Show() + if err != nil { + return nil, fmt.Errorf("could not read the key path: %w", err) + } + path = strings.TrimSpace(path) + if path == "" { + return nil, nil + } + return readSSHPubkeys([]string{path}) + } + + // Multi-select over the discovered files. Show absolute paths so + // users on multi-key setups can tell them apart. + options := make([]string, 0, len(candidates)) + pathByOpt := make(map[string]string, len(candidates)) + for _, path := range candidates { + label := relToHome(path, home) + options = append(options, label) + pathByOpt[label] = path + } + picked, err := multiselect("Install which SSH keys? (space = pick, enter = confirm, leave none to skip)"). + WithOptions(options). + Show() + if err != nil { + return nil, fmt.Errorf("could not read your selection: %w", err) + } + if len(picked) == 0 { + return nil, nil + } + paths := make([]string, 0, len(picked)) + for _, p := range picked { + if real, ok := pathByOpt[p]; ok { + paths = append(paths, real) + } + } + return readSSHPubkeys(paths) +} + +// discoverSSHPubkeys lists candidate `.pub` files under sshDir. +// Filters out anything that doesn't look like a public key (size 0 or +// missing the `ssh-` / `ecdsa-` prefix on the first line). +func discoverSSHPubkeys(sshDir string) []string { + entries, err := os.ReadDir(sshDir) + if err != nil { + return nil + } + out := make([]string, 0) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".pub") { + continue + } + path := filepath.Join(sshDir, e.Name()) + head, err := os.ReadFile(path) + if err != nil || len(head) == 0 { + continue + } + // First-line sniff for openssh public-key shape. + first := strings.SplitN(strings.TrimSpace(string(head)), " ", 2)[0] + if !strings.HasPrefix(first, "ssh-") && !strings.HasPrefix(first, "ecdsa-") && !strings.HasPrefix(first, "sk-") { + continue + } + out = append(out, path) + } + return out +} + +// relToHome shortens absolute paths under $HOME to `~/...` so the +// picker shows compact labels. +func relToHome(path, home string) string { + if strings.HasPrefix(path, home+string(filepath.Separator)) { + return "~" + strings.TrimPrefix(path, home) + } + return path +} + +// stringSliceCleanup trims and drops empty entries — pulled out of +// runCreate so the wizard plumbing can reuse it. +func stringSliceCleanup(raw []string) []string { + if len(raw) == 0 { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v != "" { + out = append(out, v) + } + } + return out +} diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go new file mode 100644 index 0000000..4509043 --- /dev/null +++ b/internal/api/sandbox.go @@ -0,0 +1,754 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + + "github.com/go-resty/resty/v2" +) + +// ListSandboxesOpts bounds the query for ListSandboxes. +type ListSandboxesOpts struct { + // Limit is the page size; 0 = server default (50). + Limit int + // Offset is the page offset; 0 = first page. + Offset int + // Status, if non-empty, filters server-side BEFORE the limit: + // "running", "creating", "destroyed", "failed", etc. + Status string +} + +// ListSandboxes returns one page of the caller's sandboxes plus the +// pagination block so callers can compute "more pages?". +func (c *SandboxClient) ListSandboxes(ctx context.Context, opts ListSandboxesOpts) ([]SandboxView, Pagination, error) { + r := c.Client.R().SetContext(ctx) + if opts.Limit > 0 { + r.SetQueryParam("limit", fmt.Sprintf("%d", opts.Limit)) + } + if opts.Offset > 0 { + r.SetQueryParam("offset", fmt.Sprintf("%d", opts.Offset)) + } + if opts.Status != "" { + r.SetQueryParam("status", opts.Status) + } + var envelope Response[SandboxList] + resp, err := r.SetResult(&envelope).Get("/v1/sandboxes") + if err != nil { + return nil, Pagination{}, err + } + if resp.IsError() { + return nil, Pagination{}, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, envelope.Data.Pagination, nil +} + +// UploadFile streams local data into the sandbox at `remote` (must be +// absolute). The server only inspects the ?path= query, not the body +// shape, so we send raw octets and an explicit Content-Length when we +// know it. +func (c *SandboxClient) UploadFile(ctx context.Context, id, remote string, body io.Reader, size int64) error { + r := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("path", remote). + SetHeader("Content-Type", "application/octet-stream"). + SetBody(body) + if size > 0 { + r.SetHeader("Content-Length", fmt.Sprintf("%d", size)) + } + resp, err := r.Put("/v1/sandboxes/{id}/files") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// DownloadFile streams the sandbox file at `remote` into dst. Returns +// the number of bytes copied. +func (c *SandboxClient) DownloadFile(ctx context.Context, id, remote string, dst io.Writer) (int64, error) { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("path", remote). + SetDoNotParseResponse(true). + Get("/v1/sandboxes/{id}/files") + if err != nil { + return 0, err + } + body := resp.RawBody() + defer body.Close() + if resp.IsError() { + raw, _ := io.ReadAll(body) + return 0, ParseAPIError(resp.StatusCode(), raw) + } + return io.Copy(dst, body) +} + +// ExecSandbox runs a command inside the sandbox and returns the +// buffered result (stdout, stderr, exit code). +func (c *SandboxClient) ExecSandbox(ctx context.Context, id string, req SandboxExecReq) (*SandboxExecResp, error) { + req.Stream = false + var envelope Response[SandboxExecResp] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(req). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/exec") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ExecSandboxStream runs a command and streams NDJSON events live to +// onEvent. onEvent is invoked per line — the caller decides what to do +// with stdout/stderr chunks. Returns the final exit code (or -1 if the +// stream ended without a terminal exit_code frame). +func (c *SandboxClient) ExecSandboxStream(ctx context.Context, id string, req SandboxExecReq, onEvent func(SandboxExecStreamEvent)) (int, error) { + req.Stream = true + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("stream", "true"). + SetBody(req). + SetDoNotParseResponse(true). + Post("/v1/sandboxes/{id}/exec") + if err != nil { + return -1, err + } + body := resp.RawBody() + defer body.Close() + + // Non-2xx bodies are JSend envelopes, not NDJSON — read and parse. + if resp.IsError() { + raw, _ := io.ReadAll(body) + return -1, ParseAPIError(resp.StatusCode(), raw) + } + + exit := -1 + scanner := bufio.NewScanner(body) + // Bigger buffer for fat stdout chunks (default is 64 KiB). + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var ev SandboxExecStreamEvent + if err := json.Unmarshal(line, &ev); err != nil { + // Skip malformed lines rather than abort the whole exec — + // otherwise a server-side log glitch kills user output. + continue + } + if ev.ExitCode != nil { + exit = *ev.ExitCode + } + onEvent(ev) + } + if err := scanner.Err(); err != nil { + return exit, fmt.Errorf("read stream: %w", err) + } + return exit, nil +} + +// AddSSHPubkeys appends OpenSSH-formatted public keys to a sandbox. +// The server canonicalises and de-duplicates against existing keys. +// Returns the new total count. +func (c *SandboxClient) AddSSHPubkeys(ctx context.Context, id string, keys []string) (int, error) { + body := map[string]any{"keys": keys} + var envelope Response[struct { + Count int `json:"count"` + }] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(body). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/ssh-pubkeys") + if err != nil { + return 0, err + } + if resp.IsError() { + return 0, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Count, nil +} + +// SetSandboxIngress flips the public-URL toggle on a running sandbox. +// PATCH /v1/sandboxes/:id with {"ingress_enabled": }. Returns +// 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} + var envelope Response[SandboxView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(body). + SetResult(&envelope). + Patch("/v1/sandboxes/{id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ── Disks ───────────────────────────────────────────────────────── + +// CreateDisk registers an S3 bucket as a named disk the caller can +// later mount into sandboxes. The server HEAD-probes the bucket +// before persisting, so wrong creds / unreachable endpoints fail +// fast at create time. +func (c *SandboxClient) CreateDisk(ctx context.Context, req DiskCreateReq) (*DiskView, error) { + var envelope Response[DiskView] + resp, err := c.Client.R(). + SetContext(ctx). + SetBody(req). + SetResult(&envelope). + Post("/v1/disks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ListDisks returns the caller's disks. Paginated server-side; we +// pull one large page to cover anyone but extreme outliers. +func (c *SandboxClient) ListDisks(ctx context.Context) ([]DiskView, error) { + var envelope Response[DiskList] + resp, err := c.Client.R(). + SetContext(ctx). + SetQueryParam("limit", "200"). + SetResult(&envelope). + Get("/v1/disks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// GetDisk fetches one disk by id OR friendly name. +func (c *SandboxClient) GetDisk(ctx context.Context, ref string) (*DiskView, error) { + var envelope Response[DiskView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + SetResult(&envelope). + Get("/v1/disks/{ref}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeleteDisk soft-deletes a disk by id or name. 409 if attached to a +// non-terminal sandbox — caller must detach (or destroy) first. +func (c *SandboxClient) DeleteDisk(ctx context.Context, ref string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + Delete("/v1/disks/{ref}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ListSandboxDisks returns the attachments on one sandbox (with the +// live mount_status reported by the guest agent). +func (c *SandboxClient) ListSandboxDisks(ctx context.Context, sandboxID string) ([]SandboxDiskView, error) { + var envelope Response[SandboxDiskList] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/disks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// AttachDisk live-attaches a disk to a running sandbox. The agent +// mounts it on its next reconcile tick (~seconds). +func (c *SandboxClient) AttachDisk(ctx context.Context, sandboxID string, req DiskAttachReq) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetBody(req). + Post("/v1/sandboxes/{id}/disks") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// DetachDisk unmounts a disk from a running sandbox. The bucket itself +// is untouched. mountPath is required so a disk mounted at multiple +// paths can detach exactly one. +func (c *SandboxClient) DetachDisk(ctx context.Context, sandboxID, diskRef, mountPath string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("disk", diskRef). + SetQueryParam("mount_path", mountPath). + Delete("/v1/sandboxes/{id}/disks/{disk}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// PauseSandbox kicks off the async pause. Returns the row in its +// `pausing` state; callers should poll GetSandbox until status flips +// to `paused` (or `failed`). +func (c *SandboxClient) PauseSandbox(ctx context.Context, id string) (*SandboxView, error) { + return c.lifecyclePOST(ctx, id, "/v1/sandboxes/{id}/pause") +} + +// ResumeSandbox kicks off the async resume. Status flips through +// `resuming` → `running`. +func (c *SandboxClient) ResumeSandbox(ctx context.Context, id string) (*SandboxView, error) { + return c.lifecyclePOST(ctx, id, "/v1/sandboxes/{id}/resume") +} + +// ForkSandbox clones a paused source sandbox into a brand-new id. By +// default the fork auto-resumes; pass StartPaused=true to keep it +// paused. The response is the NEW sandbox's view (in `forking` or +// `paused`/`running` depending on StartPaused + timing). +func (c *SandboxClient) ForkSandbox(ctx context.Context, srcID string, req SandboxForkReq) (*SandboxView, error) { + var envelope Response[SandboxView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", srcID). + SetBody(req). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/fork") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// lifecyclePOST is the shared shape of pause/resume — body-less POST +// to /v1/sandboxes/{id}/, returning the updated view. +func (c *SandboxClient) lifecyclePOST(ctx context.Context, id, path string) (*SandboxView, error) { + var envelope Response[SandboxView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetResult(&envelope). + Post(path) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DestroySandbox issues DELETE /v1/sandboxes/:id. The server returns +// 200 with {id, status:"destroying"} — actual teardown is async. +// Calling DestroySandbox on a row already destroyed returns 200 too, +// so this is idempotent on the wire. +func (c *SandboxClient) DestroySandbox(ctx context.Context, id string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + Delete("/v1/sandboxes/{id}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// GetSandbox fetches one sandbox by id. Returns a wrapped *APIError on +// non-2xx (404 when the id is wrong or the sandbox belongs to someone +// else — the server returns the same shape both ways). +func (c *SandboxClient) GetSandbox(ctx context.Context, id string) (*SandboxView, error) { + var envelope Response[SandboxView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetResult(&envelope). + Get("/v1/sandboxes/{id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ListRootfs returns the rootfs catalog (base images + templates the +// cluster has cached). Single-item endpoint — the response carries +// both the list and the cluster default name. +func (c *SandboxClient) ListRootfs(ctx context.Context) (*RootfsCatalog, error) { + var envelope Response[RootfsCatalog] + resp, err := c.Client.R(). + SetContext(ctx). + SetResult(&envelope). + Get("/v1/rootfs") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// CreateNetwork registers a new private network. Returns the row +// (with empty Members on a fresh network). +func (c *SandboxClient) CreateNetwork(ctx context.Context, name string) (*SandboxNetwork, error) { + var envelope Response[SandboxNetwork] + resp, err := c.Client.R(). + SetContext(ctx). + SetBody(map[string]string{"name": name}). + SetResult(&envelope). + Post("/v1/networks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// GetNetwork fetches one network by id OR friendly name. Includes the +// Members list so the caller can show what's attached. +func (c *SandboxClient) GetNetwork(ctx context.Context, ref string) (*SandboxNetwork, error) { + var envelope Response[SandboxNetwork] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + SetResult(&envelope). + Get("/v1/networks/{ref}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeleteNetwork soft-deletes a network by id or name. +func (c *SandboxClient) DeleteNetwork(ctx context.Context, ref string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + Delete("/v1/networks/{ref}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// AttachNetwork live-attaches a sandbox to a network. The body matches +// SandboxNetworkAttach so attach-at-create and attach-at-runtime share +// the same shape on the wire. +func (c *SandboxClient) AttachNetwork(ctx context.Context, sandboxID, netRef string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetBody(SandboxNetworkAttach{ID: netRef}). + Post("/v1/sandboxes/{id}/networks") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// DetachNetwork live-detaches a sandbox from a network. +func (c *SandboxClient) DetachNetwork(ctx context.Context, sandboxID, netRef string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("net", netRef). + Delete("/v1/sandboxes/{id}/networks/{net}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ListNetworks returns the caller's private networks. Paginated on the +// wire; we pull one page (200 max) which covers anyone but pathological +// outliers. +func (c *SandboxClient) ListNetworks(ctx context.Context) ([]SandboxNetwork, error) { + var envelope Response[NetworkList] + resp, err := c.Client.R(). + SetContext(ctx). + SetQueryParam("limit", "200"). + SetResult(&envelope). + Get("/v1/networks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// ListShapes returns the static shape catalog. Server-side this is a +// paginated endpoint; the full catalog fits in one page in practice. +func (c *SandboxClient) ListShapes(ctx context.Context) ([]Shape, error) { + var envelope Response[ShapeList] + resp, err := c.Client.R(). + SetContext(ctx). + SetResult(&envelope). + Get("/v1/shapes") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// CreateSandbox creates a sandbox via POST /v1/sandboxes. +// Returns the parsed SandboxCreateResp on 2xx; *APIError otherwise. +func (c *SandboxClient) CreateSandbox(ctx context.Context, req SandboxCreateReq) (*SandboxCreateResp, error) { + var envelope Response[SandboxCreateResp] + resp, err := c.Client.R(). + SetContext(ctx). + SetBody(req). + SetResult(&envelope). + Post("/v1/sandboxes") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// GetBandwidth returns the live quota / used / remaining counters. +func (c *SandboxClient) GetBandwidth(ctx context.Context, id string) (*BandwidthView, error) { + var envelope Response[BandwidthView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/bandwidth") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// RechargeBandwidth adds bytes to the sandbox's quota. Returns the +// updated bandwidth view. +func (c *SandboxClient) RechargeBandwidth(ctx context.Context, id string, addBytes int64) (*BandwidthView, error) { + var envelope Response[BandwidthView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(BandwidthRechargeReq{AddBytes: addBytes}). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/bandwidth/recharge") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// GetEgress returns the sandbox's outbound allowlist. +func (c *SandboxClient) GetEgress(ctx context.Context, id string) ([]string, error) { + var envelope Response[EgressView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/egress") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Egress, nil +} + +// SetEgress replaces the sandbox's outbound allowlist. Empty slice +// means allow-all. +func (c *SandboxClient) SetEgress(ctx context.Context, id string, rules []string) ([]string, error) { + if rules == nil { + rules = []string{} + } + var envelope Response[EgressView] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetBody(EgressSetReq{Egress: rules}). + SetResult(&envelope). + Put("/v1/sandboxes/{id}/egress") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Egress, nil +} + +// ── Templates ───────────────────────────────────────────────────── + +// CreateTemplate submits a Dockerfile for build. Returns the +// newly-inserted (pending) template row. Build runs async — caller +// can poll GetTemplate or stream logs to watch progress. +func (c *SandboxClient) CreateTemplate(ctx context.Context, req TemplateCreateReq) (*TemplateView, error) { + var envelope Response[TemplateView] + resp, err := c.Client.R(). + SetContext(ctx). + SetBody(req). + SetResult(&envelope). + Post("/v1/templates") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ListTemplates returns the caller's templates. Paginated server-side; +// we pull one max-page which covers anyone but pathological outliers. +func (c *SandboxClient) ListTemplates(ctx context.Context) ([]TemplateView, error) { + var envelope Response[TemplateList] + resp, err := c.Client.R(). + SetContext(ctx). + SetQueryParam("limit", "200"). + SetResult(&envelope). + Get("/v1/templates") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// GetTemplate fetches one template by id OR friendly name. When +// withDockerfile is true the response includes the source. +func (c *SandboxClient) GetTemplate(ctx context.Context, ref string, withDockerfile bool) (*TemplateView, error) { + var envelope Response[TemplateView] + req := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + SetResult(&envelope) + if withDockerfile { + req = req.SetQueryParam("include", "dockerfile") + } + resp, err := req.Get("/v1/templates/{ref}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeleteTemplate soft-deletes a template. Paused sandboxes that were +// built from it can still be resumed; new sandbox creates will 404. +func (c *SandboxClient) DeleteTemplate(ctx context.Context, ref string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + Delete("/v1/templates/{ref}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// StreamTemplateLogs opens the NDJSON log stream and returns the raw +// HTTP response — the caller is responsible for scanning lines and +// closing the body. Honours ctx cancellation through resty. +func (c *SandboxClient) StreamTemplateLogs(ctx context.Context, ref string, follow bool) (*resty.Response, error) { + req := c.Client.R(). + SetContext(ctx). + SetPathParam("ref", ref). + SetDoNotParseResponse(true) + if follow { + req = req.SetQueryParam("follow", "true") + } + resp, err := req.Get("/v1/templates/{ref}/logs") + if err != nil { + return nil, err + } + if resp.IsError() { + body := resp.RawBody() + defer body.Close() + return nil, ParseAPIError(resp.StatusCode(), nil) + } + return resp, nil +} diff --git a/internal/api/sandbox_client.go b/internal/api/sandbox_client.go new file mode 100644 index 0000000..7ac7975 --- /dev/null +++ b/internal/api/sandbox_client.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/go-resty/resty/v2" +) + +// DefaultSandboxBaseURL is the default fc-spawn API base URL. The +// sandbox surface lives on a different host from the main CreateOS API +// (api-createos.nodeops.network); these two clients are wired +// side-by-side under app.Metadata. +const DefaultSandboxBaseURL = "https://fc-spawn.bhautik.in" + +// SandboxClient wraps a resty.Client configured for the fc-spawn API. +// Mirrors APIClient but targets the sandbox base URL and uses +// X-Api-Key as the auth header (fc-spawn's preferred header — Bearer +// is not accepted on user-facing routes). +type SandboxClient struct { + Client *resty.Client +} + +// NewSandboxClient builds a SandboxClient with the given token + URL. +// Empty url falls back to DefaultSandboxBaseURL. The same token used +// for the CreateOS API works here too — fc-spawn validates against the +// same upstream NodeOps auth service. +func NewSandboxClient(token, sandboxURL string, debug bool) SandboxClient { + if sandboxURL == "" { + sandboxURL = DefaultSandboxBaseURL + } + client := resty.New(). + SetBaseURL(sandboxURL). + SetHeader("X-Api-Key", token). + SetHeader("Content-Type", "application/json") + if debug { + client.SetDebug(true) + client.SetLogger(&maskingLogger{ + token: token, + masked: maskToken(token), + }) + } + return SandboxClient{Client: client} +} + +// SandboxClientKey is the cli.Context metadata key for the sandbox client. +const SandboxClientKey = "sandbox_client" diff --git a/internal/api/sandbox_types.go b/internal/api/sandbox_types.go new file mode 100644 index 0000000..46006ab --- /dev/null +++ b/internal/api/sandbox_types.go @@ -0,0 +1,330 @@ +package api + +import "time" + +// ── Sandbox wire types ──────────────────────────────────────────── +// +// Mirrors fc-spawn's user-facing API. Field names match the JSON the +// server emits so we round-trip cleanly through Resty's SetResult. +// Pointer types are used for fields that may be null (optional name, +// optional ssh keys, etc.). + +// 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"` +} + +// SandboxNetworkAttach binds a sandbox to a private network at create time. +type SandboxNetworkAttach struct { + ID string `json:"id"` +} + +// SandboxDiskAttach mounts an S3 disk at create time. +type SandboxDiskAttach struct { + DiskID string `json:"disk_id"` + MountPath string `json:"mount_path"` +} + +// SandboxExecReq is the body of POST /v1/sandboxes/:id/exec. +type SandboxExecReq struct { + Cmd string `json:"cmd"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Stdin string `json:"stdin,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// SandboxExecResult is the inner ExecResponse the agent returns. +type SandboxExecResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` +} + +// SandboxExecResp is the buffered (non-streaming) response shape. +type SandboxExecResp struct { + Result SandboxExecResult `json:"result"` + ExecMs float64 `json:"exec_ms,omitempty"` +} + +// SandboxExecStreamEvent is one NDJSON frame from the streaming endpoint. +// Exactly one of (Stdout, Stderr, ExitCode, Error) is meaningful per +// event; HB heartbeats arrive every ~5 s on silent commands. +type SandboxExecStreamEvent struct { + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + HB bool `json:"hb,omitempty"` +} + +// SandboxForkReq is the body of POST /v1/sandboxes/:src/fork. All +// fields are optional overrides; omit any to inherit from the source +// sandbox's snapshot. +type SandboxForkReq struct { + SSHPubkeys []string `json:"ssh_pubkeys,omitempty"` + Egress []string `json:"egress,omitempty"` + IngressEnabled *bool `json:"ingress_enabled,omitempty"` + Envs map[string]string `json:"envs,omitempty"` + // StartPaused = true leaves the fork in the `paused` state; otherwise + // the server auto-resumes it after fork. + StartPaused bool `json:"start_paused,omitempty"` +} + +// 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"` +} + +// SandboxView is the projection returned by GET /v1/sandboxes and +// GET /v1/sandboxes/:id. The shape matches fc-spawn's SandboxView. +type SandboxView struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Status string `json:"status"` + IP *string `json:"ip,omitempty"` + VCPU int `json:"vcpu"` + MemMib int `json:"mem_mib"` + DiskMib int64 `json:"disk_mib"` + CreatedAt time.Time `json:"created_at"` + RunningAt *time.Time `json:"running_at,omitempty"` + DestroyedAt *time.Time `json:"destroyed_at,omitempty"` + SpawnMs float64 `json:"spawn_ms,omitempty"` + Shape string `json:"shape,omitempty"` + Rootfs *string `json:"rootfs,omitempty"` + Egress []string `json:"egress,omitempty"` + SSHPubkeys []string `json:"ssh_pubkeys,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Region string `json:"region"` + IngressEnabled bool `json:"ingress_enabled"` + IngressURLTemplate string `json:"ingress_url_template,omitempty"` + BandwidthIngressBytes int64 `json:"bandwidth_ingress_bytes,omitempty"` + Envs []string `json:"envs,omitempty"` + PausedAt *time.Time `json:"paused_at,omitempty"` + LastResumedAt *time.Time `json:"last_resumed_at,omitempty"` + ForkedFrom *string `json:"forked_from,omitempty"` +} + +// ── List shape ──────────────────────────────────────────────────── +// +// fc-spawn paginated lists wrap items under data.data[] with a +// pagination block. Matches the createos PaginatedResponse[T] shape. + +// SandboxList is the inner shape under data for GET /v1/sandboxes. +type SandboxList struct { + Data []SandboxView `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// ── Catalog ─────────────────────────────────────────────────────── + +// Shape describes one row of GET /v1/shapes. +type Shape struct { + ID string `json:"id"` + VCPU int `json:"vcpu"` + MemMib int `json:"mem_mib"` + DefaultDiskMib int64 `json:"default_disk_mib"` +} + +// ShapeList is the inner shape under data for GET /v1/shapes. +type ShapeList struct { + Data []Shape `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// RootfsEntry describes one row of GET /v1/rootfs. +type RootfsEntry struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Successor string `json:"successor,omitempty"` +} + +// RootfsCatalog is the response shape of GET /v1/rootfs. The endpoint +// is single-item (not paginated) because it carries both the list and +// the default in one object. +type RootfsCatalog struct { + Rootfs []string `json:"rootfs"` + Default string `json:"default"` + Entries []RootfsEntry `json:"entries"` +} + +// ── Disks ───────────────────────────────────────────────────────── + +// DiskCreateReq is the body of POST /v1/disks. +type DiskCreateReq struct { + Name string `json:"name"` + Kind string `json:"kind"` // "s3" today + Config DiskConfig `json:"config"` + Credentials DiskCredentials `json:"credentials"` +} + +// DiskConfig is the non-secret S3 endpoint description. +type DiskConfig struct { + Bucket string `json:"bucket"` + Endpoint string `json:"endpoint"` + Region string `json:"region,omitempty"` + UsePathStyle bool `json:"use_path_style,omitempty"` +} + +// DiskCredentials is the bucket creds. AES-encrypted at rest by the +// control plane; never returned in any GET response. +type DiskCredentials struct { + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` +} + +// DiskView is the user-facing projection returned by all read endpoints. +type DiskView struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config DiskConfig `json:"config"` + CreatedAt time.Time `json:"created_at"` +} + +// DiskList is the paginated list shape. +type DiskList struct { + Data []DiskView `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// SandboxDiskView is one attachment of a disk to a running sandbox, +// shape of GET /v1/sandboxes/:id/disks rows. +type SandboxDiskView struct { + DiskID string `json:"disk_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config DiskConfig `json:"config"` + MountPath string `json:"mount_path"` + SubPath string `json:"sub_path,omitempty"` + MountStatus string `json:"mount_status"` + MountError string `json:"mount_error,omitempty"` +} + +// SandboxDiskList is the paginated list shape under data. +type SandboxDiskList struct { + Data []SandboxDiskView `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// DiskAttachReq is the body of POST /v1/sandboxes/:id/disks. +type DiskAttachReq struct { + DiskID string `json:"disk_id"` + MountPath string `json:"mount_path"` + SubPath string `json:"sub_path,omitempty"` +} + +// SandboxNetwork describes one row of GET /v1/networks. Members is +// populated only by the per-id GET, not by the list endpoint. +type SandboxNetwork struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + MemberCount int `json:"member_count,omitempty"` + Members []SandboxNetworkMember `json:"members,omitempty"` +} + +// SandboxNetworkMember is one sandbox attached to a network. +type SandboxNetworkMember struct { + SandboxID string `json:"sandbox_id"` + Status string `json:"status"` + IP string `json:"ip,omitempty"` + Name string `json:"name,omitempty"` +} + +// NetworkList is the inner shape under data for GET /v1/networks. +type NetworkList struct { + Data []SandboxNetwork `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// BandwidthView is the response of GET /v1/sandboxes/:id/bandwidth. +// `Capped` flips true once usage hits the quota; while capped, the +// sandbox's egress is throttled hard at the host. `RemainingBytes` +// can read negative when used > quota due to in-flight accounting. +type BandwidthView struct { + ID string `json:"id"` + QuotaBytes int64 `json:"quota_bytes"` + UsedBytes int64 `json:"used_bytes"` + IngressBytes int64 `json:"ingress_bytes"` + RemainingBytes int64 `json:"remaining_bytes"` + Capped bool `json:"capped"` +} + +// BandwidthRechargeReq is the body of POST /v1/sandboxes/:id/bandwidth/recharge. +type BandwidthRechargeReq struct { + AddBytes int64 `json:"add_bytes"` +} + +// EgressView is the response of GET /v1/sandboxes/:id/egress. +type EgressView struct { + Egress []string `json:"egress"` +} + +// EgressSetReq is the body of PUT /v1/sandboxes/:id/egress. +type EgressSetReq struct { + Egress []string `json:"egress"` +} + +// ── Templates ───────────────────────────────────────────────────── + +// TemplateView projects a templates row to the user-facing API. +// Mirrors fc-spawn's types.TemplateView. +type TemplateView struct { + ID string `json:"id"` + Name string `json:"name"` + Base string `json:"base"` + Status string `json:"status"` + Ext4SizeBytes int64 `json:"ext4_size_bytes"` + CreatedAt time.Time `json:"created_at"` + BuiltAt *time.Time `json:"built_at,omitempty"` + // Only populated by GET /v1/templates/:id?include=dockerfile. + Dockerfile string `json:"dockerfile,omitempty"` +} + +// TemplateList is the inner shape under data for GET /v1/templates. +type TemplateList struct { + Data []TemplateView `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// TemplateCreateReq is the body of POST /v1/templates. +type TemplateCreateReq struct { + Name string `json:"name"` + Dockerfile string `json:"dockerfile"` + Base string `json:"base,omitempty"` +} + +// TemplateLogEvent is one NDJSON frame from +// GET /v1/templates/:id/logs?follow=true. Either `Line` carries a +// build output line, or `Final` is true with `Status` set to +// ready/failed/cancelled. +type TemplateLogEvent struct { + Line string `json:"line,omitempty"` + Final bool `json:"final,omitempty"` + Status string `json:"status,omitempty"` +} diff --git a/internal/ui/picker.go b/internal/ui/picker.go new file mode 100644 index 0000000..d538f9a --- /dev/null +++ b/internal/ui/picker.go @@ -0,0 +1,90 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// PickerItem is one row in a generic picker. Title is the highlight +// label; Subtitle is a dim secondary line shown in parentheses; Value +// is what gets returned when the user hits enter (typically the id). +type PickerItem struct { + Title string + Subtitle string + Value string +} + +// Pick runs a single-column bubbletea picker over items. Returns the +// chosen item's Value, or "" if the user cancelled. +func Pick(title string, items []PickerItem) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("nothing to pick from") + } + m := pickerModel{title: title, items: items} + p := tea.NewProgram(m) + out, err := p.Run() + if err != nil { + return "", err + } + res := out.(pickerModel) + if res.quit { + return "", nil + } + return res.items[res.cursor].Value, nil +} + +type pickerModel struct { + title string + items []PickerItem + cursor int + quit bool + done bool +} + +func (m pickerModel) Init() tea.Cmd { return nil } + +func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quit = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "enter", " ": + m.done = true + return m, tea.Quit + } + } + return m, nil +} + +func (m pickerModel) View() string { + if m.done || m.quit { + return "" + } + var b strings.Builder + b.WriteString(titleStyle.Render(m.title) + "\n") + b.WriteString(hintStyle.Render("Arrow keys to move, enter to pick, q to cancel") + "\n\n") + for i, item := range m.items { + line := item.Title + if item.Subtitle != "" { + line += " " + item.Subtitle + } + if i == m.cursor { + b.WriteString(selectedStyle.Render("› "+line) + "\n") + } else { + b.WriteString(normalStyle.Render(" "+line) + "\n") + } + } + return b.String() +} diff --git a/internal/ui/shape_picker.go b/internal/ui/shape_picker.go new file mode 100644 index 0000000..7a59910 --- /dev/null +++ b/internal/ui/shape_picker.go @@ -0,0 +1,141 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +// PickShape runs an interactive bubbletea picker over the supplied +// shape catalog and returns the picked shape's id. Returns "" if the +// user quit without selecting (ctrl+c / esc / q). Caller is expected +// to handle the empty-string outcome. +// +// Use only when stdout is a TTY (see internal/terminal.IsInteractive). +// Headless callers should print a hint instead. +func PickShape(shapes []api.Shape) (string, error) { + if len(shapes) == 0 { + return "", fmt.Errorf("no sandbox sizes available") + } + m := shapePickerModel{shapes: shapes, cursor: 0} + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return "", err + } + out := finalModel.(shapePickerModel) + if out.quit { + return "", nil + } + return out.shapes[out.cursor].ID, nil +} + +// shapePickerModel is the bubbletea Model for the shape picker. +type shapePickerModel struct { + shapes []api.Shape + cursor int + quit bool + done bool +} + +func (m shapePickerModel) Init() tea.Cmd { return nil } + +func (m shapePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quit = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.shapes)-1 { + m.cursor++ + } + case "enter", " ": + m.done = true + return m, tea.Quit + } + } + return m, nil +} + +func (m shapePickerModel) View() string { + if m.done || m.quit { + // Clear the picker once we're done so it doesn't linger above + // the rest of the create output. + return "" + } + var b strings.Builder + b.WriteString(titleStyle.Render("Pick a sandbox size") + "\n") + b.WriteString(hintStyle.Render("Arrow keys to move, enter to pick, q to cancel") + "\n\n") + + // Column widths from the data. + idW, cpuW, memW, diskW := 4, 4, 6, 8 + for _, s := range m.shapes { + idW = max(idW, len(s.ID)) + cpuW = max(cpuW, len(fmt.Sprintf("%d", s.VCPU))) + memW = max(memW, len(humanMiB(int64(s.MemMib)))) + diskW = max(diskW, len(humanMiB(s.DefaultDiskMib))) + } + + header := fmt.Sprintf(" %-*s %-*s %-*s %-*s", + idW, "ID", cpuW, "VCPU", memW, "RAM", diskW, "DISK") + b.WriteString(labelStyle.Render(header) + "\n") + + for i, s := range m.shapes { + row := fmt.Sprintf("%-*s %-*d %-*s %-*s", + idW, s.ID, cpuW, s.VCPU, memW, humanMiB(int64(s.MemMib)), diskW, humanMiB(s.DefaultDiskMib)) + if i == m.cursor { + b.WriteString(selectedStyle.Render("› "+row) + "\n") + } else { + b.WriteString(normalStyle.Render(" "+row) + "\n") + } + } + return b.String() +} + +// humanMiB renders a MiB value as GB / MB. Avoids importing a heavy +// units library — fc-spawn shapes only span 256 MiB to 60 GB. +func humanMiB(mib int64) string { + if mib <= 0 { + return "—" + } + if mib >= 1024 && mib%1024 == 0 { + return fmt.Sprintf("%d GB", mib/1024) + } + if mib >= 1024 { + return fmt.Sprintf("%.1f GB", float64(mib)/1024) + } + return fmt.Sprintf("%d MB", mib) +} + +// Local lipgloss palette mirrors the package's existing skill picker +// styles so a user sees one coherent look across pickers. +var ( + // Reuse the already-declared package vars from skills_list.go. + _ = titleStyle + _ = hintStyle + _ = labelStyle + _ = selectedStyle + _ = normalStyle +) + +// max in Go 1.21+ stdlib — written explicitly here so we don't depend +// on the build toolchain having generics-friendly builtins enabled. +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// Compile-time guard so we notice if lipgloss is removed upstream. +var _ = lipgloss.NewStyle From 19217bafeb3d91244668cc41d41bb09849c02aa7 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Thu, 4 Jun 2026 16:04:50 +0200 Subject: [PATCH 2/4] fix: sandbox client for oauth --- cmd/root/root.go | 5 +---- internal/api/sandbox_client.go | 27 ++++++++++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 0f10ee7..03bb09e 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -134,10 +134,7 @@ func NewApp() *cli.App { } client := api.NewClientWithAccessToken(session.AccessToken, c.String("api-url"), c.Bool("debug")) c.App.Metadata[api.ClientKey] = &client - // Sandbox API (fc-spawn) reuses the same access token — - // the token is validated against the shared NodeOps - // auth service on the server side. - sandboxClient := api.NewSandboxClient(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug")) + sandboxClient := api.NewSandboxClientWithAccessToken(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug")) c.App.Metadata[api.SandboxClientKey] = &sandboxClient return nil } diff --git a/internal/api/sandbox_client.go b/internal/api/sandbox_client.go index 7ac7975..d30c926 100644 --- a/internal/api/sandbox_client.go +++ b/internal/api/sandbox_client.go @@ -18,17 +18,34 @@ type SandboxClient struct { Client *resty.Client } -// NewSandboxClient builds a SandboxClient with the given token + URL. -// Empty url falls back to DefaultSandboxBaseURL. The same token used -// for the CreateOS API works here too — fc-spawn validates against the -// same upstream NodeOps auth service. +// NewSandboxClient builds a SandboxClient for API-key auth. Empty url +// falls back to DefaultSandboxBaseURL. The api key is sent as X-Api-Key +// — fc-spawn validates it against the same upstream NodeOps auth service. +// +// For OAuth/browser logins the credential is an access-token JWT, NOT an +// api key: fc-spawn rejects it under X-Api-Key ("invalid api key") and +// requires the X-Access-Token header instead. Use +// NewSandboxClientWithAccessToken for that case. func NewSandboxClient(token, sandboxURL string, debug bool) SandboxClient { + return newSandboxClient("X-Api-Key", token, sandboxURL, debug) +} + +// NewSandboxClientWithAccessToken builds a SandboxClient authenticated +// with an OAuth access token, sent via the X-Access-Token header. This +// mirrors NewClientWithAccessToken on the main API client — fc-spawn +// accepts the same token under this header. +func NewSandboxClientWithAccessToken(accessToken, sandboxURL string, debug bool) SandboxClient { + return newSandboxClient("X-Access-Token", accessToken, sandboxURL, debug) +} + +// newSandboxClient is the shared builder behind the two auth schemes. +func newSandboxClient(authHeader, token, sandboxURL string, debug bool) SandboxClient { if sandboxURL == "" { sandboxURL = DefaultSandboxBaseURL } client := resty.New(). SetBaseURL(sandboxURL). - SetHeader("X-Api-Key", token). + SetHeader(authHeader, token). SetHeader("Content-Type", "application/json") if debug { client.SetDebug(true) From ff595861e35b1e5c0080f49c2f68824d7867f384 Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Thu, 4 Jun 2026 19:22:03 +0200 Subject: [PATCH 3/4] chore: fix lint --- .golangci.yml | 54 +++++++------- cmd/ask/ask.go | 7 +- cmd/auth/login.go | 2 +- cmd/cronjobs/activities.go | 2 +- cmd/cronjobs/list.go | 2 +- cmd/cronjobs/update.go | 2 +- cmd/deploy/deploy.go | 42 ++++++----- cmd/deployments/helpers.go | 4 +- cmd/deployments/list.go | 4 +- cmd/domains/create.go | 2 +- cmd/domains/list.go | 2 +- cmd/domains/verify.go | 4 +- cmd/env/helpers.go | 4 +- cmd/env/pull.go | 8 +-- cmd/env/push.go | 6 +- cmd/environments/list.go | 4 +- cmd/init/init.go | 39 ++++++---- cmd/oauth/create.go | 15 ++-- cmd/oauth/list.go | 4 +- cmd/projects/add.go | 2 +- cmd/projects/list.go | 2 +- cmd/projects/suspend.go | 2 +- cmd/projects/unsuspend.go | 2 +- cmd/root/root.go | 71 +++++++++++++------ cmd/sandbox/bandwidth.go | 2 + cmd/sandbox/catalog.go | 6 +- cmd/sandbox/create.go | 20 +++--- cmd/sandbox/disk.go | 12 ++-- cmd/sandbox/edit.go | 16 ++--- cmd/sandbox/exec.go | 12 ++-- cmd/sandbox/firewall.go | 2 +- cmd/sandbox/fork.go | 2 +- cmd/sandbox/get.go | 4 +- cmd/sandbox/list.go | 2 +- cmd/sandbox/mutagen_install.go | 29 ++++---- cmd/sandbox/network.go | 7 +- cmd/sandbox/pause.go | 2 +- cmd/sandbox/pull.go | 10 +-- cmd/sandbox/push.go | 12 ++-- cmd/sandbox/resume.go | 2 +- cmd/sandbox/rm.go | 2 +- cmd/sandbox/shell.go | 121 +++++++++++++++++++------------- cmd/sandbox/slider.go | 14 ++-- cmd/sandbox/sync.go | 52 +++++++------- cmd/sandbox/template.go | 27 +++---- cmd/sandbox/tunnel.go | 7 +- cmd/sandbox/wizard.go | 32 +++++---- cmd/templates/use.go | 12 ++-- cmd/upgrade/upgrade.go | 25 +++---- cmd/users/consents_list.go | 4 +- cmd/vms/deploy.go | 18 +++-- cmd/vms/list.go | 2 +- cmd/vms/ssh.go | 12 ++-- go.mod | 2 +- internal/api/client.go | 66 +++++++++++++++-- internal/api/sandbox.go | 28 ++++++-- internal/api/sandbox_client.go | 12 ++-- internal/api/sandbox_types.go | 30 ++++---- internal/config/oauth.go | 2 +- internal/config/token.go | 2 +- internal/installer/installer.go | 6 +- internal/oauth/oauth.go | 27 +++++-- internal/output/render.go | 11 ++- internal/ui/picker.go | 8 ++- internal/ui/shape_picker.go | 17 ++--- internal/updater/updater.go | 4 +- internal/utils/ptr.go | 2 +- 67 files changed, 587 insertions(+), 384 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f1566d3..f220824 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,33 @@ linters: - gosec - copyloopvar - errorlint + settings: + errcheck: + check-type-assertions: true + check-blank: true + govet: + enable-all: true + disable: + - fieldalignment + revive: + rules: + - name: exported + disabled: false + - name: error-return + disabled: false + - name: error-strings + disabled: false + - name: indent-error-flow + disabled: false + gosec: + excludes: + - G304 + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + - gocritic formatters: enable: @@ -29,34 +56,7 @@ formatters: local-prefixes: - github.com/NodeOps-app/createos-cli -linters-settings: - errcheck: - check-type-assertions: true - check-blank: true - govet: - enable-all: true - disable: - - fieldalignment - revive: - rules: - - name: exported - disabled: false - - name: error-return - disabled: false - - name: error-strings - disabled: false - - name: indent-error-flow - disabled: false - gosec: - excludes: - - G304 - issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck - - gocritic max-issues-per-linter: 0 max-same-issues: 0 diff --git a/cmd/ask/ask.go b/cmd/ask/ask.go index 4c6828c..093a6ef 100644 --- a/cmd/ask/ask.go +++ b/cmd/ask/ask.go @@ -64,10 +64,13 @@ func NewAskCommand() *cli.Command { pterm.Info.Println("The 'ask' command uses OpenCode (https://opencode.ai), an open-source AI coding\nassistant, to power the CreateOS AI agent. It lets you manage your infrastructure\nusing natural language right from the terminal.") fmt.Println() - install, _ := pterm.DefaultInteractiveConfirm. + install, err := pterm.DefaultInteractiveConfirm. WithDefaultText("opencode is not installed. Install it now?"). WithDefaultValue(true). Show() + if err != nil { + return err + } if !install { return fmt.Errorf("opencode is required for the ask command\n\n Install it manually:\n curl -fsSL https://opencode.ai/install | bash") } @@ -77,7 +80,7 @@ func NewAskCommand() *cli.Command { installCmd.Stdin = os.Stdin installCmd.Stdout = os.Stdout installCmd.Stderr = os.Stderr - if err := installCmd.Run(); err != nil { + if err = installCmd.Run(); err != nil { return fmt.Errorf("failed to install opencode: %w", err) } diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 548fe41..bf2fb56 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -112,7 +112,7 @@ func loginWithBrowser() error { pterm.Println(pterm.Gray(" " + authURL)) fmt.Println() - if err := internaloauth.OpenBrowser(authURL); err != nil { + if err = internaloauth.OpenBrowser(authURL); err != nil { pterm.Warning.Println("Could not open browser automatically. Please open the URL above.") } else { pterm.Info.Println("Waiting for you to complete login in your browser...") diff --git a/cmd/cronjobs/activities.go b/cmd/cronjobs/activities.go index 57ad9e9..e636c2f 100644 --- a/cmd/cronjobs/activities.go +++ b/cmd/cronjobs/activities.go @@ -71,7 +71,7 @@ func newCronjobsActivitiesCommand() *cli.Command { log, }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/cronjobs/list.go b/cmd/cronjobs/list.go index 818aa4c..ff0b8e9 100644 --- a/cmd/cronjobs/list.go +++ b/cmd/cronjobs/list.go @@ -53,7 +53,7 @@ func newCronjobsListCommand() *cli.Command { cj.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/cronjobs/update.go b/cmd/cronjobs/update.go index 0a6fa37..2468bdd 100644 --- a/cmd/cronjobs/update.go +++ b/cmd/cronjobs/update.go @@ -62,7 +62,7 @@ Examples: // Decode existing settings for defaults in both TTY and non-TTY. var currentSettings api.HTTPCronjobSettings if existing.Settings != nil { - if err := json.Unmarshal(*existing.Settings, ¤tSettings); err != nil { + if err = json.Unmarshal(*existing.Settings, ¤tSettings); err != nil { return fmt.Errorf("could not parse existing cron job settings: %w", err) } } diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go index 5221ff3..8b2fa6d 100644 --- a/cmd/deploy/deploy.go +++ b/cmd/deploy/deploy.go @@ -147,7 +147,10 @@ func NewDeployCommand() *cli.Command { } // Try to auto-detect from git remote - dir, _ := os.Getwd() + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("couldn't determine your current directory — please try again from a valid working directory") + } repoFullName := git.GetRemoteFullName(dir) if repoFullName != "" { for _, p := range activeProjects { @@ -160,10 +163,10 @@ func NewDeployCommand() *cli.Command { } if src.VCSFullName == repoFullName { pterm.Info.Printf("Detected project %s from git remote (%s)\n", p.DisplayName, repoFullName) - useDetected, _ := pterm.DefaultInteractiveConfirm. + confirm := pterm.DefaultInteractiveConfirm. WithDefaultText(fmt.Sprintf("Deploy %s?", p.DisplayName)). - WithDefaultValue(true). - Show() + WithDefaultValue(true) + useDetected, _ := confirm.Show() //nolint:errcheck if useDetected { projectID = p.ID } @@ -281,15 +284,15 @@ func UploadDir(client *api.APIClient, projectID, displayName, dir string) error defer os.Remove(zipFile.Name()) //nolint:errcheck defer zipFile.Close() //nolint:errcheck - spinner, _ := pterm.DefaultSpinner.Start("Packaging files...") + spinner, _ := pterm.DefaultSpinner.Start("Packaging files...") //nolint:errcheck - if err := createZip(zipFile, absDir); err != nil { + if err = createZip(zipFile, absDir); err != nil { spinner.Fail("Packaging failed") return err } - stat, _ := zipFile.Stat() - if stat != nil && stat.Size() > maxZipSize { + stat, statErr := zipFile.Stat() + if statErr == nil && stat.Size() > maxZipSize { spinner.Fail("Package too large") return fmt.Errorf("deployment package is %d MB (max %d MB)\n\n Tip: check that node_modules, .git, and build artifacts are excluded", stat.Size()/(1024*1024), maxZipSize/(1024*1024)) @@ -297,7 +300,7 @@ func UploadDir(client *api.APIClient, projectID, displayName, dir string) error spinner.UpdateText("Uploading...") - if err := zipFile.Close(); err != nil { //nolint:govet + if err = zipFile.Close(); err != nil { return fmt.Errorf("could not flush deployment package: %w", err) } @@ -335,15 +338,15 @@ func deployUpload(c *cli.Context, client *api.APIClient, project *api.Project) e defer os.Remove(zipFile.Name()) //nolint:errcheck defer zipFile.Close() //nolint:errcheck - spinner, _ := pterm.DefaultSpinner.Start("Packaging files...") + spinner, _ := pterm.DefaultSpinner.Start("Packaging files...") //nolint:errcheck - if err := createZip(zipFile, absDir); err != nil { + if err = createZip(zipFile, absDir); err != nil { spinner.Fail("Packaging failed") return err } - stat, _ := zipFile.Stat() - if stat != nil && stat.Size() > maxZipSize { + stat, statErr := zipFile.Stat() + if statErr == nil && stat.Size() > maxZipSize { spinner.Fail("Package too large") return fmt.Errorf("deployment package is %d MB (max %d MB)\n\n Tip: check that node_modules, .git, and build artifacts are excluded", stat.Size()/(1024*1024), maxZipSize/(1024*1024)) @@ -352,7 +355,7 @@ func deployUpload(c *cli.Context, client *api.APIClient, project *api.Project) e spinner.UpdateText("Uploading...") // Close before uploading so the file is flushed - if err := zipFile.Close(); err != nil { //nolint:govet + if err = zipFile.Close(); err != nil { return fmt.Errorf("could not flush deployment package: %w", err) } @@ -523,8 +526,15 @@ func createZip(w io.Writer, srcDir string) error { // Check ignore patterns against basename and full relative path baseName := filepath.Base(relPath) for _, pattern := range ignorePatterns { - matchedBase, _ := filepath.Match(pattern, baseName) - matchedRel, _ := filepath.Match(pattern, filepath.ToSlash(relPath)) + var matchedBase, matchedRel bool + matchedBase, err = filepath.Match(pattern, baseName) + if err != nil { + return fmt.Errorf("invalid ignore pattern %q: %w", pattern, err) + } + matchedRel, err = filepath.Match(pattern, filepath.ToSlash(relPath)) + if err != nil { + return fmt.Errorf("invalid ignore pattern %q: %w", pattern, err) + } if matchedBase || matchedRel { if info.IsDir() { return filepath.SkipDir diff --git a/cmd/deployments/helpers.go b/cmd/deployments/helpers.go index 058837e..fccadb0 100644 --- a/cmd/deployments/helpers.go +++ b/cmd/deployments/helpers.go @@ -82,7 +82,9 @@ func pickDeployment(client *api.APIClient, projectID string, statusFilter []stri }) } fmt.Println() - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + if err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + pterm.Error.Println(err) + } fmt.Println() options := make([]string, len(deployments)) diff --git a/cmd/deployments/list.go b/cmd/deployments/list.go index 72715a6..bddd162 100644 --- a/cmd/deployments/list.go +++ b/cmd/deployments/list.go @@ -50,7 +50,9 @@ func newDeploymentsListCommand() *cli.Command { d.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + pterm.Error.Println(err) + } }) return nil }, diff --git a/cmd/domains/create.go b/cmd/domains/create.go index c9e49af..6f5e9a0 100644 --- a/cmd/domains/create.go +++ b/cmd/domains/create.go @@ -82,6 +82,6 @@ func printDNSRecords(d api.Domain) { tableData = append(tableData, []string{"TXT", txt.Name + "." + d.Name, txt.Value}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck fmt.Println() } diff --git a/cmd/domains/list.go b/cmd/domains/list.go index 3208557..3915547 100644 --- a/cmd/domains/list.go +++ b/cmd/domains/list.go @@ -70,7 +70,7 @@ func newDomainsListCommand() *cli.Command { } tableData = append(tableData, []string{d.ID, d.Name, env, icon + " " + d.Status, msg}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/domains/verify.go b/cmd/domains/verify.go index 2a70f3b..20175f3 100644 --- a/cmd/domains/verify.go +++ b/cmd/domains/verify.go @@ -33,7 +33,7 @@ func newDomainsVerifyCommand() *cli.Command { return err } - _ = client.RefreshDomain(projectID, domainID) + _ = client.RefreshDomain(projectID, domainID) //nolint:errcheck domain, err := findDomain(client, projectID, domainID) if err != nil { @@ -78,7 +78,7 @@ func newDomainsVerifyCommand() *cli.Command { return nil } - _ = client.RefreshDomain(projectID, domainID) + _ = client.RefreshDomain(projectID, domainID) //nolint:errcheck domains, err := client.ListDomains(projectID) if err != nil { continue diff --git a/cmd/env/helpers.go b/cmd/env/helpers.go index f96838c..e6bea9d 100644 --- a/cmd/env/helpers.go +++ b/cmd/env/helpers.go @@ -108,8 +108,8 @@ func ensureEnvGitignored() { if err != nil { return } - defer f.Close() //nolint:errcheck - _, _ = f.WriteString(content) + defer f.Close() //nolint:errcheck + _, _ = f.WriteString(content) //nolint:errcheck pterm.Println(pterm.Gray(" Added .env.* to .gitignore")) } diff --git a/cmd/env/pull.go b/cmd/env/pull.go index b09d872..789bbde 100644 --- a/cmd/env/pull.go +++ b/cmd/env/pull.go @@ -46,14 +46,14 @@ func newEnvPullCommand() *cli.Command { // Check if file exists if !c.Bool("force") { - if _, err := os.Stat(filePath); err == nil { + if _, err = os.Stat(filePath); err == nil { if !terminal.IsInteractive() { return fmt.Errorf("%s already exists — use --force to overwrite", filePath) } - result, _ := pterm.DefaultInteractiveConfirm. + prompt := pterm.DefaultInteractiveConfirm. WithDefaultText(fmt.Sprintf("%s already exists. Overwrite?", filePath)). - WithDefaultValue(false). - Show() + WithDefaultValue(false) + result, _ := prompt.Show() //nolint:errcheck //nolint:errcheck if !result { return nil } diff --git a/cmd/env/push.go b/cmd/env/push.go index dbb1dc7..a2c713a 100644 --- a/cmd/env/push.go +++ b/cmd/env/push.go @@ -63,10 +63,10 @@ func newEnvPushCommand() *cli.Command { fmt.Printf(" %s\n", k) } fmt.Println() - result, _ := pterm.DefaultInteractiveConfirm. + prompt := pterm.DefaultInteractiveConfirm. WithDefaultText("Continue?"). - WithDefaultValue(true). - Show() + WithDefaultValue(true) + result, _ := prompt.Show() //nolint:errcheck //nolint:errcheck if !result { return nil } diff --git a/cmd/environments/list.go b/cmd/environments/list.go index 87d7643..4a64d14 100644 --- a/cmd/environments/list.go +++ b/cmd/environments/list.go @@ -62,7 +62,9 @@ func newEnvironmentsListCommand() *cli.Command { env.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + pterm.Error.Println(err) + } fmt.Println() }) return nil diff --git a/cmd/init/init.go b/cmd/init/init.go index 4fc56d2..b801adf 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -37,17 +37,22 @@ func NewInitCommand() *cli.Command { return fmt.Errorf("could not determine current directory: %w", err) } - // Check if already linked - existing, _ := config.LoadProjectConfig(dir) - if existing != nil { + // Check if already linked. A load error means the directory + // isn't linked yet (or the config is unreadable), so we treat + // it as "not linked" and continue. + existing, loadErr := config.LoadProjectConfig(dir) + if loadErr == nil && existing != nil { pterm.Warning.Printf("This directory is already linked to project %s\n", existing.ProjectName) if !terminal.IsInteractive() { return fmt.Errorf("directory already linked — use --project to re-link non-interactively") } - result, _ := pterm.DefaultInteractiveConfirm. + result, confirmErr := pterm.DefaultInteractiveConfirm. WithDefaultText("Overwrite existing link?"). WithDefaultValue(false). Show() + if confirmErr != nil { + return fmt.Errorf("could not read confirmation: %w", confirmErr) + } if !result { return nil } @@ -57,9 +62,9 @@ func NewInitCommand() *cli.Command { if pid := c.String("project"); pid != "" { // Non-interactive: validate the project exists - project, err := client.GetProject(pid) - if err != nil { - return err + project, getErr := client.GetProject(pid) + if getErr != nil { + return getErr } projectID = project.ID projectName = project.DisplayName @@ -68,7 +73,8 @@ func NewInitCommand() *cli.Command { return fmt.Errorf("no project specified — use --project to link non-interactively\n\n To see your projects, run:\n createos projects list") } // Interactive: list projects and let user pick - projects, err := client.ListProjects() + var projects []api.Project + projects, err = client.ListProjects() if err != nil { return err } @@ -99,15 +105,18 @@ func NewInitCommand() *cli.Command { continue } var src api.VCSSource - if err := json.Unmarshal(p.Source, &src); err != nil { + if unmarshalErr := json.Unmarshal(p.Source, &src); unmarshalErr != nil { continue } if src.VCSFullName == repoFullName { pterm.Info.Printf("Detected project %s from git remote (%s)\n", p.DisplayName, repoFullName) - useDetected, _ := pterm.DefaultInteractiveConfirm. + useDetected, confirmErr := pterm.DefaultInteractiveConfirm. WithDefaultText(fmt.Sprintf("Link to %s?", p.DisplayName)). WithDefaultValue(true). Show() + if confirmErr != nil { + return fmt.Errorf("could not read confirmation: %w", confirmErr) + } if useDetected { projectID = p.ID projectName = p.DisplayName @@ -128,7 +137,8 @@ func NewInitCommand() *cli.Command { options[i] = fmt.Sprintf("%s (%s)%s", p.DisplayName, p.ID, desc) } - selected, err := pterm.DefaultInteractiveSelect. + var selected string + selected, err = pterm.DefaultInteractiveSelect. WithDefaultText("Select a project to link"). WithOptions(options). Show() @@ -183,8 +193,11 @@ func NewInitCommand() *cli.Command { return fmt.Errorf("could not save project config: %w", err) } - // Add to .gitignore - _ = config.EnsureGitignore(dir) + // Add to .gitignore (best effort — a failure here shouldn't + // undo the successful link). + if gitignoreErr := config.EnsureGitignore(dir); gitignoreErr != nil { + pterm.Warning.Printf("Could not update .gitignore: %v\n", gitignoreErr) + } pterm.Success.Printf("Linked to %s\n", projectName) return nil diff --git a/cmd/oauth/create.go b/cmd/oauth/create.go index d20fadc..80bc2d7 100644 --- a/cmd/oauth/create.go +++ b/cmd/oauth/create.go @@ -92,7 +92,8 @@ func newCreateCommand() *cli.Command { return err } - confirm, err := pterm.DefaultInteractiveConfirm. + var confirm bool + confirm, err = pterm.DefaultInteractiveConfirm. WithDefaultText("Create this OAuth client now?"). WithDefaultValue(true). Show() @@ -105,7 +106,7 @@ func newCreateCommand() *cli.Command { } } else { name = strings.TrimSpace(c.String("name")) - if err := validateClientName(name); err != nil { + if err = validateClientName(name); err != nil { return fmt.Errorf("--name: %w", err) } @@ -114,7 +115,7 @@ func newCreateCommand() *cli.Command { return fmt.Errorf("at least one --redirect-uri is required") } for _, u := range redirectURIs { - if err := validateURI(u); err != nil { + if err = validateURI(u); err != nil { return fmt.Errorf("--redirect-uri %q: %w", u, err) } } @@ -122,19 +123,19 @@ func newCreateCommand() *cli.Command { public = c.Bool("public") clientURI = c.String("app-url") - if err := validateURI(clientURI); err != nil { + if err = validateURI(clientURI); err != nil { return fmt.Errorf("--app-url: %w", err) } policyURI = c.String("policy-url") - if err := validateURI(policyURI); err != nil { + if err = validateURI(policyURI); err != nil { return fmt.Errorf("--policy-url: %w", err) } tosURI = c.String("tos-url") - if err := validateURI(tosURI); err != nil { + if err = validateURI(tosURI); err != nil { return fmt.Errorf("--tos-url: %w", err) } logoURI = c.String("logo-url") - if err := validateURI(logoURI); err != nil { + if err = validateURI(logoURI); err != nil { return fmt.Errorf("--logo-url: %w", err) } } diff --git a/cmd/oauth/list.go b/cmd/oauth/list.go index 7d0a5a6..22c589f 100644 --- a/cmd/oauth/list.go +++ b/cmd/oauth/list.go @@ -40,7 +40,9 @@ func newListCommand() *cli.Command { item.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + pterm.Error.Println(err) + } fmt.Println() }) return nil diff --git a/cmd/projects/add.go b/cmd/projects/add.go index f988c24..a65c90a 100644 --- a/cmd/projects/add.go +++ b/cmd/projects/add.go @@ -338,7 +338,7 @@ all required flags: if err := config.SaveProjectConfig(dir, cfg); err != nil { pterm.Warning.Printf("Could not link directory: %s\n", err) } else { - _ = config.EnsureGitignore(dir) + _ = config.EnsureGitignore(dir) //nolint:errcheck pterm.Success.Printf("Linked to %s\n", displayName) } } diff --git a/cmd/projects/list.go b/cmd/projects/list.go index 6343cbb..5fd4ea3 100644 --- a/cmd/projects/list.go +++ b/cmd/projects/list.go @@ -43,7 +43,7 @@ func newListCommand() *cli.Command { p.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck }) return nil }, diff --git a/cmd/projects/suspend.go b/cmd/projects/suspend.go index 01cca58..c9a0fb0 100644 --- a/cmd/projects/suspend.go +++ b/cmd/projects/suspend.go @@ -28,7 +28,7 @@ func newSuspendCommand() *cli.Command { // Try linked project config if projectID == "" { - cfg, _ := config.FindProjectConfig() + cfg, _ := config.FindProjectConfig() //nolint:errcheck if cfg != nil { projectID = cfg.ProjectID } diff --git a/cmd/projects/unsuspend.go b/cmd/projects/unsuspend.go index dc03203..e4b71f2 100644 --- a/cmd/projects/unsuspend.go +++ b/cmd/projects/unsuspend.go @@ -28,7 +28,7 @@ func newUnsuspendCommand() *cli.Command { // Try linked project config if projectID == "" { - cfg, _ := config.FindProjectConfig() + cfg, _ := config.FindProjectConfig() //nolint:errcheck if cfg != nil { projectID = cfg.ProjectID } diff --git a/cmd/root/root.go b/cmd/root/root.go index 03bb09e..6581de9 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -107,34 +107,27 @@ func NewApp() *cli.App { return fmt.Errorf("could not load your session: %w", err) } if session != nil { - // Auto-refresh if expired + // Pre-flight refresh when the token is expired (or + // about to be). A hard failure here means the refresh + // token is dead — the user must sign in again. if config.IsTokenExpired(session) { - tokenEndpoint := session.TokenEndpoint - if tokenEndpoint == "" { - tokenEndpoint = config.OAuthIssuerURL + "/oauth2/token" - } - refreshed, err := internaloauth.RefreshTokens( - tokenEndpoint, - config.OAuthClientID, - session.RefreshToken, - ) - if err != nil { + if _, err := refreshOAuthSession(session); err != nil { return fmt.Errorf("your session has expired and could not be renewed — run 'createos login' to sign in again") } - session.AccessToken = refreshed.AccessToken - if refreshed.RefreshToken != "" { - session.RefreshToken = refreshed.RefreshToken - } - if refreshed.ExpiresIn > 0 { - session.ExpiresAt = time.Now().Unix() + int64(refreshed.ExpiresIn) - } - if err := config.SaveOAuthSession(*session); err != nil { - return fmt.Errorf("could not save refreshed session: %w", err) - } } - client := api.NewClientWithAccessToken(session.AccessToken, c.String("api-url"), c.Bool("debug")) + // Reactive refresher: if the server rejects the token + // mid-command with a 401 (revoked server-side, or our + // clock was wrong), the clients refresh and retry once. + refresher := func() (string, error) { + return refreshOAuthSession(session) + } + client := api.NewClientWithAccessToken(session.AccessToken, c.String("api-url"), c.Bool("debug"), refresher) c.App.Metadata[api.ClientKey] = &client - sandboxClient := api.NewSandboxClientWithAccessToken(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug")) + // Sandbox API (fc-spawn) reuses the same OAuth access + // token, but it must go in the X-Access-Token header — + // fc-spawn rejects a JWT sent as X-Api-Key with + // "invalid api key". + sandboxClient := api.NewSandboxClientWithAccessToken(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug"), refresher) c.App.Metadata[api.SandboxClientKey] = &sandboxClient return nil } @@ -219,3 +212,35 @@ func NewApp() *cli.App { return app } + +// refreshOAuthSession exchanges the session's refresh token for a new +// access token, updates the session in place, and persists it to +// ~/.createos/.oauth. It returns the new access token. This is shared by +// the pre-flight (expiry-based) refresh in the Before hook and the +// reactive on-401 retry wired into the API clients, so both paths rotate +// and store tokens identically. +func refreshOAuthSession(session *config.OAuthSession) (string, error) { + tokenEndpoint := session.TokenEndpoint + if tokenEndpoint == "" { + tokenEndpoint = config.OAuthIssuerURL + "/oauth2/token" + } + refreshed, err := internaloauth.RefreshTokens( + tokenEndpoint, + config.OAuthClientID, + session.RefreshToken, + ) + if err != nil { + return "", err + } + session.AccessToken = refreshed.AccessToken + if refreshed.RefreshToken != "" { + session.RefreshToken = refreshed.RefreshToken + } + if refreshed.ExpiresIn > 0 { + session.ExpiresAt = time.Now().Unix() + int64(refreshed.ExpiresIn) + } + if err := config.SaveOAuthSession(*session); err != nil { + return "", err + } + return session.AccessToken, nil +} diff --git a/cmd/sandbox/bandwidth.go b/cmd/sandbox/bandwidth.go index 578d839..909cab2 100644 --- a/cmd/sandbox/bandwidth.go +++ b/cmd/sandbox/bandwidth.go @@ -10,6 +10,8 @@ import ( // — 1 KB = 1000 bytes) plus binary suffixes (KiB/MiB/GiB/TiB). Pure // digits are treated as raw bytes. Used by `sandbox edit`'s bandwidth // top-up step and any future caller that needs human size parsing. +// +//nolint:unused // reserved for the non-interactive `bandwidth-recharge ` path func parseSizeBytes(in string) (int64, error) { s := strings.TrimSpace(strings.ToUpper(in)) if s == "" { diff --git a/cmd/sandbox/catalog.go b/cmd/sandbox/catalog.go index f59cb33..5713b9d 100644 --- a/cmd/sandbox/catalog.go +++ b/cmd/sandbox/catalog.go @@ -42,7 +42,7 @@ func runShapes(c *cli.Context) error { fmt.Sprintf("%d MB", s.DefaultDiskMib), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck pterm.Println() pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --shape ")) }) @@ -93,7 +93,7 @@ func runRootfs(c *cli.Context) error { } table = append(table, []string{e.Name, e.Description, status}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck } else { table := pterm.TableData{{"Name", "Default"}} for _, name := range cat.Rootfs { @@ -103,7 +103,7 @@ func runRootfs(c *cli.Context) error { } table = append(table, []string{name, def}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck } pterm.Println() pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --rootfs ")) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 069ee5e..413c884 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -105,15 +105,15 @@ func runCreate(c *cli.Context) error { // remembering every flag. Headless callers continue to get the // "use --shape" error. if shape == "" { - w, err := runCreateWizard(c, client, wizardSeed{ + w, werr := runCreateWizard(c, client, wizardSeed{ name: name, rootfs: rootfs, ingress: ingress, netIDs: netIDs, sshKeys: sshKeys, }) - if err != nil { - return err + if werr != nil { + return werr } if w == nil { // User cancelled mid-wizard — exit quietly, no error. @@ -143,8 +143,8 @@ func runCreate(c *cli.Context) error { IngressEnabled: ingress, } - if envs, err := parseEnvFlags(c.StringSlice("env")); err != nil { - return err + if envs, envErr := parseEnvFlags(c.StringSlice("env")); envErr != nil { + return envErr } else if len(envs) > 0 { req.Envs = envs } @@ -165,14 +165,14 @@ func runCreate(c *cli.Context) error { } if rawDisks := c.StringSlice("disk"); len(rawDisks) > 0 { - disks, err := parseDiskFlags(rawDisks) - if err != nil { - return err + disks, derr := parseDiskFlags(rawDisks) + if derr != nil { + return derr } req.Disks = disks } - spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") + spinner, _ := pterm.DefaultSpinner.Start("Creating sandbox…") //nolint:errcheck resp, err := client.CreateSandbox(c.Context, req) if err != nil { spinner.Fail("Could not create sandbox") @@ -239,7 +239,7 @@ func readSSHPubkeys(paths []string) ([]string, error) { if p == "" { continue } - b, err := os.ReadFile(p) + b, err := os.ReadFile(p) // #nosec G304 -- p is a user-supplied SSH public-key path if err != nil { return nil, fmt.Errorf("could not read SSH public key %s: %w", p, err) } diff --git a/cmd/sandbox/disk.go b/cmd/sandbox/disk.go index 2d70ee1..758ef1c 100644 --- a/cmd/sandbox/disk.go +++ b/cmd/sandbox/disk.go @@ -123,7 +123,7 @@ func runDiskCreate(c *cli.Context) error { return fmt.Errorf("missing required values\n\n Need: , --bucket, --endpoint, --access-key, --secret-key\n Optional: --region, --path-style") } - spinner, _ := pterm.DefaultSpinner.Start("Checking the bucket…") + spinner, _ := pterm.DefaultSpinner.Start("Checking the bucket…") //nolint:errcheck d, err := client.CreateDisk(c.Context, api.DiskCreateReq{ Name: name, Kind: "s3", @@ -181,7 +181,7 @@ func runDiskList(c *cli.Context) error { d.CreatedAt.Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck }) return nil } @@ -444,7 +444,8 @@ func runDiskAttach(c *cli.Context) error { if !tty { return fmt.Errorf("usage: createos sandbox disk attach ") } - picked, err := pickDisk(c, client, "Attach which disk?") + var picked string + picked, err = pickDisk(c, client, "Attach which disk?") if err != nil { return err } @@ -459,7 +460,8 @@ func runDiskAttach(c *cli.Context) error { if !tty { return fmt.Errorf("usage: createos sandbox disk attach ") } - v, err := pterm.DefaultInteractiveTextInput. + var v string + v, err = pterm.DefaultInteractiveTextInput. WithDefaultText("Where in the sandbox should it mount (absolute path, e.g. /mnt/data)"). WithDefaultValue("/mnt/" + diskRef). Show() @@ -472,7 +474,7 @@ func runDiskAttach(c *cli.Context) error { return fmt.Errorf("mount path must be absolute (start with '/'), got %q", mountPath) } - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Attaching %s → %s:%s", diskRef, refLabel(sandboxRef, sandboxID), mountPath)) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Attaching %s → %s:%s", diskRef, refLabel(sandboxRef, sandboxID), mountPath)) //nolint:errcheck err = client.AttachDisk(c.Context, sandboxID, api.DiskAttachReq{ DiskID: diskRef, MountPath: mountPath, diff --git a/cmd/sandbox/edit.go b/cmd/sandbox/edit.go index 29f81cc..330365a 100644 --- a/cmd/sandbox/edit.go +++ b/cmd/sandbox/edit.go @@ -13,13 +13,13 @@ import ( // newEditCommand returns the `sandbox edit` command. Two ways to use: // -// 1) Flag form (script-friendly): -// createos sandbox edit --ingress on|off -// createos sandbox edit --add-ssh-key ~/.ssh/id_ed25519.pub +// 1. Flag form (script-friendly): +// createos sandbox edit --ingress on|off +// createos sandbox edit --add-ssh-key ~/.ssh/id_ed25519.pub // -// 2) Interactive (TTY, no flags): -// createos sandbox edit -// → menu: toggle public URL / add SSH key / cancel +// 2. Interactive (TTY, no flags): +// createos sandbox edit +// → menu: toggle public URL / add SSH key / cancel // // SSH-key removal is not supported by the server today — once a key is // on a sandbox you cannot retract it without destroying the sandbox. @@ -152,7 +152,7 @@ func runEditMenu(c *cli.Context, client *api.SandboxClient, label, id string) er } // Bandwidth is on a sibling endpoint. Best-effort — a stale/missing // counter shouldn't block the rest of the edit menu. - bw, _ := client.GetBandwidth(c.Context, id) + bw, _ := client.GetBandwidth(c.Context, id) //nolint:errcheck fmt.Println() pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" Editing %s", refLabel(label, id)) @@ -273,7 +273,7 @@ func applyIngressFlag(c *cli.Context, client *api.SandboxClient, label, id, valu case "off", "false", "no", "disable": target = false default: - return fmt.Errorf("--ingress %q is not a value I understand. Use `on` or `off`.", value) + return fmt.Errorf("--ingress %q is not a value I understand — use `on` or `off`", value) } updated, err := client.SetSandboxIngress(c.Context, id, target) if err != nil { diff --git a/cmd/sandbox/exec.go b/cmd/sandbox/exec.go index 702d845..6adfbb9 100644 --- a/cmd/sandbox/exec.go +++ b/cmd/sandbox/exec.go @@ -151,9 +151,9 @@ func runExecStream(c *cli.Context, client *api.SandboxClient, id string, req api exit, err := client.ExecSandboxStream(c.Context, id, req, func(ev api.SandboxExecStreamEvent) { switch { case ev.Stdout != "": - _, _ = io.WriteString(os.Stdout, ev.Stdout) + _, _ = io.WriteString(os.Stdout, ev.Stdout) //nolint:errcheck case ev.Stderr != "": - _, _ = io.WriteString(os.Stderr, ev.Stderr) + _, _ = io.WriteString(os.Stderr, ev.Stderr) //nolint:errcheck case ev.Error != "": pterm.Error.Println(ev.Error) } @@ -174,10 +174,10 @@ func runExecStream(c *cli.Context, client *api.SandboxClient, id string, req api // // Both forms work: // -// createos sandbox exec my-box -- ls -la # explicit separator -// createos sandbox exec my-box ls -la # implicit (first token = ref) -// createos sandbox exec -- ls -la # no ref, picker on TTY -// createos sandbox exec # nothing — picker + prompt +// createos sandbox exec my-box -- ls -la # explicit separator +// createos sandbox exec my-box ls -la # implicit (first token = ref) +// createos sandbox exec -- ls -la # no ref, picker on TTY +// createos sandbox exec # nothing — picker + prompt // // urfave/cli v2 strips a LEADING `--` (it interprets that as // "end-of-flags" and consumes the token). To distinguish diff --git a/cmd/sandbox/firewall.go b/cmd/sandbox/firewall.go index 6443157..d542842 100644 --- a/cmd/sandbox/firewall.go +++ b/cmd/sandbox/firewall.go @@ -106,7 +106,7 @@ func runFirewallSet(c *cli.Context) error { fmt.Println("Cancelled.") return nil } - current, _ := client.GetEgress(c.Context, pickedID) + current, _ := client.GetEgress(c.Context, pickedID) //nolint:errcheck prefill := strings.Join(current, ", ") if len(current) == 0 { pterm.Println(pterm.Gray(" Firewall is currently open. Enter destinations to lock it down, or leave empty to cancel.")) diff --git a/cmd/sandbox/fork.go b/cmd/sandbox/fork.go index 6cfe59a..458f079 100644 --- a/cmd/sandbox/fork.go +++ b/cmd/sandbox/fork.go @@ -80,7 +80,7 @@ func runForkByID(c *cli.Context, client *api.SandboxClient, ref, srcID string) e req.Egress = egress } - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Forking %s…", refLabel(ref, srcID))) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Forking %s…", refLabel(ref, srcID))) //nolint:errcheck view, err := client.ForkSandbox(c.Context, srcID, req) if err != nil { spinner.Fail("Fork failed") diff --git a/cmd/sandbox/get.go b/cmd/sandbox/get.go index 21d1774..b94be0f 100644 --- a/cmd/sandbox/get.go +++ b/cmd/sandbox/get.go @@ -37,7 +37,7 @@ func runGet(c *cli.Context) error { if !terminal.IsInteractive() { return fmt.Errorf("please provide a sandbox ID or name\n\n To see your sandboxes, run:\n createos sandbox list") } - pickedID, label, err := pickByStatus(c, client, "Show details for which sandbox?", "") + pickedID, _, err := pickByStatus(c, client, "Show details for which sandbox?", "") if err != nil { return err } @@ -45,7 +45,7 @@ func runGet(c *cli.Context) error { fmt.Println("Cancelled.") return nil } - id, ref = pickedID, label + id = pickedID } else { resolved, err := resolveSandboxRef(c.Context, client, ref) if err != nil { diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index 4a26052..5a815c0 100644 --- a/cmd/sandbox/list.go +++ b/cmd/sandbox/list.go @@ -120,7 +120,7 @@ func runList(c *cli.Context) error { r.CreatedAt.Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck }) return nil } diff --git a/cmd/sandbox/mutagen_install.go b/cmd/sandbox/mutagen_install.go index e2068c5..4b72185 100644 --- a/cmd/sandbox/mutagen_install.go +++ b/cmd/sandbox/mutagen_install.go @@ -8,6 +8,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -34,12 +35,12 @@ const mutagenVersion = "v0.18.1" // // Bump mutagenVersion → also refresh these. var mutagenSHA256 = map[string]string{ - "mutagen_linux_amd64_v0.18.1.tar.gz": "7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f", - "mutagen_linux_arm64_v0.18.1.tar.gz": "bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a", - "mutagen_darwin_amd64_v0.18.1.tar.gz": "7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf", - "mutagen_darwin_arm64_v0.18.1.tar.gz": "6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2", - "mutagen_windows_amd64_v0.18.1.zip": "76f8223d5e6b607efdd9516473669ae5492e4f142887352d59bc6934d1f07a2d", - "mutagen_windows_arm64_v0.18.1.zip": "d0dd95b60f6077f0c02baee3128f754c1507bc4abfa63ae0bcae12e01a3d45f1", + "mutagen_linux_amd64_v0.18.1.tar.gz": "7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f", + "mutagen_linux_arm64_v0.18.1.tar.gz": "bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a", + "mutagen_darwin_amd64_v0.18.1.tar.gz": "7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf", + "mutagen_darwin_arm64_v0.18.1.tar.gz": "6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2", + "mutagen_windows_amd64_v0.18.1.zip": "76f8223d5e6b607efdd9516473669ae5492e4f142887352d59bc6934d1f07a2d", + "mutagen_windows_arm64_v0.18.1.zip": "d0dd95b60f6077f0c02baee3128f754c1507bc4abfa63ae0bcae12e01a3d45f1", } // ensureMutagen returns an absolute path to a working `mutagen` @@ -66,10 +67,10 @@ func ensureMutagen() (string, error) { return "", err } target := filepath.Join(dir, mutagenBinaryName()) - if _, err := os.Stat(target); err == nil { + if _, err = os.Stat(target); err == nil { return target, nil } - if err := os.MkdirAll(dir, 0o755); err != nil { + if err = os.MkdirAll(dir, 0o750); err != nil { return "", fmt.Errorf("create mutagen cache dir: %w", err) } url, ext, err := mutagenReleaseURL() @@ -166,7 +167,7 @@ func downloadAndExtractMutagen(url, ext, target string) error { if err != nil { return fmt.Errorf("fetch %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() //nolint:errcheck if resp.StatusCode != http.StatusOK { return fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode) } @@ -221,11 +222,11 @@ func downloadAndExtractMutagen(url, ext, target string) error { if len(bundle) == 0 { return fmt.Errorf("%s not found inside archive at %s", mutagenAgentBundleName, url) } - if err := os.WriteFile(target, bin, 0o755); err != nil { + if err := os.WriteFile(target, bin, 0o755); err != nil { // #nosec G306 -- mutagen agent binary must be executable return fmt.Errorf("write %s: %w", target, err) } bundlePath := filepath.Join(filepath.Dir(target), mutagenAgentBundleName) - if err := os.WriteFile(bundlePath, bundle, 0o644); err != nil { + if err := os.WriteFile(bundlePath, bundle, 0o600); err != nil { return fmt.Errorf("write %s: %w", bundlePath, err) } return nil @@ -316,11 +317,11 @@ func extractMutagenFromTarGz(blob []byte, binName, bundleName string) (bin, bund if err != nil { return nil, nil, fmt.Errorf("gzip open: %w", err) } - defer gz.Close() + defer func() { _ = gz.Close() }() //nolint:errcheck tr := tar.NewReader(gz) for { h, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { return bin, bundle, nil } if err != nil { @@ -366,7 +367,7 @@ func extractMutagenFromZip(blob []byte, binName, bundleName string) (bin, bundle return nil, nil, fmt.Errorf("zip entry open: %w", err) } buf, err := io.ReadAll(rc) - rc.Close() + _ = rc.Close() //nolint:errcheck if err != nil { return nil, nil, fmt.Errorf("read %s: %w", name, err) } diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go index 13fcc4a..75d4fce 100644 --- a/cmd/sandbox/network.go +++ b/cmd/sandbox/network.go @@ -108,7 +108,7 @@ func runNetworkList(c *cli.Context) error { n.CreatedAt.Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck }) return nil } @@ -166,7 +166,8 @@ func runNetworkShow(c *cli.Context) error { if len(n.Members) > 0 { pterm.Println() pterm.Println(pterm.Gray(" Attached sandboxes:")) - table := pterm.TableData{{"Sandbox", "Name", "Status", "IP", "Reachable as"}} + table := make(pterm.TableData, 0, 1+len(n.Members)) + table = append(table, []string{"Sandbox", "Name", "Status", "IP", "Reachable as"}) for _, m := range n.Members { reachable := m.SandboxID if m.Name != "" { @@ -174,7 +175,7 @@ func runNetworkShow(c *cli.Context) error { } table = append(table, []string{m.SandboxID, m.Name, m.Status, m.IP, reachable}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck pterm.Println(pterm.Gray(" Tip: inside any of these sandboxes you can `ping ` or curl by name.")) } }) diff --git a/cmd/sandbox/pause.go b/cmd/sandbox/pause.go index 06ab047..3c8f120 100644 --- a/cmd/sandbox/pause.go +++ b/cmd/sandbox/pause.go @@ -56,7 +56,7 @@ func runPauseByID(c *cli.Context, client *api.SandboxClient, ref, id string) err if _, err := client.PauseSandbox(c.Context, id); err != nil { return err } - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Pausing %s…", refLabel(ref, id))) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Pausing %s…", refLabel(ref, id))) //nolint:errcheck sb, err := waitForStatus(c.Context, client, id, "paused") if err != nil { spinner.Fail("Pause did not complete") diff --git a/cmd/sandbox/pull.go b/cmd/sandbox/pull.go index 6ef561d..28ca621 100644 --- a/cmd/sandbox/pull.go +++ b/cmd/sandbox/pull.go @@ -51,21 +51,21 @@ func runPull(c *cli.Context) error { // "-" writes to stdout. Anything else is a real file we create. if local == "-" { - _, err := client.DownloadFile(c.Context, id, remote, os.Stdout) + _, err = client.DownloadFile(c.Context, id, remote, os.Stdout) return err } - f, err := os.Create(local) + f, err := os.Create(local) // #nosec G304 -- local is a user-supplied destination path if err != nil { return fmt.Errorf("could not create %s: %w", local, err) } - defer f.Close() + defer func() { _ = f.Close() }() //nolint:errcheck - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading %s:%s → %s", refLabel(ref, id), remote, local)) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading %s:%s → %s", refLabel(ref, id), remote, local)) //nolint:errcheck n, err := client.DownloadFile(c.Context, id, remote, f) if err != nil { spinner.Fail("Download failed") - _ = os.Remove(local) // don't leave a half-written file behind + _ = os.Remove(local) //nolint:errcheck // don't leave a half-written file behind return err } spinner.Success(fmt.Sprintf("Downloaded %s:%s → %s (%s)", refLabel(ref, id), remote, local, humanBytes(n))) diff --git a/cmd/sandbox/push.go b/cmd/sandbox/push.go index b260cb7..a70802f 100644 --- a/cmd/sandbox/push.go +++ b/cmd/sandbox/push.go @@ -54,7 +54,7 @@ func runPush(c *cli.Context) error { // Open the source: a real file (we know its size for Content-Length) // or stdin ("-") for piped uploads. var ( - src interface { + src interface { Read(p []byte) (int, error) } size int64 @@ -67,17 +67,17 @@ func runPush(c *cli.Context) error { label = "(stdin)" closer = func() error { return nil } } else { - f, err := os.Open(local) + f, err := os.Open(local) // #nosec G304 -- local is a user-supplied source path if err != nil { return fmt.Errorf("could not open %s: %w", local, err) } info, err := f.Stat() if err != nil { - _ = f.Close() + _ = f.Close() //nolint:errcheck return fmt.Errorf("could not stat %s: %w", local, err) } if info.IsDir() { - _ = f.Close() + _ = f.Close() //nolint:errcheck return fmt.Errorf("%s is a directory — push handles single files. Tar it first:\n tar -c %s | createos sandbox push %s - /tmp/bundle.tar", local, local, ref) } src = f @@ -85,9 +85,9 @@ func runPush(c *cli.Context) error { label = local closer = f.Close } - defer func() { _ = closer() }() + defer func() { _ = closer() }() //nolint:errcheck - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Uploading %s → %s:%s", label, refLabel(ref, id), remote)) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Uploading %s → %s:%s", label, refLabel(ref, id), remote)) //nolint:errcheck if err := client.UploadFile(c.Context, id, remote, src, size); err != nil { spinner.Fail("Upload failed") return err diff --git a/cmd/sandbox/resume.go b/cmd/sandbox/resume.go index 81b6c68..b9cc866 100644 --- a/cmd/sandbox/resume.go +++ b/cmd/sandbox/resume.go @@ -55,7 +55,7 @@ func runResumeByID(c *cli.Context, client *api.SandboxClient, ref, id string) er if _, err := client.ResumeSandbox(c.Context, id); err != nil { return err } - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Resuming %s…", refLabel(ref, id))) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Resuming %s…", refLabel(ref, id))) //nolint:errcheck sb, err := waitForStatus(c.Context, client, id, "running") if err != nil { spinner.Fail("Resume did not complete") diff --git a/cmd/sandbox/rm.go b/cmd/sandbox/rm.go index d0d8cf0..50facbf 100644 --- a/cmd/sandbox/rm.go +++ b/cmd/sandbox/rm.go @@ -164,7 +164,7 @@ func pickSandboxesForDelete(c *cli.Context, client *api.SandboxClient) ([]string options := make([]string, 0, len(rows)) idByOption := make(map[string]string, len(rows)) for _, r := range rows { - label := r.ID + var label string if r.Name != nil && *r.Name != "" { label = *r.Name + " " + r.ID + " " + r.Status } else { diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go index a098020..820c04b 100644 --- a/cmd/sandbox/shell.go +++ b/cmd/sandbox/shell.go @@ -5,6 +5,7 @@ import ( "context" "crypto/tls" "encoding/binary" + "errors" "fmt" "io" "net" @@ -118,7 +119,7 @@ func runShellSSH(c *cli.Context, client *api.SandboxClient, id, ref string) erro if err != nil { return err } - pubBytes, err := os.ReadFile(pubPath) + pubBytes, err := os.ReadFile(pubPath) // #nosec G304 -- pubPath is the user's own SSH public key, chosen via --identity if err != nil { return fmt.Errorf("could not read public key %s: %w", pubPath, err) } @@ -132,7 +133,7 @@ func runShellSSH(c *cli.Context, client *api.SandboxClient, id, ref string) erro // file API. sshd refuses keys unless ~/.ssh is 0700 and the // file is 0600 — we chmod in the next step. authPath := authorizedKeysPath(user) - if err := client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { + if err = client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { return fmt.Errorf("could not install your SSH key: %w", err) } @@ -170,24 +171,41 @@ fi return fmt.Errorf("sshd prep failed: %s", strings.TrimSpace(resp.Result.Stderr)) } - // 3. Open a local TCP listener that bridges every accepted - // connection through the control plane to the sandbox's :22. + // 3. Open the tunnel and hand off to system ssh. Kept in a helper + // so its deferred cleanup (tunnel + context) runs before we may + // os.Exit with ssh's exit code below — os.Exit skips defers. + exitCode, err := sshHandoff(c, id, ref, privPath, user) + if err != nil { + return err + } + if exitCode != 0 { + os.Exit(exitCode) + } + return nil +} + +// sshHandoff opens a local TCP bridge to the sandbox's :22 and hands +// control to system 'ssh' for a real PTY, returning ssh's exit code. +func sshHandoff(c *cli.Context, id, ref, privPath, user string) (int, error) { + // Open a local TCP listener that bridges every accepted connection + // through the control plane to the sandbox's :22. ctx, cancel := context.WithCancel(c.Context) defer cancel() bridge, err := startTunnelBridge(ctx, c, id, 22) if err != nil { - return fmt.Errorf("could not open tunnel to the sandbox: %w", err) + return 0, fmt.Errorf("could not open tunnel to the sandbox: %w", err) } defer bridge.close() - if err := waitForTCP(bridge.localAddr, 5*time.Second); err != nil { - return fmt.Errorf("sshd did not start in time: %w", err) + if err = waitForTCP(ctx, bridge.localAddr, 5*time.Second); err != nil { + return 0, fmt.Errorf("sshd did not start in time: %w", err) } - // 4. Hand off to system ssh through the local tunnel for a real PTY. - _, port, _ := net.SplitHostPort(bridge.localAddr) + // Hand off to system ssh through the local tunnel for a real PTY. + _, port, _ := net.SplitHostPort(bridge.localAddr) //nolint:errcheck pterm.Println(pterm.Gray(fmt.Sprintf(" connecting to %s as %s…", refLabel(ref, id), user))) - sshCmd := exec.Command( + sshCmd := exec.CommandContext( // #nosec G204 -- fixed "ssh" binary; args are flags plus the resolved key path and tunnel port + ctx, "ssh", "-p", port, "-i", privPath, @@ -209,10 +227,11 @@ fi sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr err = sshCmd.Run() - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil } - return err + return 0, err } // ── Keyless PTY path ────────────────────────────────────────────── @@ -231,7 +250,7 @@ const ( // runShellPTY puts the local terminal in raw mode and pumps a real PTY // session that runs inside the sandbox. Auth is the API token only. func runShellPTY(c *cli.Context, id, ref string) error { - stdinFd := int(os.Stdin.Fd()) + stdinFd := int(os.Stdin.Fd()) // #nosec G115 -- a file descriptor always fits in an int if !term.IsTerminal(stdinFd) { return fmt.Errorf("shell needs a real terminal — re-run interactively, or pass --ssh for the SSH path") } @@ -249,7 +268,7 @@ func runShellPTY(c *cli.Context, id, ref string) error { if err != nil { return err } - defer conn.Close() + defer func() { _ = conn.Close() }() //nolint:errcheck pterm.Println(pterm.Gray(fmt.Sprintf(" connecting to %s…", refLabel(ref, id)))) @@ -257,7 +276,7 @@ func runShellPTY(c *cli.Context, id, ref string) error { if err != nil { return fmt.Errorf("could not switch terminal to raw mode: %w", err) } - defer func() { _ = term.Restore(stdinFd, oldState) }() + defer func() { _ = term.Restore(stdinFd, oldState) }() //nolint:errcheck // frameMu serialises writes to conn — the stdin pump and the // SIGWINCH handler both emit frames, and an interleaved write would @@ -277,7 +296,7 @@ func runShellPTY(c *cli.Context, id, ref string) error { done := make(chan struct{}, 2) // remote → local screen go func() { - _, _ = io.Copy(os.Stdout, conn) + _, _ = io.Copy(os.Stdout, conn) //nolint:errcheck done <- struct{}{} }() // local keystrokes → framed stdin @@ -307,16 +326,16 @@ func sendResize(conn io.Writer, mu *sync.Mutex, fd int) { return } var p [4]byte - binary.BigEndian.PutUint16(p[0:2], uint16(rows)) - binary.BigEndian.PutUint16(p[2:4], uint16(cols)) - _ = writeFrame(conn, mu, ptyFrameResize, p[:]) + binary.BigEndian.PutUint16(p[0:2], uint16(rows)) // #nosec G115 -- terminal dimensions fit in uint16 + binary.BigEndian.PutUint16(p[2:4], uint16(cols)) // #nosec G115 -- terminal dimensions fit in uint16 + _ = writeFrame(conn, mu, ptyFrameResize, p[:]) //nolint:errcheck } // writeFrame emits one [type:1][len:4 BE][payload] frame under mu. func writeFrame(w io.Writer, mu *sync.Mutex, typ byte, payload []byte) error { var hdr [5]byte hdr[0] = typ - binary.BigEndian.PutUint32(hdr[1:5], uint32(len(payload))) + binary.BigEndian.PutUint32(hdr[1:5], uint32(len(payload))) // #nosec G115 -- frame payloads are far smaller than uint32 max mu.Lock() defer mu.Unlock() if _, err := w.Write(hdr[:]); err != nil { @@ -346,11 +365,12 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.C if !strings.Contains(host, ":") { host += ":443" } - sni, _, _ := net.SplitHostPort(host) - conn, err = tls.DialWithDialer(d, "tcp", host, &tls.Config{ + sni, _, _ := net.SplitHostPort(host) //nolint:errcheck + td := &tls.Dialer{NetDialer: d, Config: &tls.Config{ ServerName: sni, NextProtos: []string{"http/1.1"}, - }) + }} + conn, err = td.DialContext(ctx, "tcp", host) } else { if !strings.Contains(host, ":") { host += ":80" @@ -367,21 +387,21 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.C "Connection: Upgrade\r\n" + "Upgrade: tcp-tunnel\r\n" + "Content-Length: 0\r\n\r\n" - if _, err := conn.Write([]byte(req)); err != nil { - conn.Close() + if _, err = conn.Write([]byte(req)); err != nil { + _ = conn.Close() //nolint:errcheck return nil, err } br := bufio.NewReader(conn) status, err := br.ReadString('\n') if err != nil { - conn.Close() + _ = conn.Close() //nolint:errcheck return nil, fmt.Errorf("read upgrade response: %w", err) } if !strings.Contains(status, " 101 ") { // Read up to a few KB so we can show the server's error message. - body, _ := io.ReadAll(io.LimitReader(br, 4096)) - conn.Close() + body, _ := io.ReadAll(io.LimitReader(br, 4096)) //nolint:errcheck + _ = conn.Close() //nolint:errcheck msg := strings.TrimSpace(string(body)) if msg == "" { msg = strings.TrimSpace(status) @@ -391,7 +411,7 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.C for { line, err := br.ReadString('\n') if err != nil { - conn.Close() + _ = conn.Close() //nolint:errcheck return nil, fmt.Errorf("read upgrade headers: %w", err) } if line == "\r\n" || line == "\n" { @@ -429,7 +449,7 @@ func (b *tunnelBridge) close() { b.stop() } if b.listener != nil { - _ = b.listener.Close() + _ = b.listener.Close() //nolint:errcheck } } @@ -442,7 +462,8 @@ func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, if err != nil { return nil, err } - l, err := net.Listen("tcp", "127.0.0.1:0") + var lc net.ListenConfig + l, err := lc.Listen(parent, "tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("local listen: %w", err) } @@ -484,12 +505,12 @@ func loadAPIToken() (string, error) { // peers, and closing early on a transient half-drain truncates the // handshake mid-flight and looks like an auth failure. func bridgeOne(ctx context.Context, ctrlURL, token, id string, port int, local net.Conn) { - defer local.Close() + defer func() { _ = local.Close() }() //nolint:errcheck remote, err := dialControlTunnel(ctx, ctrlURL, token, id, port) if err != nil { return } - defer remote.Close() + defer func() { _ = remote.Close() }() //nolint:errcheck // closeWrite when one direction reaches EOF so the peer sees a // proper half-close instead of either side hanging on the read. // We still wait for the other goroutine before returning. @@ -497,16 +518,16 @@ func bridgeOne(ctx context.Context, ctrlURL, token, id string, port int, local n wg.Add(2) go func() { defer wg.Done() - _, _ = io.Copy(remote, local) + _, _ = io.Copy(remote, local) //nolint:errcheck if cw, ok := remote.(interface{ CloseWrite() error }); ok { - _ = cw.CloseWrite() + _ = cw.CloseWrite() //nolint:errcheck } }() go func() { defer wg.Done() - _, _ = io.Copy(local, remote) + _, _ = io.Copy(local, remote) //nolint:errcheck if cw, ok := local.(interface{ CloseWrite() error }); ok { - _ = cw.CloseWrite() + _ = cw.CloseWrite() //nolint:errcheck } }() doneCh := make(chan struct{}) @@ -533,14 +554,15 @@ func dialControlTunnel(ctx context.Context, ctrlURL, token, id string, port int) if !strings.Contains(host, ":") { host += ":443" } - sni, _, _ := net.SplitHostPort(host) + sni, _, _ := net.SplitHostPort(host) //nolint:errcheck // Force HTTP/1.1 via ALPN. HTTP/2 doesn't expose the // hop-by-hop `Upgrade` header we rely on; if the server picks // h2 the tunnel handshake silently falls apart. - conn, err = tls.DialWithDialer(d, "tcp", host, &tls.Config{ + td := &tls.Dialer{NetDialer: d, Config: &tls.Config{ ServerName: sni, NextProtos: []string{"http/1.1"}, - }) + }} + conn, err = td.DialContext(ctx, "tcp", host) } else { if !strings.Contains(host, ":") { host += ":80" @@ -555,25 +577,25 @@ func dialControlTunnel(ctx context.Context, ctrlURL, token, id string, port int) "Host: %s\r\nX-Api-Key: %s\r\n"+ "Connection: Upgrade\r\nUpgrade: tcp-tunnel\r\nContent-Length: 0\r\n\r\n", id, port, u.Host, token) - if _, err := conn.Write([]byte(req)); err != nil { - conn.Close() + if _, err = conn.Write([]byte(req)); err != nil { + _ = conn.Close() //nolint:errcheck return nil, err } br := bufio.NewReader(conn) status, err := br.ReadString('\n') if err != nil { - conn.Close() + _ = conn.Close() //nolint:errcheck return nil, fmt.Errorf("read tunnel response: %w", err) } if !strings.Contains(status, " 101 ") { - conn.Close() + _ = conn.Close() //nolint:errcheck return nil, fmt.Errorf("server rejected the tunnel: %s", strings.TrimSpace(status)) } for { line, err := br.ReadString('\n') if err != nil { - conn.Close() + _ = conn.Close() //nolint:errcheck return nil, fmt.Errorf("read tunnel headers: %w", err) } if line == "\r\n" || line == "\n" { @@ -595,12 +617,13 @@ func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) } // ── tiny helpers ──────────────────────────────────────────────────── -func waitForTCP(addr string, timeout time.Duration) error { +func waitForTCP(ctx context.Context, addr string, timeout time.Duration) error { + d := net.Dialer{Timeout: 250 * time.Millisecond} deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - c, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + c, err := d.DialContext(ctx, "tcp", addr) if err == nil { - _ = c.Close() + _ = c.Close() //nolint:errcheck return nil } time.Sleep(100 * time.Millisecond) diff --git a/cmd/sandbox/slider.go b/cmd/sandbox/slider.go index cf0f278..01d9681 100644 --- a/cmd/sandbox/slider.go +++ b/cmd/sandbox/slider.go @@ -119,19 +119,19 @@ func pickRechargeAmountGB(initialGB int) (int, error) { // renderSliderBar draws a width-character bar with a marker at the // current value's position. -func renderSliderBar(value, min, max, width int) string { +func renderSliderBar(value, lo, hi, width int) string { if width < 3 { width = 3 } - if value < min { - value = min + if value < lo { + value = lo } - if value > max { - value = max + if value > hi { + value = hi } pos := 0 - if max > min { - pos = ((value - min) * (width - 1)) / (max - min) + if hi > lo { + pos = ((value - lo) * (width - 1)) / (hi - lo) } var b strings.Builder b.WriteString("[") diff --git a/cmd/sandbox/sync.go b/cmd/sandbox/sync.go index e886ac0..9819d28 100644 --- a/cmd/sandbox/sync.go +++ b/cmd/sandbox/sync.go @@ -121,7 +121,7 @@ func runSync(c *cli.Context) error { if !tty { return errors.New("--local is required (no terminal for interactive prompt)") } - cwd, _ := os.Getwd() + cwd, _ := os.Getwd() //nolint:errcheck v, err := pterm.DefaultInteractiveTextInput. WithDefaultText("Local directory to sync (enter for current directory)"). WithDefaultValue(cwd). @@ -153,7 +153,7 @@ func runSync(c *cli.Context) error { if err != nil { return err } - if err := validateRemoteSyncPath(remote); err != nil { + if err = validateRemoteSyncPath(remote); err != nil { return err } @@ -167,7 +167,7 @@ func runSync(c *cli.Context) error { if err != nil { return err } - pubBytes, err := os.ReadFile(pubPath) + pubBytes, err := os.ReadFile(pubPath) // #nosec G304 -- pubPath is the user's own SSH public key, chosen via --identity if err != nil { return fmt.Errorf("could not read public key %s: %w", pubPath, err) } @@ -193,7 +193,7 @@ func runSync(c *cli.Context) error { // 4. Install authorized_keys + start sshd. Mirror of the SSH-shell // path so sync gets the same modes/sshd setup. authPath := authorizedKeysPath(user) - if err := client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { + if err = client.UploadFile(c.Context, id, authPath, bytesReader(pubBytes), int64(len(pubBytes))); err != nil { return fmt.Errorf("could not install your SSH key: %w", err) } prepScript := fmt.Sprintf(` @@ -212,11 +212,11 @@ if ! awk 'NR>1{print $2}' /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -qi ': /usr/sbin/sshd fi `, filepath.Dir(authPath), user, shellQuote(remote)) - if pre, err := client.ExecSandbox(c.Context, id, api.SandboxExecReq{ + if pre, execErr := client.ExecSandbox(c.Context, id, api.SandboxExecReq{ Cmd: "sh", Args: []string{"-c", prepScript}, - }); err != nil { - return fmt.Errorf("could not prepare sshd: %w", err) + }); execErr != nil { + return fmt.Errorf("could not prepare sshd: %w", execErr) } else if pre.Result.ExitCode == 100 { return 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 { @@ -231,10 +231,10 @@ fi return fmt.Errorf("could not open tunnel to the sandbox: %w", err) } defer bridge.close() - if err := waitForTCP(bridge.localAddr, c.Duration("sshd-wait")); err != nil { + if err = waitForTCP(ctx, bridge.localAddr, c.Duration("sshd-wait")); err != nil { return fmt.Errorf("sshd did not start in time: %w", err) } - _, port, _ := net.SplitHostPort(bridge.localAddr) + _, port, _ := net.SplitHostPort(bridge.localAddr) //nolint:errcheck // 6. Create the mutagen session. // Mutagen's URL parser dislikes `ssh://user@host:port/path` — @@ -246,12 +246,12 @@ fi if err != nil { return fmt.Errorf("could not set up ssh wrapper: %w", err) } - defer os.RemoveAll(wrapperDir) + defer func() { _ = os.RemoveAll(wrapperDir) }() //nolint:errcheck // Mutagen runs ssh from its long-lived daemon, not from this // process. Stop the daemon so the next `create` auto-starts it // under our env, picking up the wrapper PATH. - _ = runMutagen(ctx, mutagenBin, wrapperEnv, "daemon", "stop") + _ = runMutagen(ctx, mutagenBin, wrapperEnv, "daemon", "stop") //nolint:errcheck pterm.Println(pterm.Gray(fmt.Sprintf(" syncing %s ⇄ %s:%s", local, refLabel(ref, id), remote))) createArgs := []string{ @@ -268,7 +268,7 @@ fi // cancellation propagating before we exit. defer func() { bg := context.Background() - _ = runMutagen(bg, mutagenBin, wrapperEnv, "sync", "terminate", sessionName) + _ = runMutagen(bg, mutagenBin, wrapperEnv, "sync", "terminate", sessionName) //nolint:errcheck }() pterm.Success.Println("Sync running. Press Ctrl+C to stop.") @@ -276,7 +276,7 @@ fi // 7. Monitor the session in the foreground. `mutagen sync monitor` // streams status lines until the session is terminated or the // process exits. - mon := exec.CommandContext(ctx, mutagenBin, "sync", "monitor", sessionName) + mon := exec.CommandContext(ctx, mutagenBin, "sync", "monitor", sessionName) // #nosec G204 -- mutagenBin is our managed binary; sessionName is internally generated mon.Env = wrapperEnv mon.Stdout = os.Stdout mon.Stderr = os.Stderr @@ -290,7 +290,7 @@ fi // runMutagen runs `mutagen ` with our shadowed PATH env. // stdout/stderr are forwarded so the user sees mutagen's progress. func runMutagen(ctx context.Context, bin string, env []string, args ...string) error { - cmd := exec.CommandContext(ctx, bin, args...) + cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is our managed mutagen binary; args are internally constructed cmd.Env = env cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -326,20 +326,22 @@ func makeSSHWrapper(privPath string) (string, []string, error) { "-o UserKnownHostsFile=%s -o LogLevel=ERROR", shellQuote(privPath), shellQuote(knownHosts)) + // #nosec G306 -- these are wrapper scripts that must be executable if err := os.WriteFile( filepath.Join(dir, "ssh"), []byte(fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", realSSH, commonOpts)), 0o755, ); err != nil { - _ = os.RemoveAll(dir) + _ = os.RemoveAll(dir) //nolint:errcheck return "", nil, err } + // #nosec G306 -- these are wrapper scripts that must be executable if err := os.WriteFile( filepath.Join(dir, "scp"), []byte(fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", realSCP, commonOpts)), 0o755, ); err != nil { - _ = os.RemoveAll(dir) + _ = os.RemoveAll(dir) //nolint:errcheck return "", nil, err } @@ -357,7 +359,7 @@ func makeSSHWrapper(privPath string) (string, []string, error) { // shellQuote single-quotes a string for safe inclusion in /bin/sh. // Embedded single quotes are escaped via the standard close-escape-open -// dance: 'foo'\''bar' decodes to foo'bar. +// dance: 'foo'\”bar' decodes to foo'bar. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } @@ -407,7 +409,7 @@ func validateLocalSyncPath(p string, force bool) (string, error) { if abs == "/" { return "", fmt.Errorf("refusing to sync from %q — pick a specific subdirectory", abs) } - home, _ := os.UserHomeDir() + home, _ := os.UserHomeDir() //nolint:errcheck if abs == home { return "", fmt.Errorf("refusing to sync from $HOME itself — pick a subdirectory like ~/work") } @@ -419,7 +421,7 @@ func validateLocalSyncPath(p string, force bool) (string, error) { rel := strings.TrimPrefix(strings.TrimPrefix(abs, home), "/") first := strings.SplitN(rel, "/", 2)[0] if _, bad := sensitiveLocalDirs[first]; bad { - return "", fmt.Errorf("refusing to sync from %s (sensitive directory). Pass --force if you really mean it.", abs) + return "", fmt.Errorf("refusing to sync from %s (sensitive directory) — pass --force if you really mean it", abs) } } } @@ -435,7 +437,7 @@ func validateLocalSyncPath(p string, force bool) (string, error) { // reservedRemoteDirs are remote first-path-components we refuse to // sync TO. System dirs that mutagen would happily overwrite. var reservedRemoteDirs = map[string]struct{}{ - "": {}, "/": {}, "etc": {}, "usr": {}, "bin": {}, "sbin": {}, + "": {}, "/": {}, "etc": {}, "usr": {}, "bin": {}, "sbin": {}, "lib": {}, "lib64": {}, "boot": {}, "proc": {}, "sys": {}, "dev": {}, "run": {}, } @@ -473,7 +475,7 @@ func validateRemoteSyncPath(p string) error { // before this command returns. func unlockSSHKeyIfNeeded(path string) (string, func(), error) { noop := func() {} - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(path) // #nosec G304 -- path is the user's own SSH private key, chosen via --identity if err != nil { return "", noop, fmt.Errorf("could not read SSH key %s: %w", path, err) } @@ -485,7 +487,7 @@ func unlockSSHKeyIfNeeded(path string) (string, func(), error) { return "", noop, fmt.Errorf("the SSH key %s is passphrase-protected — pass --identity or run from a terminal that can prompt for the passphrase", path) } fmt.Printf("Enter passphrase for %s: ", path) - pw, perr := term.ReadPassword(int(os.Stdin.Fd())) + pw, perr := term.ReadPassword(int(os.Stdin.Fd())) // #nosec G115 -- a file descriptor always fits in an int fmt.Println() if perr != nil { return "", noop, fmt.Errorf("could not read passphrase: %w", perr) @@ -504,9 +506,9 @@ func unlockSSHKeyIfNeeded(path string) (string, func(), error) { return "", noop, derr } out := filepath.Join(dir, "id_unlocked") - if werr := os.WriteFile(out, pem.EncodeToMemory(block), 0o600); werr != nil { - _ = os.RemoveAll(dir) + if werr := os.WriteFile(out, pem.EncodeToMemory(block), 0o600); werr != nil { // #nosec G703 -- out is under a freshly created MkdirTemp dir, not user-controlled + _ = os.RemoveAll(dir) //nolint:errcheck return "", noop, fmt.Errorf("could not write unlocked key: %w", werr) } - return out, func() { _ = os.RemoveAll(dir) }, nil + return out, func() { _ = os.RemoveAll(dir) }, nil //nolint:errcheck } diff --git a/cmd/sandbox/template.go b/cmd/sandbox/template.go index 90e0315..8ba246c 100644 --- a/cmd/sandbox/template.go +++ b/cmd/sandbox/template.go @@ -64,12 +64,12 @@ func runTemplateSubmit(c *cli.Context) error { if name == "" { return fmt.Errorf("template name required\n\n Example:\n createos sandbox template submit my-rails -f Dockerfile") } - body, err := os.ReadFile(path) + body, err := os.ReadFile(path) // #nosec G304 -- path is a user-supplied Dockerfile path if err != nil { return fmt.Errorf("read %s: %w", path, err) } if len(body) == 0 { - return fmt.Errorf("Dockerfile %s is empty", path) + return fmt.Errorf("the Dockerfile at %s is empty", path) } view, err := client.CreateTemplate(c.Context, api.TemplateCreateReq{ @@ -155,7 +155,7 @@ func runTemplateList(c *cli.Context) error { t.CreatedAt.Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck pterm.Println() pterm.Println(pterm.Gray(" Spawn from a ready template: createos sandbox create --rootfs ")) }) @@ -212,10 +212,11 @@ func runTemplateShow(c *cli.Context) error { if t.BuiltAt != nil { row("Built", t.BuiltAt.Format("2006-01-02 15:04:05")) } - if t.Status == "failed" { + switch t.Status { + case "failed": pterm.Println() pterm.Println(pterm.Gray(fmt.Sprintf(" Build failed. See logs: createos sandbox template logs %s", t.Name))) - } else if t.Status == "ready" { + case "ready": pterm.Println() pterm.Println(pterm.Gray(fmt.Sprintf(" Spawn from it: createos sandbox create --rootfs %s", t.Name))) } @@ -258,8 +259,8 @@ func runTemplateLogs(c *cli.Context) error { if err != nil { return err } - defer resp.RawBody().Close() - _, _ = io.Copy(os.Stdout, resp.RawBody()) + defer func() { _ = resp.RawBody().Close() }() //nolint:errcheck + _, _ = io.Copy(os.Stdout, resp.RawBody()) //nolint:errcheck return nil } @@ -271,15 +272,15 @@ func streamTemplateLogs(c *cli.Context, client *api.SandboxClient, ref string) e if err != nil { return err } - defer resp.RawBody().Close() + defer func() { _ = resp.RawBody().Close() }() //nolint:errcheck var spinner *pterm.SpinnerPrinter if terminal.IsInteractive() { - spinner, _ = pterm.DefaultSpinner.Start("template build queued…") + spinner, _ = pterm.DefaultSpinner.Start("template build queued…") //nolint:errcheck } stopSpinner := func() { if spinner != nil { - _ = spinner.Stop() + _ = spinner.Stop() //nolint:errcheck spinner = nil } } @@ -304,7 +305,7 @@ func streamTemplateLogs(c *cli.Context, client *api.SandboxClient, ref string) e pterm.Success.Println("Build succeeded.") case "failed": pterm.Error.Println("Build failed.") - os.Exit(1) + return cli.Exit("", 1) default: pterm.Println(pterm.Gray("(stream ended)")) } @@ -312,8 +313,8 @@ func streamTemplateLogs(c *cli.Context, client *api.SandboxClient, ref string) e } if ev.Line != "" { stopSpinner() - os.Stdout.WriteString(ev.Line) - os.Stdout.WriteString("\n") + _, _ = os.Stdout.WriteString(ev.Line) //nolint:errcheck + _, _ = os.Stdout.WriteString("\n") //nolint:errcheck } } return scanner.Err() diff --git a/cmd/sandbox/tunnel.go b/cmd/sandbox/tunnel.go index 96ab4e2..73c16ae 100644 --- a/cmd/sandbox/tunnel.go +++ b/cmd/sandbox/tunnel.go @@ -153,11 +153,12 @@ func runTunnel(c *cli.Context) error { // opens its own HTTP-Upgrade tunnel through control to the // sandbox's `remote` port. listenAddr := net.JoinHostPort(bind, strconv.Itoa(local)) - listener, err := net.Listen("tcp", listenAddr) + 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 listener.Close() + defer func() { _ = listener.Close() }() //nolint:errcheck ctrlURL := strings.TrimSpace(c.String("sandbox-api-url")) if ctrlURL == "" { @@ -177,7 +178,7 @@ func runTunnel(c *cli.Context) error { defer signal.Stop(sigCh) go func() { <-sigCh - _ = listener.Close() + _ = listener.Close() //nolint:errcheck }() // 4. Accept loop. Each connection runs in its own goroutine via diff --git a/cmd/sandbox/wizard.go b/cmd/sandbox/wizard.go index 2e2fd15..0d52601 100644 --- a/cmd/sandbox/wizard.go +++ b/cmd/sandbox/wizard.go @@ -81,13 +81,14 @@ func runCreateWizard(c *cli.Context, client *api.SandboxClient, seed wizardSeed) // ── 3. Rootfs (optional; default = host default) ──────────────── if out.rootfs == "" { picked, err := wizardPickRootfs(c, client) - if err != nil { + switch { + case err != nil: // Non-fatal — log and continue with the server default. pterm.Println(pterm.Gray(" Could not load image list — using the default.")) - } else if picked == "" { + case picked == "": // User cancelled the rootfs step specifically — keep going // with the default rather than aborting the whole wizard. - } else { + default: out.rootfs = picked } } @@ -129,9 +130,9 @@ func runCreateWizard(c *cli.Context, client *api.SandboxClient, seed wizardSeed) // wizardPickShape — bubbletea picker over GET /v1/shapes. func wizardPickShape(c *cli.Context, client *api.SandboxClient) (string, error) { - spinner, _ := pterm.DefaultSpinner.Start("Loading sizes…") + spinner, _ := pterm.DefaultSpinner.Start("Loading sizes…") //nolint:errcheck shapes, err := client.ListShapes(c.Context) - spinner.Stop() + _ = spinner.Stop() //nolint:errcheck if err != nil { return "", err } @@ -146,16 +147,16 @@ func wizardPickShape(c *cli.Context, client *api.SandboxClient) (string, error) // (GET /v1/templates, status=ready). Empty return = user cancelled // the step; caller falls back to the default. func wizardPickRootfs(c *cli.Context, client *api.SandboxClient) (string, error) { - spinner, _ := pterm.DefaultSpinner.Start("Loading images…") + spinner, _ := pterm.DefaultSpinner.Start("Loading images…") //nolint:errcheck cat, err := client.ListRootfs(c.Context) if err != nil { - spinner.Stop() + _ = spinner.Stop() //nolint:errcheck return "", err } // Templates are best-effort: a fetch failure shouldn't kill the // create flow. Same forgiveness the UI shows. - tpls, _ := client.ListTemplates(c.Context) - spinner.Stop() + tpls, _ := client.ListTemplates(c.Context) //nolint:errcheck + _ = spinner.Stop() //nolint:errcheck if cat == nil || len(cat.Rootfs) == 0 { return "", nil } @@ -200,9 +201,9 @@ func wizardPickRootfs(c *cli.Context, client *api.SandboxClient) (string, error) // wizardPickNetworks — multi-select over GET /v1/networks. Returns [] // empty when the user has no networks or skips the prompt. func wizardPickNetworks(c *cli.Context, client *api.SandboxClient) ([]string, error) { - spinner, _ := pterm.DefaultSpinner.Start("Loading networks…") + spinner, _ := pterm.DefaultSpinner.Start("Loading networks…") //nolint:errcheck nets, err := client.ListNetworks(c.Context) - spinner.Stop() + _ = spinner.Stop() //nolint:errcheck if err != nil { return nil, err } @@ -260,7 +261,8 @@ func wizardPickSSHKeys() ([]string, error) { if len(candidates) == 0 { // Nothing auto-detected. Offer a one-shot manual path entry. - path, err := pterm.DefaultInteractiveTextInput. + var path string + path, err = pterm.DefaultInteractiveTextInput. WithDefaultText("Path to a public-key file to install (leave empty to skip)"). Show() if err != nil { @@ -293,8 +295,8 @@ func wizardPickSSHKeys() ([]string, error) { } paths := make([]string, 0, len(picked)) for _, p := range picked { - if real, ok := pathByOpt[p]; ok { - paths = append(paths, real) + if realPath, ok := pathByOpt[p]; ok { + paths = append(paths, realPath) } } return readSSHPubkeys(paths) @@ -314,7 +316,7 @@ func discoverSSHPubkeys(sshDir string) []string { continue } path := filepath.Join(sshDir, e.Name()) - head, err := os.ReadFile(path) + head, err := os.ReadFile(path) // #nosec G304 -- path is from os.ReadDir over the user's ~/.ssh if err != nil || len(head) == 0 { continue } diff --git a/cmd/templates/use.go b/cmd/templates/use.go index 168fc93..4a35a64 100644 --- a/cmd/templates/use.go +++ b/cmd/templates/use.go @@ -66,7 +66,8 @@ func newTemplatesUseCommand() *cli.Command { } else { confirmText = fmt.Sprintf("Download %q (free)?", tmpl.Name) } - confirm, err := pterm.DefaultInteractiveConfirm. + var confirm bool + confirm, err = pterm.DefaultInteractiveConfirm. WithDefaultText(confirmText). WithDefaultValue(true). Show() @@ -79,11 +80,10 @@ func newTemplatesUseCommand() *cli.Command { } } - newPurchaseID, err := client.BuyTemplate(templateID) + purchaseID, err = client.BuyTemplate(templateID) if err != nil { return err } - purchaseID = newPurchaseID } downloadURL, err := client.GetTemplatePurchaseDownloadURL(purchaseID) @@ -104,7 +104,7 @@ func newTemplatesUseCommand() *cli.Command { return err } - if err := os.MkdirAll(absDir, 0750); err != nil { + if err = os.MkdirAll(absDir, 0750); err != nil { return fmt.Errorf("could not create directory %s: %w", dir, err) } @@ -124,7 +124,7 @@ func newTemplatesUseCommand() *cli.Command { zipPath := filepath.Join(absDir, "template.zip") if err := downloadToFile(zipPath, resp.Body); err != nil { - _ = os.Remove(zipPath) + _ = os.Remove(zipPath) //nolint:errcheck return err } @@ -140,7 +140,7 @@ func downloadToFile(path string, src io.Reader) error { return fmt.Errorf("could not create file: %w", err) } if _, err := io.Copy(out, src); err != nil { - _ = out.Close() + _ = out.Close() //nolint:errcheck return fmt.Errorf("could not write template: %w", err) } if err := out.Close(); err != nil { diff --git a/cmd/upgrade/upgrade.go b/cmd/upgrade/upgrade.go index fbeee61..52c9ff0 100644 --- a/cmd/upgrade/upgrade.go +++ b/cmd/upgrade/upgrade.go @@ -64,7 +64,8 @@ func runUpgrade() error { } if version.Channel == "nightly" { - remoteCommit, err := fetchNightlyCommit(release) + var remoteCommit string + remoteCommit, err = fetchNightlyCommit(release) if err != nil { return fmt.Errorf("could not check nightly commit: %w", err) } @@ -106,10 +107,10 @@ func runUpgrade() error { return fmt.Errorf("no checksum file found for %s in release %s", assetName, release.TagName) } - if err := validateDownloadURL(downloadURL); err != nil { + if err = validateDownloadURL(downloadURL); err != nil { return fmt.Errorf("release asset URL failed validation: %w", err) } - if err := validateDownloadURL(checksumURL); err != nil { + if err = validateDownloadURL(checksumURL); err != nil { return fmt.Errorf("checksum URL failed validation: %w", err) } @@ -119,7 +120,7 @@ func runUpgrade() error { } defer os.Remove(tmp) //nolint:errcheck - spinner, _ := pterm.DefaultSpinner.Start("Verifying checksum...") + spinner, _ := pterm.DefaultSpinner.Start("Verifying checksum...") //nolint:errcheck expectedHash, err := fetchChecksum(checksumURL) if err != nil { @@ -127,7 +128,7 @@ func runUpgrade() error { return fmt.Errorf("could not fetch checksum: %w", err) } - if err := verifyChecksum(tmp, expectedHash); err != nil { + if err = verifyChecksum(tmp, expectedHash); err != nil { spinner.Fail("Checksum mismatch") return err } @@ -255,16 +256,16 @@ func downloadToTemp(rawURL, assetName string) (string, error) { totalBytes := resp.ContentLength if totalBytes > 0 { totalKB := int(totalBytes / 1024) - bar, _ := pterm.DefaultProgressbar. + pb := pterm.DefaultProgressbar. WithTotal(totalKB). - WithTitle(fmt.Sprintf("Downloading %s (%.1f MB)", assetName, float64(totalBytes)/1024/1024)). - Start() + WithTitle(fmt.Sprintf("Downloading %s (%.1f MB)", assetName, float64(totalBytes)/1024/1024)) + bar, _ := pb.Start() //nolint:errcheck pw := &progressWriter{bar: bar} _, err = io.Copy(tmp, io.TeeReader(limited, pw)) - _, _ = bar.Stop() + _, _ = bar.Stop() //nolint:errcheck } else { // Content-Length unknown — fall back to spinner - spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading %s...", assetName)) + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading %s...", assetName)) //nolint:errcheck _, err = io.Copy(tmp, limited) if err != nil { spinner.Fail("Download failed") @@ -274,7 +275,7 @@ func downloadToTemp(rawURL, assetName string) (string, error) { } if err != nil { - _ = os.Remove(tmp.Name()) + _ = os.Remove(tmp.Name()) //nolint:errcheck return "", err } @@ -389,7 +390,7 @@ func replaceExecutable(dst, src string) error { // rename it away first then rename the new one into place. if strings.EqualFold(runtime.GOOS, "windows") { old := dst + ".old" - _ = os.Remove(old) + _ = os.Remove(old) //nolint:errcheck if err := os.Rename(dst, old); err != nil { return err } diff --git a/cmd/users/consents_list.go b/cmd/users/consents_list.go index 1a28a10..a6a1d3d 100644 --- a/cmd/users/consents_list.go +++ b/cmd/users/consents_list.go @@ -48,7 +48,9 @@ func newOAuthConsentsListCommand() *cli.Command { } tableData = append(tableData, []string{clientID, clientName, clientURI}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + pterm.Error.Println(err) + } fmt.Println() }) return nil diff --git a/cmd/vms/deploy.go b/cmd/vms/deploy.go index 3223f56..564b488 100644 --- a/cmd/vms/deploy.go +++ b/cmd/vms/deploy.go @@ -58,13 +58,15 @@ func newVMDeployCommand() *cli.Command { if isInteractive && !hasFlags { // Fetch zones from API - zones, err := client.GetDOZones() + var zones []api.DOZone + zones, err = client.GetDOZones() if err != nil { return fmt.Errorf("could not fetch available zones: %w", err) } // Name - nameInput, err := pterm.DefaultInteractiveTextInput. + var nameInput string + nameInput, err = pterm.DefaultInteractiveTextInput. WithDefaultText("VM name (optional, press Enter to skip)"). Show() if err != nil { @@ -77,7 +79,8 @@ func newVMDeployCommand() *cli.Command { for i, z := range zones { zoneOptions[i] = fmt.Sprintf("%-6s %s, %s", z.Name, z.Region, z.Country) } - zoneSelected, err := pterm.DefaultInteractiveSelect. + var zoneSelected string + zoneSelected, err = pterm.DefaultInteractiveSelect. WithOptions(zoneOptions). WithDefaultText("Select a zone"). Show() @@ -91,7 +94,8 @@ func newVMDeployCommand() *cli.Command { for i, s := range sizes { sizeOptions[i] = formatVMSize(i+1, s) } - sizeSelected, err := pterm.DefaultInteractiveSelect. + var sizeSelected string + sizeSelected, err = pterm.DefaultInteractiveSelect. WithOptions(sizeOptions). WithDefaultText("Select a VM size"). Show() @@ -101,7 +105,8 @@ func newVMDeployCommand() *cli.Command { size = sizes[indexFromOption(sizeSelected, sizeOptions)] // SSH keys (optional) - addKey, err := pterm.DefaultInteractiveConfirm. + var addKey bool + addKey, err = pterm.DefaultInteractiveConfirm. WithDefaultText("Add an SSH public key?"). WithDefaultValue(false). Show() @@ -109,7 +114,8 @@ func newVMDeployCommand() *cli.Command { return fmt.Errorf("could not read confirmation: %w", err) } for addKey { - keyInput, err := pterm.DefaultInteractiveTextInput. + var keyInput string + keyInput, err = pterm.DefaultInteractiveTextInput. WithDefaultText("Paste your SSH public key"). Show() if err != nil { diff --git a/cmd/vms/list.go b/cmd/vms/list.go index e5393d2..9500031 100644 --- a/cmd/vms/list.go +++ b/cmd/vms/list.go @@ -52,7 +52,7 @@ func newVMListCommand() *cli.Command { vm.CreatedAt.Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/vms/ssh.go b/cmd/vms/ssh.go index 6c0d025..48860b2 100644 --- a/cmd/vms/ssh.go +++ b/cmd/vms/ssh.go @@ -76,18 +76,18 @@ func generateTempSSHKeypair() (publicKey string, privateKeyPath string, cleanup return "", "", nil, fmt.Errorf("could not create temp key file: %w", err) } if err := os.Chmod(f.Name(), 0600); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) + _ = f.Close() //nolint:errcheck + _ = os.Remove(f.Name()) //nolint:errcheck return "", "", nil, fmt.Errorf("could not set key file permissions: %w", err) } if _, err := f.Write(privBytes); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) + _ = f.Close() //nolint:errcheck + _ = os.Remove(f.Name()) //nolint:errcheck return "", "", nil, fmt.Errorf("could not write private key: %w", err) } - _ = f.Close() + _ = f.Close() //nolint:errcheck - cleanup = func() { _ = os.Remove(f.Name()) } + cleanup = func() { _ = os.Remove(f.Name()) } //nolint:errcheck return pubKeyStr, f.Name(), cleanup, nil } diff --git a/go.mod b/go.mod index 73d4a3b..aeb5f9c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/NodeOps-app/createos-cli -go 1.25.0 +go 1.26.3 require ( github.com/charmbracelet/bubbletea v1.3.10 diff --git a/internal/api/client.go b/internal/api/client.go index 3fce69c..30516d2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -3,12 +3,67 @@ package api import ( "fmt" + "io" "log" + "net/http" "strings" "github.com/go-resty/resty/v2" ) +// TokenRefresher obtains a fresh access token after the server rejects +// the current one with 401. The implementation is responsible for +// persisting the new token (e.g. back to ~/.createos/.oauth). Returning +// an error leaves the original 401 to propagate to the caller. +type TokenRefresher func() (string, error) + +// installAuthRefresh makes client refresh its auth header and retry the +// request once when the server answers 401. authHeader is the header +// carrying the credential (e.g. "X-Access-Token"). A nil refresher is a +// no-op, so API-key clients keep their single-shot behaviour. +// +// The refresh fires on exactly the first 401 of a request and only when +// the request body is replayable: resty leaves Request.Body as an +// io.Reader only on its unbuffered streaming path (e.g. file uploads), +// where the reader is already consumed and a retry would send an empty +// body. Those requests skip the retry and surface the 401. +func installAuthRefresh(client *resty.Client, authHeader string, refresher TokenRefresher) { + if refresher == nil { + return + } + client.SetRetryCount(1) + client.AddRetryCondition(func(resp *resty.Response, _ error) bool { + if resp == nil || resp.StatusCode() != http.StatusUnauthorized { + return false + } + // resty re-evaluates the condition on the final attempt too; + // gating on Attempt==1 keeps us from firing a second, + // refresh-token-rotating refresh. + if resp.Request.Attempt != 1 { + return false + } + if _, ok := resp.Request.Body.(io.Reader); ok { + return false // non-replayable streaming body — don't retry + } + newToken, err := refresher() + if err != nil { + return false // refresh failed — surface the original 401 + } + client.SetHeader(authHeader, newToken) + resp.Request.Header.Set(authHeader, newToken) + return true + }) +} + +// Auth header names. HTTP header keys are case-insensitive (and Go +// canonicalises them on the wire), so these double as the API-key and +// OAuth-access-token headers for both the main API and the fc-spawn +// sandbox API. +const ( + headerAPIKey = "X-Api-Key" // #nosec G101 -- HTTP header name, not a credential + headerAccessToken = "X-Access-Token" // #nosec G101 -- HTTP header name, not a credential +) + // DefaultBaseURL is the default CreateOS API base URL. const DefaultBaseURL = "https://api-createos.nodeops.network" @@ -25,7 +80,7 @@ func NewClient(token, apiURL string, debug bool) APIClient { client := resty.New(). SetBaseURL(apiURL). - SetHeader("x-api-key", token). + SetHeader(headerAPIKey, token). SetHeader("Content-Type", "application/json") if debug { @@ -40,15 +95,16 @@ func NewClient(token, apiURL string, debug bool) APIClient { } // NewClientWithAccessToken creates a resty client authenticated with an OAuth access token. -// Uses X-Access-Token header instead of x-api-key. -func NewClientWithAccessToken(accessToken, apiURL string, debug bool) APIClient { +// Uses X-Access-Token header instead of x-api-key. When refresher is non-nil the client +// refreshes the token and retries once on a 401 (see installAuthRefresh). +func NewClientWithAccessToken(accessToken, apiURL string, debug bool, refresher TokenRefresher) APIClient { if apiURL == "" { apiURL = DefaultBaseURL } client := resty.New(). SetBaseURL(apiURL). - SetHeader("X-Access-Token", accessToken). + SetHeader(headerAccessToken, accessToken). SetHeader("Content-Type", "application/json") if debug { @@ -59,6 +115,8 @@ func NewClientWithAccessToken(accessToken, apiURL string, debug bool) APIClient }) } + installAuthRefresh(client, headerAccessToken, refresher) + return APIClient{Client: client} } diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 4509043..8ea63e3 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -82,9 +82,16 @@ func (c *SandboxClient) DownloadFile(ctx context.Context, id, remote string, dst return 0, err } body := resp.RawBody() - defer body.Close() + defer func() { + if cerr := body.Close(); cerr != nil { + _ = cerr + } + }() if resp.IsError() { - raw, _ := io.ReadAll(body) + raw, readErr := io.ReadAll(body) + if readErr != nil { + raw = nil + } return 0, ParseAPIError(resp.StatusCode(), raw) } return io.Copy(dst, body) @@ -127,11 +134,18 @@ func (c *SandboxClient) ExecSandboxStream(ctx context.Context, id string, req Sa return -1, err } body := resp.RawBody() - defer body.Close() + defer func() { + if cerr := body.Close(); cerr != nil { + _ = cerr + } + }() // Non-2xx bodies are JSend envelopes, not NDJSON — read and parse. if resp.IsError() { - raw, _ := io.ReadAll(body) + raw, readErr := io.ReadAll(body) + if readErr != nil { + raw = nil + } return -1, ParseAPIError(resp.StatusCode(), raw) } @@ -747,7 +761,11 @@ func (c *SandboxClient) StreamTemplateLogs(ctx context.Context, ref string, foll } if resp.IsError() { body := resp.RawBody() - defer body.Close() + defer func() { + if cerr := body.Close(); cerr != nil { + _ = cerr + } + }() return nil, ParseAPIError(resp.StatusCode(), nil) } return resp, nil diff --git a/internal/api/sandbox_client.go b/internal/api/sandbox_client.go index d30c926..23298bf 100644 --- a/internal/api/sandbox_client.go +++ b/internal/api/sandbox_client.go @@ -27,19 +27,20 @@ type SandboxClient struct { // requires the X-Access-Token header instead. Use // NewSandboxClientWithAccessToken for that case. func NewSandboxClient(token, sandboxURL string, debug bool) SandboxClient { - return newSandboxClient("X-Api-Key", token, sandboxURL, debug) + return newSandboxClient(headerAPIKey, token, sandboxURL, debug, nil) } // NewSandboxClientWithAccessToken builds a SandboxClient authenticated // with an OAuth access token, sent via the X-Access-Token header. This // mirrors NewClientWithAccessToken on the main API client — fc-spawn -// accepts the same token under this header. -func NewSandboxClientWithAccessToken(accessToken, sandboxURL string, debug bool) SandboxClient { - return newSandboxClient("X-Access-Token", accessToken, sandboxURL, debug) +// accepts the same token under this header. When refresher is non-nil +// the client refreshes the token and retries once on a 401. +func NewSandboxClientWithAccessToken(accessToken, sandboxURL string, debug bool, refresher TokenRefresher) SandboxClient { + return newSandboxClient(headerAccessToken, accessToken, sandboxURL, debug, refresher) } // newSandboxClient is the shared builder behind the two auth schemes. -func newSandboxClient(authHeader, token, sandboxURL string, debug bool) SandboxClient { +func newSandboxClient(authHeader, token, sandboxURL string, debug bool, refresher TokenRefresher) SandboxClient { if sandboxURL == "" { sandboxURL = DefaultSandboxBaseURL } @@ -54,6 +55,7 @@ func newSandboxClient(authHeader, token, sandboxURL string, debug bool) SandboxC masked: maskToken(token), }) } + installAuthRefresh(client, authHeader, refresher) return SandboxClient{Client: client} } diff --git a/internal/api/sandbox_types.go b/internal/api/sandbox_types.go index 46006ab..cde491f 100644 --- a/internal/api/sandbox_types.go +++ b/internal/api/sandbox_types.go @@ -176,10 +176,10 @@ type RootfsCatalog struct { // DiskCreateReq is the body of POST /v1/disks. type DiskCreateReq struct { - Name string `json:"name"` - Kind string `json:"kind"` // "s3" today - Config DiskConfig `json:"config"` - Credentials DiskCredentials `json:"credentials"` + Name string `json:"name"` + Kind string `json:"kind"` // "s3" today + Config DiskConfig `json:"config"` + Credentials DiskCredentials `json:"credentials"` } // DiskConfig is the non-secret S3 endpoint description. @@ -199,11 +199,11 @@ type DiskCredentials struct { // DiskView is the user-facing projection returned by all read endpoints. type DiskView struct { - ID string `json:"id"` - Name string `json:"name"` - Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` Config DiskConfig `json:"config"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // DiskList is the paginated list shape. @@ -215,14 +215,14 @@ type DiskList struct { // SandboxDiskView is one attachment of a disk to a running sandbox, // shape of GET /v1/sandboxes/:id/disks rows. type SandboxDiskView struct { - DiskID string `json:"disk_id"` - Name string `json:"name"` - Kind string `json:"kind"` + DiskID string `json:"disk_id"` + Name string `json:"name"` + Kind string `json:"kind"` Config DiskConfig `json:"config"` - MountPath string `json:"mount_path"` - SubPath string `json:"sub_path,omitempty"` - MountStatus string `json:"mount_status"` - MountError string `json:"mount_error,omitempty"` + MountPath string `json:"mount_path"` + SubPath string `json:"sub_path,omitempty"` + MountStatus string `json:"mount_status"` + MountError string `json:"mount_error,omitempty"` } // SandboxDiskList is the paginated list shape under data. diff --git a/internal/config/oauth.go b/internal/config/oauth.go index e5a719f..6db9a3a 100644 --- a/internal/config/oauth.go +++ b/internal/config/oauth.go @@ -41,7 +41,7 @@ func SaveOAuthSession(session OAuthSession) error { if err != nil { return err } - if err := os.MkdirAll(dir, 0700); err != nil { + if err = os.MkdirAll(dir, 0700); err != nil { return err } path, err := oauthPath() diff --git a/internal/config/token.go b/internal/config/token.go index a412443..3484712 100644 --- a/internal/config/token.go +++ b/internal/config/token.go @@ -37,7 +37,7 @@ func SaveToken(token string) error { return err } - if err := os.MkdirAll(dir, 0700); err != nil { + if err = os.MkdirAll(dir, 0700); err != nil { return err } diff --git a/internal/installer/installer.go b/internal/installer/installer.go index cf15c7d..4fd85e1 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -90,7 +90,7 @@ func InstallToScope(downloadURL, uniqueName string, scope InstallScope) ([]strin } // Extract into each dir - var installed []string + installed := make([]string, 0, len(dirs)) for _, dir := range dirs { if err := unzip(data, dir); err != nil { return installed, fmt.Errorf("failed to unzip to %s: %w", dir, err) @@ -154,7 +154,9 @@ func unzip(data []byte, destDir string) error { rc, err := f.Open() if err != nil { - _ = out.Close() + if cerr := out.Close(); cerr != nil { + _ = cerr + } return err } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 6b0015b..4cd0893 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -114,7 +114,10 @@ func ExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) ( } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) + b, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("token endpoint returned status %d", resp.StatusCode) + } return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(b)) } var token TokenResponse @@ -142,7 +145,10 @@ func RefreshTokens(tokenEndpoint, clientID, refreshToken string) (*TokenResponse } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) + b, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("token refresh returned status %d", resp.StatusCode) + } return nil, fmt.Errorf("token refresh returned status %d: %s", resp.StatusCode, string(b)) } var token TokenResponse @@ -201,7 +207,9 @@ func StartCallbackServer(port int) (code string, state string, err error) { result := <-codeCh ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - _ = srv.Shutdown(ctx) + if shutdownErr := srv.Shutdown(ctx); shutdownErr != nil && result.err == nil { + result.err = fmt.Errorf("could not shut down callback server: %w", shutdownErr) + } return result.code, result.state, result.err } @@ -231,7 +239,16 @@ func FindFreePort() (int, error) { if err != nil { return 0, fmt.Errorf("could not find a free port: %w", err) } - port := ln.Addr().(*net.TCPAddr).Port - _ = ln.Close() + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + if closeErr := ln.Close(); closeErr != nil { + return 0, fmt.Errorf("could not determine a free port: %w", closeErr) + } + return 0, fmt.Errorf("could not determine a free port") + } + port := addr.Port + if err = ln.Close(); err != nil { + return 0, fmt.Errorf("could not release temporary port: %w", err) + } return port, nil } diff --git a/internal/output/render.go b/internal/output/render.go index c27a872..4c7316f 100644 --- a/internal/output/render.go +++ b/internal/output/render.go @@ -3,6 +3,7 @@ package output import ( "encoding/json" + "fmt" "os" "github.com/urfave/cli/v2" @@ -26,7 +27,9 @@ func Render(c *cli.Context, data any, tableRenderer func()) { if IsJSON(c) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - _ = enc.Encode(data) + if err := enc.Encode(data); err != nil { + fmt.Fprintln(os.Stderr, err) + } return } tableRenderer() @@ -39,12 +42,14 @@ func RenderError(c *cli.Context, code string, message string) bool { } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - _ = enc.Encode(map[string]any{ + if err := enc.Encode(map[string]any{ "error": map[string]string{ "code": code, "message": message, }, - }) + }); err != nil { + fmt.Fprintln(os.Stderr, err) + } return true } diff --git a/internal/ui/picker.go b/internal/ui/picker.go index d538f9a..c5ac043 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -28,7 +28,10 @@ func Pick(title string, items []PickerItem) (string, error) { if err != nil { return "", err } - res := out.(pickerModel) + res, ok := out.(pickerModel) + if !ok { + return "", fmt.Errorf("unexpected picker result") + } if res.quit { return "", nil } @@ -46,8 +49,7 @@ type pickerModel struct { func (m pickerModel) Init() tea.Cmd { return nil } func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: + if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "ctrl+c", "q", "esc": m.quit = true diff --git a/internal/ui/shape_picker.go b/internal/ui/shape_picker.go index 7a59910..4e81dbe 100644 --- a/internal/ui/shape_picker.go +++ b/internal/ui/shape_picker.go @@ -27,7 +27,10 @@ func PickShape(shapes []api.Shape) (string, error) { if err != nil { return "", err } - out := finalModel.(shapePickerModel) + out, ok := finalModel.(shapePickerModel) + if !ok { + return "", fmt.Errorf("unexpected picker result") + } if out.quit { return "", nil } @@ -45,8 +48,7 @@ type shapePickerModel struct { func (m shapePickerModel) Init() tea.Cmd { return nil } func (m shapePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: + if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "ctrl+c", "q", "esc": m.quit = true @@ -128,14 +130,5 @@ var ( _ = normalStyle ) -// max in Go 1.21+ stdlib — written explicitly here so we don't depend -// on the build toolchain having generics-friendly builtins enabled. -func max(a, b int) int { - if a > b { - return a - } - return b -} - // Compile-time guard so we notice if lipgloss is removed upstream. var _ = lipgloss.NewStyle diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 878f59e..57464a2 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -97,7 +97,9 @@ func saveCache(latest string) { return } - _ = os.WriteFile(path, data, 0o600) + if werr := os.WriteFile(path, data, 0o600); werr != nil { + _ = werr + } } func fetchLatest() string { diff --git a/internal/utils/ptr.go b/internal/utils/ptr.go index 817032e..e4cae28 100644 --- a/internal/utils/ptr.go +++ b/internal/utils/ptr.go @@ -1,5 +1,5 @@ // Package utils provides utility functions for the createos-cli project. -package utils +package utils //nolint:revive // shared helper package; name is intentional and widely imported // Ptr is a generic helper function that takes a value of any type and returns a pointer to it. func Ptr[T any](v T) *T { From 92117e052e9ca9b5ea28598aa9217970b06380cd Mon Sep 17 00:00:00 2001 From: bhautikchudasama Date: Thu, 4 Jun 2026 19:40:04 +0200 Subject: [PATCH 4/4] chore: fix shell, port forward --- cmd/sandbox/shell.go | 45 +++++++++++++++++----------------- cmd/sandbox/tunnel.go | 4 +-- internal/api/sandbox_client.go | 14 ++++++++++- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go index 820c04b..806d19a 100644 --- a/cmd/sandbox/shell.go +++ b/cmd/sandbox/shell.go @@ -24,7 +24,6 @@ import ( "golang.org/x/term" "github.com/NodeOps-app/createos-cli/internal/api" - "github.com/NodeOps-app/createos-cli/internal/config" "github.com/NodeOps-app/createos-cli/internal/terminal" ) @@ -259,12 +258,12 @@ func runShellPTY(c *cli.Context, id, ref string) error { if ctrlURL == "" { ctrlURL = api.DefaultSandboxBaseURL } - token, err := loadAPIToken() + authHeader, token, err := sandboxAuth(c) if err != nil { return err } - conn, err := dialControlUpgrade(c.Context, ctrlURL, token, "/v1/sandboxes/"+id+"/shell") + conn, err := dialControlUpgrade(c.Context, ctrlURL, authHeader, token, "/v1/sandboxes/"+id+"/shell") if err != nil { return err } @@ -353,7 +352,7 @@ func writeFrame(w io.Writer, mu *sync.Mutex, typ byte, payload []byte) error { // Upgrade handshake, and returns the raw connection on a 101 reply. // Used by the keyless PTY path — same wire shape as the tunnel bridge // but with a different target path. -func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.Conn, error) { +func dialControlUpgrade(ctx context.Context, ctrlURL, authHeader, token, path string) (net.Conn, error) { u, err := url.Parse(ctrlURL) if err != nil { return nil, fmt.Errorf("bad sandbox URL %q: %w", ctrlURL, err) @@ -383,7 +382,7 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, token, path string) (net.C req := "POST " + path + " HTTP/1.1\r\n" + "Host: " + u.Host + "\r\n" + - "X-Api-Key: " + token + "\r\n" + + authHeader + ": " + token + "\r\n" + "Connection: Upgrade\r\n" + "Upgrade: tcp-tunnel\r\n" + "Content-Length: 0\r\n\r\n" @@ -458,7 +457,7 @@ func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, if ctrlURL == "" { ctrlURL = api.DefaultSandboxBaseURL } - token, err := loadAPIToken() + authHeader, token, err := sandboxAuth(c) if err != nil { return nil, err } @@ -479,23 +478,25 @@ func startTunnelBridge(parent context.Context, c *cli.Context, sandboxID string, if err != nil { return } - go bridgeOne(ctx, ctrlURL, token, sandboxID, remotePort, conn) + go bridgeOne(ctx, ctrlURL, authHeader, token, sandboxID, remotePort, conn) } }() return b, nil } -// loadAPIToken pulls the user's token the same way the root Before -// hook does — OAuth session if present, else the static api-key file. -// We re-read because the Resty client doesn't expose the raw value. -func loadAPIToken() (string, error) { - if config.HasOAuthSession() { - sess, err := config.LoadOAuthSession() - if err == nil && sess != nil && sess.AccessToken != "" { - return sess.AccessToken, nil - } +// sandboxAuth returns the auth header name and current token from the +// sandbox client the root Before hook already built and stored in +// metadata — the single source of truth for credentials. The raw +// HTTP-Upgrade paths reuse it so they authenticate exactly like every +// other sandbox call: the right header for the auth type (X-Access-Token +// for OAuth, X-Api-Key for an api key) and the already-refreshed token. +func sandboxAuth(c *cli.Context) (header, token string, err error) { + sc, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return "", "", fmt.Errorf("you're not signed in — run 'createos login' to get started") } - return config.LoadToken() + header, token = sc.AuthHeader() + return header, token, nil } // bridgeOne handles a single accepted local connection: it opens an @@ -504,9 +505,9 @@ func loadAPIToken() (string, error) { // not just one — because SSH negotiation interleaves writes from both // peers, and closing early on a transient half-drain truncates the // handshake mid-flight and looks like an auth failure. -func bridgeOne(ctx context.Context, ctrlURL, token, id string, port int, local net.Conn) { +func bridgeOne(ctx context.Context, ctrlURL, authHeader, token, id string, port int, local net.Conn) { defer func() { _ = local.Close() }() //nolint:errcheck - remote, err := dialControlTunnel(ctx, ctrlURL, token, id, port) + remote, err := dialControlTunnel(ctx, ctrlURL, authHeader, token, id, port) if err != nil { return } @@ -542,7 +543,7 @@ func bridgeOne(ctx context.Context, ctrlURL, token, id string, port int, local n // protocol by hand: POST `/v1/sandboxes/:id/tunnel/:port` with the // Upgrade headers, watch for 101 Switching Protocols, then return a // net.Conn that carries only tunnel bytes from then on. -func dialControlTunnel(ctx context.Context, ctrlURL, token, id string, port int) (net.Conn, error) { +func dialControlTunnel(ctx context.Context, ctrlURL, authHeader, token, id string, port int) (net.Conn, error) { u, err := url.Parse(ctrlURL) if err != nil { return nil, fmt.Errorf("bad sandbox URL %q: %w", ctrlURL, err) @@ -574,9 +575,9 @@ func dialControlTunnel(ctx context.Context, ctrlURL, token, id string, port int) } req := fmt.Sprintf("POST /v1/sandboxes/%s/tunnel/%d HTTP/1.1\r\n"+ - "Host: %s\r\nX-Api-Key: %s\r\n"+ + "Host: %s\r\n%s: %s\r\n"+ "Connection: Upgrade\r\nUpgrade: tcp-tunnel\r\nContent-Length: 0\r\n\r\n", - id, port, u.Host, token) + id, port, u.Host, authHeader, token) if _, err = conn.Write([]byte(req)); err != nil { _ = conn.Close() //nolint:errcheck return nil, err diff --git a/cmd/sandbox/tunnel.go b/cmd/sandbox/tunnel.go index 73c16ae..230042a 100644 --- a/cmd/sandbox/tunnel.go +++ b/cmd/sandbox/tunnel.go @@ -164,7 +164,7 @@ func runTunnel(c *cli.Context) error { if ctrlURL == "" { ctrlURL = api.DefaultSandboxBaseURL } - token, err := loadAPIToken() + authHeader, token, err := sandboxAuth(c) if err != nil { return err } @@ -190,6 +190,6 @@ func runTunnel(c *cli.Context) error { // Closed by signal handler or local error → done. return nil } - go bridgeOne(c.Context, ctrlURL, token, id, remote, conn) + go bridgeOne(c.Context, ctrlURL, authHeader, token, id, remote, conn) } } diff --git a/internal/api/sandbox_client.go b/internal/api/sandbox_client.go index 23298bf..158af61 100644 --- a/internal/api/sandbox_client.go +++ b/internal/api/sandbox_client.go @@ -16,6 +16,18 @@ const DefaultSandboxBaseURL = "https://fc-spawn.bhautik.in" // is not accepted on user-facing routes). type SandboxClient struct { Client *resty.Client + // authHeader is the header name this client sends its credential + // under (X-Api-Key for an api key, X-Access-Token for an OAuth JWT). + authHeader string +} + +// AuthHeader returns the header name and the current token this client +// authenticates with. The hand-rolled HTTP-Upgrade streaming paths +// (sandbox shell PTY, tunnel, sync) reuse this so they send the same +// header and the same (refresh-rotated) token as every other sandbox +// call, instead of re-deriving credentials and hardcoding X-Api-Key. +func (c *SandboxClient) AuthHeader() (header, token string) { + return c.authHeader, c.Client.Header.Get(c.authHeader) } // NewSandboxClient builds a SandboxClient for API-key auth. Empty url @@ -56,7 +68,7 @@ func newSandboxClient(authHeader, token, sandboxURL string, debug bool, refreshe }) } installAuthRefresh(client, authHeader, refresher) - return SandboxClient{Client: client} + return SandboxClient{Client: client, authHeader: authHeader} } // SandboxClientKey is the cli.Context metadata key for the sandbox client.