Skip to content

Commit 67ae0c5

Browse files
committed
feat: add resource pool CLI support
1 parent b7dd9f1 commit 67ae0c5

4 files changed

Lines changed: 300 additions & 0 deletions

File tree

cmd/instances/deploy.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var (
2727
sshKeyFlag string
2828
billingCycleFlag string
2929
promoFlag string
30+
poolFlag string
3031
yesFlag bool
3132
jsonFlag bool
3233
)
@@ -57,6 +58,9 @@ Examples:
5758
# Apply a promo code
5859
odo instances deploy --promo LETCLI
5960
61+
# Create a $0 VM inside a Hostodo capacity subscription
62+
odo instances deploy --pool pool::abc123 --os "Ubuntu 24.04" --region DET01 --plan EPYC-2G1C32GN --hostname web-1 --yes
63+
6064
# JSON output (requires all selection flags)
6165
odo instances deploy --os "Ubuntu 22.04" --region "Los Angeles" --plan KVM-2G --json`,
6266
RunE: runDeploy,
@@ -70,6 +74,7 @@ func init() {
7074
DeployCmd.Flags().StringVar(&sshKeyFlag, "ssh-key", "", "SSH key name to use for authentication")
7175
DeployCmd.Flags().StringVar(&billingCycleFlag, "billing-cycle", "", "Billing cycle (monthly, annually, semiannually, biennially, triennially)")
7276
DeployCmd.Flags().StringVar(&promoFlag, "promo", "", "Promo code for a discount")
77+
DeployCmd.Flags().StringVar(&poolFlag, "pool", "", "Create a $0 VM inside a Hostodo capacity pool_id")
7378
DeployCmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Skip confirmation prompt")
7479
DeployCmd.Flags().BoolVar(&jsonFlag, "json", false, "JSON output mode (requires --os, --region, --plan)")
7580
}
@@ -91,6 +96,10 @@ func runDeploy(cmd *cobra.Command, args []string) error {
9196
return fmt.Errorf("failed to create API client: %w", err)
9297
}
9398

99+
if poolFlag != "" {
100+
return runDeployInPool(client)
101+
}
102+
94103
// Fetch available options
95104
if !jsonFlag {
96105
fmt.Println("Loading available options...")
@@ -947,3 +956,79 @@ func mapEventMessage(msg string) string {
947956
return ""
948957
}
949958
}
959+
960+
// runDeployInPool creates a $0 capacity VM (no order/invoice/payment).
961+
func runDeployInPool(client *api.Client) error {
962+
if osFlag == "" || regionFlag == "" || planFlag == "" || hostnameFlag == "" {
963+
return fmt.Errorf("--pool requires --os, --region, --plan, and --hostname")
964+
}
965+
966+
templates, err := client.ListTemplates()
967+
if err != nil {
968+
return fmt.Errorf("failed to load OS templates: %w", err)
969+
}
970+
regions, err := client.ListRegions()
971+
if err != nil {
972+
return fmt.Errorf("failed to load regions: %w", err)
973+
}
974+
plans, err := client.ListPlans()
975+
if err != nil {
976+
return fmt.Errorf("failed to load plans: %w", err)
977+
}
978+
979+
selectedTemplate, err := selectTemplate(templates, osFlag, true)
980+
if err != nil {
981+
return err
982+
}
983+
selectedRegion, err := selectRegion(regions, regionFlag, true)
984+
if err != nil {
985+
return err
986+
}
987+
var selectedPlan *api.Plan
988+
for i := range plans {
989+
if strings.EqualFold(plans[i].Name, planFlag) {
990+
selectedPlan = &plans[i]
991+
break
992+
}
993+
}
994+
if selectedPlan == nil {
995+
return fmt.Errorf("plan not found: %s", planFlag)
996+
}
997+
998+
req := map[string]interface{}{
999+
"pool_id": poolFlag,
1000+
"hostname": hostnameFlag,
1001+
"region_id": selectedRegion.ID,
1002+
"template_id": selectedTemplate.ID,
1003+
"plan_id": selectedPlan.ID,
1004+
}
1005+
if sshKeyFlag != "" {
1006+
keys, err := client.ListSSHKeys()
1007+
if err != nil {
1008+
return err
1009+
}
1010+
for _, k := range keys {
1011+
if strings.EqualFold(k.Name, sshKeyFlag) {
1012+
req["ssh_key_id"] = k.ID
1013+
break
1014+
}
1015+
}
1016+
}
1017+
1018+
if !yesFlag && !jsonFlag {
1019+
fmt.Printf("Create VM %s in capacity %s for $0? [y/N] ", hostnameFlag, poolFlag)
1020+
var answer string
1021+
fmt.Scanln(&answer)
1022+
if strings.ToLower(strings.TrimSpace(answer)) != "y" && strings.ToLower(strings.TrimSpace(answer)) != "yes" {
1023+
return fmt.Errorf("cancelled")
1024+
}
1025+
}
1026+
1027+
out, err := client.CreatePoolVM(req)
1028+
if err != nil {
1029+
return err
1030+
}
1031+
enc := json.NewEncoder(os.Stdout)
1032+
enc.SetIndent("", " ")
1033+
return enc.Encode(out)
1034+
}

cmd/pools.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
10+
"github.com/hostodo/odo-cli/v2/pkg/api"
11+
"github.com/hostodo/odo-cli/v2/pkg/auth"
12+
"github.com/hostodo/odo-cli/v2/pkg/config"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
poolsJSON bool
18+
poolsSimple bool
19+
)
20+
21+
type poolPlanSummary struct {
22+
Name string `json:"name"`
23+
}
24+
25+
type poolSummary struct {
26+
ID string `json:"id"`
27+
PoolID string `json:"pool_id"`
28+
Status string `json:"status"`
29+
PlanName string `json:"plan_name"`
30+
Plan *poolPlanSummary `json:"plan"`
31+
UsedRAMMB int `json:"used_ram_mb"`
32+
TotalRAMMB int `json:"total_ram_mb"`
33+
UsedDiskGB int `json:"used_disk_gb"`
34+
TotalDiskGB int `json:"total_disk_gb"`
35+
UsedInstances int `json:"used_instances"`
36+
MaxInstances int `json:"max_instances"`
37+
}
38+
39+
type poolsListResponse struct {
40+
Count int `json:"count"`
41+
Results []poolSummary `json:"results"`
42+
}
43+
44+
var poolsCmd = &cobra.Command{
45+
Use: "pools",
46+
Short: "Manage Hostodo capacity subscriptions",
47+
Long: "List and inspect Hostodo capacity subscriptions (resource pools).",
48+
}
49+
50+
var poolsListCmd = &cobra.Command{
51+
Use: "list",
52+
Short: "List capacity subscriptions",
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
return runPoolsList()
55+
},
56+
}
57+
58+
var poolsShowCmd = &cobra.Command{
59+
Use: "show <pool_id>",
60+
Short: "Show a capacity subscription",
61+
Args: cobra.ExactArgs(1),
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
return runPoolsShow(args[0])
64+
},
65+
}
66+
67+
func init() {
68+
poolsCmd.PersistentFlags().BoolVar(&poolsJSON, "json", false, "JSON output")
69+
poolsCmd.PersistentFlags().BoolVar(&poolsSimple, "simple", false, "simple table output")
70+
poolsCmd.AddCommand(poolsListCmd)
71+
poolsCmd.AddCommand(poolsShowCmd)
72+
}
73+
74+
func poolsClient() (*api.Client, error) {
75+
cfg, err := config.Load()
76+
if err != nil {
77+
return nil, err
78+
}
79+
if !auth.IsAuthenticated() {
80+
return nil, api.ErrNotAuthenticated
81+
}
82+
return api.NewClient(cfg)
83+
}
84+
85+
func runPoolsList() error {
86+
client, err := poolsClient()
87+
if err != nil {
88+
return err
89+
}
90+
resp, err := client.Get("/client/resource-pools/")
91+
if err != nil {
92+
return err
93+
}
94+
defer resp.Body.Close()
95+
body, err := io.ReadAll(resp.Body)
96+
if err != nil {
97+
return err
98+
}
99+
if resp.StatusCode >= 400 {
100+
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
101+
}
102+
if poolsSimple && !poolsJSON {
103+
var payload poolsListResponse
104+
if err := json.Unmarshal(body, &payload); err != nil {
105+
return err
106+
}
107+
printPoolsSimple(payload.Results)
108+
return nil
109+
}
110+
return printPrettyJSON(body)
111+
}
112+
113+
func runPoolsShow(poolID string) error {
114+
client, err := poolsClient()
115+
if err != nil {
116+
return err
117+
}
118+
resp, err := client.Get("/client/resource-pools/" + poolID + "/")
119+
if err != nil {
120+
return err
121+
}
122+
defer resp.Body.Close()
123+
body, err := io.ReadAll(resp.Body)
124+
if err != nil {
125+
return err
126+
}
127+
if resp.StatusCode >= 400 {
128+
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
129+
}
130+
if poolsSimple && !poolsJSON {
131+
var pool poolSummary
132+
if err := json.Unmarshal(body, &pool); err != nil {
133+
return err
134+
}
135+
printPoolsSimple([]poolSummary{pool})
136+
return nil
137+
}
138+
return printPrettyJSON(body)
139+
}
140+
141+
func printPrettyJSON(body []byte) error {
142+
var out bytes.Buffer
143+
if err := json.Indent(&out, body, "", " "); err != nil {
144+
return err
145+
}
146+
out.WriteByte('\n')
147+
_, err := out.WriteTo(os.Stdout)
148+
return err
149+
}
150+
151+
func printPoolsSimple(pools []poolSummary) {
152+
if len(pools) == 0 {
153+
fmt.Println("No capacity subscriptions found.")
154+
return
155+
}
156+
fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n", "POOL", "STATUS", "PLAN", "RAM_MB", "DISK_GB", "VMS")
157+
for _, pool := range pools {
158+
fmt.Printf("%-18s %-12s %-14s %-12s %-12s %-8s\n",
159+
poolIdentifier(pool),
160+
valueOrDash(pool.Status),
161+
poolPlanName(pool),
162+
fmt.Sprintf("%d/%d", pool.UsedRAMMB, pool.TotalRAMMB),
163+
fmt.Sprintf("%d/%d", pool.UsedDiskGB, pool.TotalDiskGB),
164+
fmt.Sprintf("%d/%d", pool.UsedInstances, pool.MaxInstances),
165+
)
166+
}
167+
}
168+
169+
func poolIdentifier(pool poolSummary) string {
170+
if pool.PoolID != "" {
171+
return pool.PoolID
172+
}
173+
return valueOrDash(pool.ID)
174+
}
175+
176+
func poolPlanName(pool poolSummary) string {
177+
if pool.PlanName != "" {
178+
return pool.PlanName
179+
}
180+
if pool.Plan != nil && pool.Plan.Name != "" {
181+
return pool.Plan.Name
182+
}
183+
return "-"
184+
}
185+
186+
func valueOrDash(value string) string {
187+
if value == "" {
188+
return "-"
189+
}
190+
return value
191+
}

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Billing:
4444
odo invoices # List your invoices
4545
odo pay <invoice-id> # Pay an invoice
4646
47+
Capacity:
48+
odo pools list # List capacity subscriptions
49+
odo pools show <pool-id> # Show capacity quota and usage
50+
4751
Support:
4852
odo tickets list # List support tickets
4953
odo tickets open <subject> # Open a support ticket
@@ -97,6 +101,9 @@ func init() {
97101
rootCmd.AddCommand(invoicesCmd)
98102
rootCmd.AddCommand(payCmd)
99103

104+
// Capacity subscriptions
105+
rootCmd.AddCommand(poolsCmd)
106+
100107
// Support ticket commands
101108
rootCmd.AddCommand(ticketsCmd)
102109

pkg/api/deploy.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,23 @@ func (c *Client) CreateDeployOrder(req DeployRequest) (*DeployResponse, error) {
134134
return &deployResp, nil
135135
}
136136

137+
// CreatePoolVM creates a $0 VM inside a Hostodo capacity subscription.
138+
func (c *Client) CreatePoolVM(req map[string]interface{}) (map[string]interface{}, error) {
139+
path := "/client/instances/create_in_pool/"
140+
141+
resp, err := c.Post(path, req)
142+
if err != nil {
143+
return nil, err
144+
}
145+
146+
var out map[string]interface{}
147+
if err := parseResponse(resp, &out); err != nil {
148+
return nil, err
149+
}
150+
151+
return out, nil
152+
}
153+
137154
// CheckHostnameExists checks if a hostname is already in use
138155
func (c *Client) CheckHostnameExists(hostname string) (bool, error) {
139156
// Use ListInstances to get all instances and check for hostname collision

0 commit comments

Comments
 (0)