Skip to content

Commit 2734903

Browse files
feat: added vms cli commands
1 parent 4d35964 commit 2734903

10 files changed

Lines changed: 1112 additions & 0 deletions

File tree

cmd/root/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/NodeOps-app/createos-cli/cmd/skills"
1717
"github.com/NodeOps-app/createos-cli/cmd/users"
1818
versioncmd "github.com/NodeOps-app/createos-cli/cmd/version"
19+
"github.com/NodeOps-app/createos-cli/cmd/vms"
1920
"github.com/NodeOps-app/createos-cli/internal/api"
2021
"github.com/NodeOps-app/createos-cli/internal/config"
2122
"github.com/NodeOps-app/createos-cli/internal/intro"
@@ -109,6 +110,7 @@ func NewApp() *cli.App {
109110
fmt.Println(" projects Manage projects")
110111
fmt.Println(" skills Manage skills")
111112
fmt.Println(" users Manage your user account")
113+
fmt.Println(" vms Manage VM terminal instances")
112114
fmt.Println(" whoami Show the currently authenticated user")
113115
} else {
114116
fmt.Println(" login Authenticate with CreateOS")
@@ -129,6 +131,7 @@ func NewApp() *cli.App {
129131
projects.NewProjectsCommand(),
130132
skills.NewSkillsCommand(),
131133
users.NewUsersCommand(),
134+
vms.NewVMsCommand(),
132135
auth.NewWhoamiCommand(),
133136
versioncmd.NewVersionCommand(),
134137
},

cmd/vms/vms.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Package vms provides VM terminal management commands.
2+
package vms
3+
4+
import "github.com/urfave/cli/v2"
5+
6+
// NewVMsCommand creates the vms command group.
7+
func NewVMsCommand() *cli.Command {
8+
return &cli.Command{
9+
Name: "vms",
10+
Usage: "Manage VM terminal instances",
11+
Subcommands: []*cli.Command{
12+
newVMDeployCommand(),
13+
newVMGetCommand(),
14+
newVMListCommand(),
15+
newVMRebootCommand(),
16+
newVMResizeCommand(),
17+
newVMSSHCommand(),
18+
newVMTerminateCommand(),
19+
},
20+
}
21+
}

cmd/vms/vms_deploy.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package vms
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/pterm/pterm"
9+
"github.com/urfave/cli/v2"
10+
11+
"github.com/NodeOps-app/createos-cli/internal/api"
12+
"github.com/NodeOps-app/createos-cli/internal/terminal"
13+
)
14+
15+
func newVMDeployCommand() *cli.Command {
16+
return &cli.Command{
17+
Name: "deploy",
18+
Usage: "Deploy a new VM terminal instance",
19+
Flags: []cli.Flag{
20+
&cli.StringFlag{
21+
Name: "name",
22+
Usage: "Optional name for the VM",
23+
},
24+
&cli.StringFlag{
25+
Name: "zone",
26+
Usage: "Deployment zone (e.g. nyc3, sfo3, sgp1)",
27+
},
28+
&cli.IntFlag{
29+
Name: "size",
30+
Usage: "VM size index from the available sizes list (1-based)",
31+
},
32+
&cli.StringSliceFlag{
33+
Name: "ssh-key",
34+
Usage: "SSH public key to add (repeatable, optional)",
35+
},
36+
},
37+
Action: func(c *cli.Context) error {
38+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
39+
if !ok {
40+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
41+
}
42+
43+
// Fetch available sizes from API
44+
sizes, err := client.GetVMSizes()
45+
if err != nil {
46+
return fmt.Errorf("could not fetch available VM sizes: %w", err)
47+
}
48+
if len(sizes) == 0 {
49+
return fmt.Errorf("no VM sizes are currently available")
50+
}
51+
52+
var name, zone string
53+
var sshKeys []string
54+
var size api.VMSize
55+
56+
isInteractive := terminal.IsInteractive()
57+
hasFlags := c.IsSet("size") || c.IsSet("zone")
58+
59+
if isInteractive && !hasFlags {
60+
// Fetch zones from API
61+
zones, err := client.GetDOZones()
62+
if err != nil {
63+
return fmt.Errorf("could not fetch available zones: %w", err)
64+
}
65+
66+
// Name
67+
nameInput, err := pterm.DefaultInteractiveTextInput.
68+
WithDefaultText("VM name (optional, press Enter to skip)").
69+
Show()
70+
if err != nil {
71+
return fmt.Errorf("could not read name: %w", err)
72+
}
73+
name = strings.TrimSpace(nameInput)
74+
75+
// Zone
76+
zoneOptions := make([]string, len(zones))
77+
for i, z := range zones {
78+
zoneOptions[i] = fmt.Sprintf("%-6s %s, %s", z.Name, z.Region, z.Country)
79+
}
80+
zoneSelected, err := pterm.DefaultInteractiveSelect.
81+
WithOptions(zoneOptions).
82+
WithDefaultText("Select a zone").
83+
Show()
84+
if err != nil {
85+
return fmt.Errorf("could not read zone: %w", err)
86+
}
87+
zone = strings.SplitN(strings.TrimSpace(zoneSelected), " ", 2)[0]
88+
89+
// Size
90+
sizeOptions := make([]string, len(sizes))
91+
for i, s := range sizes {
92+
sizeOptions[i] = formatVMSize(i+1, s)
93+
}
94+
sizeSelected, err := pterm.DefaultInteractiveSelect.
95+
WithOptions(sizeOptions).
96+
WithDefaultText("Select a VM size").
97+
Show()
98+
if err != nil {
99+
return fmt.Errorf("could not read size: %w", err)
100+
}
101+
size = sizes[indexFromOption(sizeSelected, sizeOptions)]
102+
103+
// SSH keys (optional)
104+
addKey, err := pterm.DefaultInteractiveConfirm.
105+
WithDefaultText("Add an SSH public key?").
106+
WithDefaultValue(false).
107+
Show()
108+
if err != nil {
109+
return fmt.Errorf("could not read confirmation: %w", err)
110+
}
111+
for addKey {
112+
keyInput, err := pterm.DefaultInteractiveTextInput.
113+
WithDefaultText("Paste your SSH public key").
114+
Show()
115+
if err != nil {
116+
return fmt.Errorf("could not read SSH key: %w", err)
117+
}
118+
key := strings.TrimSpace(keyInput)
119+
if key != "" {
120+
sshKeys = append(sshKeys, key)
121+
}
122+
addKey, err = pterm.DefaultInteractiveConfirm.
123+
WithDefaultText("Add another SSH key?").
124+
WithDefaultValue(false).
125+
Show()
126+
if err != nil {
127+
return fmt.Errorf("could not read confirmation: %w", err)
128+
}
129+
}
130+
131+
// Summary
132+
pterm.Info.Println("Deploying VM with the following configuration:")
133+
fmt.Printf(" Zone: %s\n", zone)
134+
fmt.Printf(" Size: %s\n", formatVMSize(0, size))
135+
fmt.Printf(" SSH Keys: %d key(s)\n", len(sshKeys))
136+
if name != "" {
137+
fmt.Printf(" Name: %s\n", name)
138+
}
139+
fmt.Println()
140+
} else {
141+
// Non-TTY or flags provided
142+
name = c.String("name")
143+
zone = c.String("zone")
144+
if zone == "" {
145+
return fmt.Errorf("please provide a zone with --zone\n\n Example:\n createos vms deploy --size 1 --zone nyc3")
146+
}
147+
148+
sizeIndex := c.Int("size")
149+
if sizeIndex == 0 {
150+
sizeList := vmSizeList(sizes)
151+
return fmt.Errorf("please provide a size index with --size\n\n%s\n\n Example:\n createos vms deploy --size 1 --zone nyc3", sizeList)
152+
}
153+
if sizeIndex < 1 || sizeIndex > len(sizes) {
154+
return fmt.Errorf("size index %d is out of range (1–%d)", sizeIndex, len(sizes))
155+
}
156+
size = sizes[sizeIndex-1]
157+
sshKeys = c.StringSlice("ssh-key")
158+
}
159+
160+
pterm.Info.Println("VM creation can take 5–15 minutes. Please wait...")
161+
fmt.Println()
162+
163+
spinner, err := pterm.DefaultSpinner.Start("Deploying your VM...")
164+
if err != nil {
165+
return fmt.Errorf("could not start spinner: %w", err)
166+
}
167+
168+
vm, err := client.CreateVMDeployment(name, zone, sshKeys, size)
169+
if err != nil {
170+
spinner.Fail("Deployment request failed")
171+
return err
172+
}
173+
174+
// Poll until deployed or terminated (max ~10 minutes)
175+
vmID := vm.ID
176+
const maxPolls = 200
177+
for i := 0; i < maxPolls; i++ {
178+
time.Sleep(3 * time.Second)
179+
180+
updated, err := client.GetVMDeployment(vmID)
181+
if err != nil {
182+
spinner.Fail("Failed to check deployment status")
183+
return err
184+
}
185+
186+
if updated.Status == "deployed" {
187+
spinner.Success("VM deployed successfully!")
188+
fmt.Println()
189+
pterm.Success.Printf("VM ID: %s\n", updated.ID)
190+
if updated.Extra.IPAddress != "" {
191+
pterm.Success.Printf("IP Address: %s\n", updated.Extra.IPAddress)
192+
}
193+
fmt.Println()
194+
pterm.Println(pterm.Gray(" Connect via SSH: createos vms ssh " + updated.ID))
195+
return nil
196+
}
197+
198+
if updated.Status == "terminated" {
199+
spinner.Fail("VM deployment was terminated unexpectedly.")
200+
return fmt.Errorf("VM deployment failed — the VM was terminated during provisioning")
201+
}
202+
}
203+
204+
spinner.Warning("Deployment is taking longer than expected.")
205+
fmt.Println()
206+
pterm.Println(pterm.Gray(" Check the status with: createos vms get " + vmID))
207+
return nil
208+
},
209+
}
210+
}
211+
212+
// formatVMSize returns a human-readable label for a VM size.
213+
// Pass index=0 to omit the index prefix.
214+
func formatVMSize(index int, s api.VMSize) string {
215+
cpu := s.CPU / 1000
216+
if cpu == 0 {
217+
cpu = 1
218+
}
219+
if index > 0 {
220+
return fmt.Sprintf("[%d] %d vCPU, %d MiB RAM, %d MiB disk", index, cpu, s.MemoryMiB, s.DiskMiB)
221+
}
222+
return fmt.Sprintf("%d vCPU, %d MiB RAM, %d MiB disk", cpu, s.MemoryMiB, s.DiskMiB)
223+
}
224+
225+
// vmSizeList returns a formatted list of sizes for error messages.
226+
func vmSizeList(sizes []api.VMSize) string {
227+
var sb strings.Builder
228+
sb.WriteString(" Available sizes:\n")
229+
for i, s := range sizes {
230+
sb.WriteString(" " + formatVMSize(i+1, s) + "\n")
231+
}
232+
return sb.String()
233+
}
234+
235+
// indexFromOption returns the 0-based index of the selected option.
236+
func indexFromOption(selected string, options []string) int {
237+
for i, o := range options {
238+
if o == selected {
239+
return i
240+
}
241+
}
242+
return 0
243+
}

cmd/vms/vms_get.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package vms
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+
)
11+
12+
func newVMGetCommand() *cli.Command {
13+
return &cli.Command{
14+
Name: "get",
15+
Usage: "Get details for a VM instance",
16+
ArgsUsage: "<vm-id>",
17+
Action: func(c *cli.Context) error {
18+
if c.NArg() == 0 {
19+
return fmt.Errorf("please provide a VM ID\n\n To see your VMs and their IDs, run:\n createos vms list")
20+
}
21+
22+
client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient)
23+
if !ok {
24+
return fmt.Errorf("you're not signed in — run 'createos login' to get started")
25+
}
26+
27+
id := c.Args().First()
28+
vm, err := client.GetVMDeployment(id)
29+
if err != nil {
30+
return err
31+
}
32+
33+
cyan := pterm.NewStyle(pterm.FgCyan)
34+
35+
cyan.Printf("ID: ")
36+
fmt.Println(vm.ID)
37+
38+
cyan.Printf("Name: ")
39+
if vm.Name != nil {
40+
fmt.Println(*vm.Name)
41+
} else {
42+
fmt.Println("-")
43+
}
44+
45+
cyan.Printf("Status: ")
46+
fmt.Println(vm.Status)
47+
48+
cyan.Printf("IP Address: ")
49+
if vm.Extra.IPAddress != "" {
50+
fmt.Println(vm.Extra.IPAddress)
51+
} else {
52+
fmt.Println("-")
53+
}
54+
55+
cyan.Printf("Created At: ")
56+
fmt.Println(vm.CreatedAt.Format("2006-01-02 15:04:05"))
57+
58+
cyan.Printf("Updated At: ")
59+
fmt.Println(vm.UpdatedAt.Format("2006-01-02 15:04:05"))
60+
61+
fmt.Println()
62+
if vm.Status == "deployed" && vm.Extra.IPAddress != "" {
63+
pterm.Println(pterm.Gray(" To connect via SSH: createos vms ssh " + vm.ID))
64+
} else if vm.Status == "deploying" {
65+
pterm.Println(pterm.Gray(" Your VM is still deploying. Check status with: createos vms get " + vm.ID))
66+
}
67+
68+
return nil
69+
},
70+
}
71+
}

0 commit comments

Comments
 (0)