Skip to content

Commit 228496d

Browse files
feat: sandbox
1 parent d70eeae commit 228496d

32 files changed

Lines changed: 7705 additions & 0 deletions

cmd/root/root.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/NodeOps-app/createos-cli/cmd/oauth"
2121
"github.com/NodeOps-app/createos-cli/cmd/open"
2222
"github.com/NodeOps-app/createos-cli/cmd/projects"
23+
"github.com/NodeOps-app/createos-cli/cmd/sandbox"
2324
"github.com/NodeOps-app/createos-cli/cmd/scale"
2425
"github.com/NodeOps-app/createos-cli/cmd/skills"
2526
"github.com/NodeOps-app/createos-cli/cmd/status"
@@ -56,6 +57,18 @@ func NewApp() *cli.App {
5657
EnvVars: []string{"CREATEOS_API_URL"},
5758
Value: api.DefaultBaseURL,
5859
},
60+
&cli.StringFlag{
61+
Name: "sandbox-api-url",
62+
Usage: "Override the sandbox (fc-spawn) base URL",
63+
EnvVars: []string{"CREATEOS_SANDBOX_URL"},
64+
Value: api.DefaultSandboxBaseURL,
65+
},
66+
&cli.StringFlag{
67+
Name: "sandbox-gateway",
68+
Usage: "SSH gateway address (<host:port>) used by `sandbox shell`",
69+
EnvVars: []string{"CREATEOS_SANDBOX_GATEWAY"},
70+
Value: "65.109.104.247:2222",
71+
},
5972
&cli.StringFlag{
6073
Name: "output",
6174
Aliases: []string{"o"},
@@ -121,6 +134,11 @@ func NewApp() *cli.App {
121134
}
122135
client := api.NewClientWithAccessToken(session.AccessToken, c.String("api-url"), c.Bool("debug"))
123136
c.App.Metadata[api.ClientKey] = &client
137+
// Sandbox API (fc-spawn) reuses the same access token —
138+
// the token is validated against the shared NodeOps
139+
// auth service on the server side.
140+
sandboxClient := api.NewSandboxClient(session.AccessToken, c.String("sandbox-api-url"), c.Bool("debug"))
141+
c.App.Metadata[api.SandboxClientKey] = &sandboxClient
124142
return nil
125143
}
126144
}
@@ -132,6 +150,8 @@ func NewApp() *cli.App {
132150
}
133151
client := api.NewClient(token, c.String("api-url"), c.Bool("debug"))
134152
c.App.Metadata[api.ClientKey] = &client
153+
sandboxClient := api.NewSandboxClient(token, c.String("sandbox-api-url"), c.Bool("debug"))
154+
c.App.Metadata[api.SandboxClientKey] = &sandboxClient
135155
return nil
136156
},
137157
Action: func(_ *cli.Context) error {
@@ -151,6 +171,7 @@ func NewApp() *cli.App {
151171
fmt.Println(" oauth-clients Manage OAuth clients")
152172
fmt.Println(" open Open project URL or dashboard in browser")
153173
fmt.Println(" projects Manage projects")
174+
fmt.Println(" sandbox Manage sandboxes")
154175
fmt.Println(" scale Adjust replicas and resources")
155176
fmt.Println(" skills Manage skills")
156177
fmt.Println(" status Show project health and deployment status")
@@ -186,6 +207,7 @@ func NewApp() *cli.App {
186207
oauth.NewOAuthCommand(),
187208
open.NewOpenCommand(),
188209
projects.NewProjectsCommand(),
210+
sandbox.NewSandboxCommand(),
189211
scale.NewScaleCommand(),
190212
skills.NewSkillsCommand(),
191213
status.NewStatusCommand(),

cmd/sandbox/bandwidth.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package sandbox
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// parseSizeBytes accepts "5GB", "500MB", "1024" etc. (decimal SI units
10+
// — 1 KB = 1000 bytes) plus binary suffixes (KiB/MiB/GiB/TiB). Pure
11+
// digits are treated as raw bytes. Used by `sandbox edit`'s bandwidth
12+
// top-up step and any future caller that needs human size parsing.
13+
func parseSizeBytes(in string) (int64, error) {
14+
s := strings.TrimSpace(strings.ToUpper(in))
15+
if s == "" {
16+
return 0, fmt.Errorf("empty amount")
17+
}
18+
type unit struct {
19+
suffix string
20+
mul int64
21+
}
22+
for _, u := range []unit{
23+
{"TIB", 1 << 40},
24+
{"GIB", 1 << 30},
25+
{"MIB", 1 << 20},
26+
{"KIB", 1 << 10},
27+
{"TB", 1_000_000_000_000},
28+
{"GB", 1_000_000_000},
29+
{"MB", 1_000_000},
30+
{"KB", 1_000},
31+
{"T", 1_000_000_000_000},
32+
{"G", 1_000_000_000},
33+
{"M", 1_000_000},
34+
{"K", 1_000},
35+
{"B", 1},
36+
} {
37+
if strings.HasSuffix(s, u.suffix) {
38+
num := strings.TrimSpace(strings.TrimSuffix(s, u.suffix))
39+
f, err := strconv.ParseFloat(num, 64)
40+
if err != nil {
41+
return 0, fmt.Errorf("could not read %q as a number", num)
42+
}
43+
return int64(f * float64(u.mul)), nil
44+
}
45+
}
46+
n, err := strconv.ParseInt(s, 10, 64)
47+
if err != nil {
48+
return 0, fmt.Errorf("could not read %q — try a value like 5GB or a raw byte count", in)
49+
}
50+
return n, nil
51+
}

cmd/sandbox/catalog.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package sandbox
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pterm/pterm"
7+
"github.com/urfave/cli/v2"
8+
9+
"github.com/NodeOps-app/createos-cli/internal/api"
10+
"github.com/NodeOps-app/createos-cli/internal/output"
11+
)
12+
13+
// newShapesCommand lists the static VM size catalog.
14+
func newShapesCommand() *cli.Command {
15+
return &cli.Command{
16+
Name: "shapes",
17+
Usage: "List the available sandbox sizes (vCPU / RAM / disk)",
18+
Action: runShapes,
19+
}
20+
}
21+
22+
func runShapes(c *cli.Context) error {
23+
client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient)
24+
if !ok {
25+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
26+
}
27+
shapes, err := client.ListShapes(c.Context)
28+
if err != nil {
29+
return err
30+
}
31+
output.Render(c, shapes, func() {
32+
if len(shapes) == 0 {
33+
fmt.Println("No sizes available.")
34+
return
35+
}
36+
table := pterm.TableData{{"ID", "vCPU", "RAM", "Default disk"}}
37+
for _, s := range shapes {
38+
table = append(table, []string{
39+
s.ID,
40+
fmt.Sprintf("%d", s.VCPU),
41+
fmt.Sprintf("%d MB", s.MemMib),
42+
fmt.Sprintf("%d MB", s.DefaultDiskMib),
43+
})
44+
}
45+
_ = pterm.DefaultTable.WithHasHeader().WithData(table).Render()
46+
pterm.Println()
47+
pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --shape <id>"))
48+
})
49+
return nil
50+
}
51+
52+
// newRootfsCommand lists the built-in rootfs images that any sandbox
53+
// can boot from. User-built templates are not included here — see
54+
// `sandbox template ls`.
55+
func newRootfsCommand() *cli.Command {
56+
return &cli.Command{
57+
Name: "rootfs",
58+
Aliases: []string{"images"},
59+
Usage: "List the built-in OS images you can boot a sandbox from",
60+
Action: runRootfs,
61+
}
62+
}
63+
64+
func runRootfs(c *cli.Context) error {
65+
client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient)
66+
if !ok {
67+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
68+
}
69+
cat, err := client.ListRootfs(c.Context)
70+
if err != nil {
71+
return err
72+
}
73+
output.Render(c, cat, func() {
74+
if cat == nil || len(cat.Rootfs) == 0 {
75+
fmt.Println("No built-in images available.")
76+
return
77+
}
78+
// Use the per-entry view when the server provides it; otherwise
79+
// just the names.
80+
hasEntries := len(cat.Entries) > 0
81+
if hasEntries {
82+
table := pterm.TableData{{"Name", "Description", "Status"}}
83+
for _, e := range cat.Entries {
84+
status := ""
85+
switch {
86+
case e.Name == cat.Default:
87+
status = "default"
88+
case e.Deprecated:
89+
status = "deprecated"
90+
if e.Successor != "" {
91+
status += " → " + e.Successor
92+
}
93+
}
94+
table = append(table, []string{e.Name, e.Description, status})
95+
}
96+
_ = pterm.DefaultTable.WithHasHeader().WithData(table).Render()
97+
} else {
98+
table := pterm.TableData{{"Name", "Default"}}
99+
for _, name := range cat.Rootfs {
100+
def := ""
101+
if name == cat.Default {
102+
def = "yes"
103+
}
104+
table = append(table, []string{name, def})
105+
}
106+
_ = pterm.DefaultTable.WithHasHeader().WithData(table).Render()
107+
}
108+
pterm.Println()
109+
pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --rootfs <name>"))
110+
pterm.Println(pterm.Gray(" To list your own custom templates: createos sandbox template ls"))
111+
})
112+
return nil
113+
}

0 commit comments

Comments
 (0)