From c2b52af743b5d5b8345b15078d0f3fa67964273b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 04:45:15 +0000 Subject: [PATCH 1/2] feat: add Hostodo resource pool commands to the CLI Mirror the MCP pool tools with odo pools list/show/options, billable buy/upgrade checkout, and $0 create-in-pool VM provisioning. Co-authored-by: me --- cmd/pools/checkout.go | 466 ++++++++++++++++++++++++++++++++ cmd/pools/checkout_test.go | 29 ++ cmd/pools/list.go | 155 +++++++++++ cmd/pools/options.go | 43 +++ cmd/pools/root.go | 81 ++++++ cmd/pools/vm.go | 539 +++++++++++++++++++++++++++++++++++++ cmd/root.go | 12 + pkg/api/client.go | 6 +- pkg/api/errors.go | 62 +++++ pkg/api/models.go | 185 ++++++++++++- pkg/api/pools.go | 131 +++++++++ pkg/api/pools_test.go | 281 +++++++++++++++++++ pkg/ui/formatters.go | 217 +++++++++++++++ pkg/ui/styles.go | 2 +- 14 files changed, 2202 insertions(+), 7 deletions(-) create mode 100644 cmd/pools/checkout.go create mode 100644 cmd/pools/checkout_test.go create mode 100644 cmd/pools/list.go create mode 100644 cmd/pools/options.go create mode 100644 cmd/pools/root.go create mode 100644 cmd/pools/vm.go create mode 100644 pkg/api/errors.go create mode 100644 pkg/api/pools.go create mode 100644 pkg/api/pools_test.go diff --git a/cmd/pools/checkout.go b/cmd/pools/checkout.go new file mode 100644 index 0000000..e581900 --- /dev/null +++ b/cmd/pools/checkout.go @@ -0,0 +1,466 @@ +package pools + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/google/uuid" + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/ui" + "github.com/spf13/cobra" +) + +var ( + poolPlanFlag string + poolBillingCycleFlag string + poolPromoFlag string + poolYesFlag bool + poolCheckoutJSONFlag bool +) + +var buyCmd = &cobra.Command{ + Use: "buy", + Aliases: []string{"create", "new", "subscribe"}, + Short: "Buy a Hostodo pool", + Long: `Buy a Hostodo capacity pool. If you already have an active pool this becomes an upgrade. + +Examples: + odo pools buy + odo pools buy --plan "Hostodo Nano" --billing-cycle monthly --yes + odo pools buy --plan 12 --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCheckout(cmd, args, false) + }, +} + +var upgradeCmd = &cobra.Command{ + Use: "upgrade [pool-id]", + Short: "Upgrade a Hostodo pool", + ValidArgsFunction: completePoolID, + Args: cobra.MaximumNArgs(1), + Long: `Upgrade an existing Hostodo pool to a larger tier. + +Examples: + odo pools upgrade + odo pools upgrade pool::abc --plan "Hostodo Micro" --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCheckout(cmd, args, true) + }, +} + +func init() { + for _, cmd := range []*cobra.Command{buyCmd, upgradeCmd} { + cmd.Flags().StringVar(&poolPlanFlag, "plan", "", "Pool plan name or ID") + cmd.Flags().StringVar(&poolBillingCycleFlag, "billing-cycle", "", "Billing cycle (monthly, annually, semiannually, biennially, triennially)") + cmd.Flags().StringVar(&poolPromoFlag, "promo", "", "Promo code") + cmd.Flags().BoolVarP(&poolYesFlag, "yes", "y", false, "Skip confirmation") + cmd.Flags().BoolVar(&poolCheckoutJSONFlag, "json", false, "JSON output (requires --plan)") + } +} + +func runCheckout(cmd *cobra.Command, args []string, upgrade bool) error { + if poolCheckoutJSONFlag && poolPlanFlag == "" { + return fmt.Errorf("JSON mode requires --plan") + } + + client, err := newAuthenticatedClient() + if err != nil { + return err + } + + options, err := client.ListPoolOptions() + if err != nil { + return fmt.Errorf("failed to list pool options: %w", err) + } + if len(options.Tiers) == 0 { + return fmt.Errorf("no Hostodo pool tiers available") + } + + poolID := "" + if len(args) == 1 { + pool, err := resolvePool(client, args[0]) + if err != nil { + return err + } + poolID = pool.PoolID + } else if options.CurrentPoolID != "" { + poolID = options.CurrentPoolID + } + + if upgrade && poolID == "" { + return fmt.Errorf("no active Hostodo pool to upgrade. Buy one with: odo pools buy") + } + + tiers := options.Tiers + if upgrade { + var upgradeTiers []api.PoolTier + for _, tier := range options.Tiers { + if tier.Flag == "current" || tier.IsCurrent { + continue + } + upgradeTiers = append(upgradeTiers, tier) + } + if len(upgradeTiers) == 0 { + return fmt.Errorf("no upgrade tiers available") + } + tiers = upgradeTiers + } + + selected, err := selectPoolTier(tiers, poolPlanFlag, poolCheckoutJSONFlag) + if err != nil { + return err + } + + cycle, err := selectPoolBillingCycle(options.BillingCycles, selected, poolBillingCycleFlag, poolCheckoutJSONFlag) + if err != nil { + return err + } + + if poolPromoFlag == "" && !poolCheckoutJSONFlag && !poolYesFlag { + poolPromoFlag, err = promptPoolPromo() + if err != nil { + return err + } + } + + quote, err := client.QuotePoolCheckout(api.PoolCheckoutRequest{ + PlanID: selected.ID, + BillingCycle: cycle, + Promocode: poolPromoFlag, + }) + if err != nil { + return fmt.Errorf("failed to quote pool: %w", err) + } + + paymentMethod, err := client.GetDefaultPaymentMethod() + if err != nil { + return fmt.Errorf("failed to get payment method: %w", err) + } + + if !poolYesFlag && !poolCheckoutJSONFlag { + confirmed, err := confirmPoolCheckout(upgrade, selected, cycle, quote, paymentMethod, poolPromoFlag) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled.") + return nil + } + } + + req := api.PoolCheckoutRequest{ + PlanID: selected.ID, + TargetPlanID: selected.ID, + BillingCycle: cycle, + Promocode: poolPromoFlag, + IdempotencyKey: uuid.NewString(), + Confirm: true, + } + if paymentMethod != nil { + req.PaymentMethod = "saved_card" + req.PaymentMethodID = paymentMethod.PaymentMethodID + } else { + req.PaymentMethod = "stripe_checkout" + } + + var result *api.PoolCheckoutResponse + if upgrade { + result, err = client.UpgradeResourcePool(poolID, req) + } else { + result, err = client.CheckoutResourcePool(req) + } + if err != nil { + return fmt.Errorf("failed to %s pool: %w", checkoutVerb(upgrade), err) + } + + if poolCheckoutJSONFlag { + return printJSON(result) + } + + mode := result.Mode + if mode == "" { + if upgrade { + mode = "upgrade" + } else { + mode = "purchase" + } + } + fmt.Println(ui.SuccessStyle.Render(fmt.Sprintf("✓ Pool %s order created", mode))) + if result.PlanName != "" { + fmt.Printf("Plan: %s\n", result.PlanName) + } + if result.OrderNumber != "" { + fmt.Printf("Order: %s\n", result.OrderNumber) + } + if result.InvoiceNumber != "" { + fmt.Printf("Invoice: %s\n", result.InvoiceNumber) + } + if result.AmountDue != "" { + fmt.Printf("Due: $%s\n", result.AmountDue) + } + checkoutURL := result.CheckoutURL + if checkoutURL == "" && result.Checkout != nil { + if url, ok := result.Checkout["url"].(string); ok { + checkoutURL = url + } + } + if checkoutURL != "" { + fmt.Printf("Checkout: %s\n", checkoutURL) + } + fmt.Println("Create a VM with: odo pools vm") + return nil +} + +func checkoutVerb(upgrade bool) string { + if upgrade { + return "upgrade" + } + return "buy" +} + +func selectPoolTier(tiers []api.PoolTier, flag string, jsonMode bool) (*api.PoolTier, error) { + if flag != "" { + tier, err := findPoolTier(tiers, flag) + if err != nil { + return nil, err + } + if tier == nil { + names := make([]string, len(tiers)) + for i, t := range tiers { + names[i] = t.Name + } + return nil, fmt.Errorf("no pool plan matching %q. Available: %s", flag, strings.Join(names, ", ")) + } + return tier, nil + } + if jsonMode { + return nil, fmt.Errorf("JSON mode requires --plan") + } + + options := make([]string, len(tiers)) + indexByOption := map[string]int{} + for i, t := range tiers { + flagLabel := t.Flag + if flagLabel == "" && t.IsCurrent { + flagLabel = "current" + } + if flagLabel != "" { + flagLabel = " [" + flagLabel + "]" + } + options[i] = fmt.Sprintf("[%d] %s $%s/mo %d vCPU, %s RAM, %d GB disk%s", + t.ID, t.Name, t.PriceMonthly, t.TotalVCPU, formatRAMGB(t.RAMMB), t.DiskGB, flagLabel) + indexByOption[options[i]] = i + } + var selected string + err := huh.NewSelect[string](). + Title("Choose a Hostodo pool:"). + Options(huh.NewOptions(options...)...). + Value(&selected). + Height(15). + Run() + if err != nil { + return nil, err + } + idx, ok := indexByOption[selected] + if !ok { + return nil, fmt.Errorf("invalid pool selection") + } + return &tiers[idx], nil +} + +func findPoolTier(tiers []api.PoolTier, name string) (*api.PoolTier, error) { + if id, err := strconv.Atoi(name); err == nil { + for i := range tiers { + if tiers[i].ID == id { + return &tiers[i], nil + } + } + } + for i := range tiers { + if strings.EqualFold(tiers[i].Name, name) { + return &tiers[i], nil + } + } + lower := strings.ToLower(name) + var matches []*api.PoolTier + for i := range tiers { + if strings.Contains(strings.ToLower(tiers[i].Name), lower) { + matches = append(matches, &tiers[i]) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return nil, fmt.Errorf("ambiguous pool plan %q — matches: %s", name, strings.Join(names, ", ")) + } + return nil, nil +} + +func selectPoolBillingCycle(cycles []string, tier *api.PoolTier, flag string, jsonMode bool) (string, error) { + available := filterPricedCycles(cycles, tier) + if len(available) == 0 { + available = []string{"monthly"} + } + if flag != "" { + for _, c := range available { + if strings.EqualFold(c, flag) { + return c, nil + } + } + return "", fmt.Errorf("invalid billing cycle %q. Available: %s", flag, strings.Join(available, ", ")) + } + if jsonMode || len(available) == 1 { + return available[0], nil + } + labels := make([]string, len(available)) + for i, c := range available { + labels[i] = billingCycleLabel(c) + } + var selected string + err := huh.NewSelect[string](). + Title("Choose a billing cycle:"). + Options(huh.NewOptions(labels...)...). + Value(&selected). + Height(10). + Run() + if err != nil { + return "", err + } + for _, c := range available { + if billingCycleLabel(c) == selected { + return c, nil + } + } + return available[0], nil +} + +func filterPricedCycles(cycles []string, tier *api.PoolTier) []string { + if len(cycles) == 0 { + cycles = []string{"monthly", "semiannually", "annually", "biennially", "triennially"} + } + var available []string + for _, cycle := range cycles { + if poolTierHasPricing(tier, cycle) { + available = append(available, cycle) + } + } + return available +} + +func poolTierHasPricing(tier *api.PoolTier, cycle string) bool { + price := poolPriceForCycle(tier, cycle) + return price != "" && price != "0.00" && price != "0" +} + +func poolPriceForCycle(tier *api.PoolTier, cycle string) string { + if tier == nil { + return "" + } + switch cycle { + case "monthly": + return tier.PriceMonthly + case "annually": + return tier.PriceAnnually + case "semiannually": + return tier.PriceSemiannually + case "biennially": + return tier.PriceBiennially + case "triennially": + return tier.PriceTriennially + default: + return tier.PriceMonthly + } +} + +func billingCycleLabel(cycle string) string { + switch cycle { + case "monthly": + return "Monthly" + case "annually": + return "Annually" + case "semiannually": + return "Semi-Annually" + case "biennially": + return "Biennially" + case "triennially": + return "Triennially" + default: + return cycle + } +} + +func promptPoolPromo() (string, error) { + var code string + err := huh.NewInput(). + Title("Promo code (leave blank to skip):"). + Value(&code). + Run() + if err != nil { + return "", err + } + return strings.TrimSpace(code), nil +} + +func confirmPoolCheckout(upgrade bool, tier *api.PoolTier, cycle string, quote *api.PoolQuote, pm *api.PaymentMethod, promo string) (bool, error) { + action := "Buy" + if upgrade || quote.Mode == "upgrade" { + action = "Upgrade" + } + promoLine := "" + if promo != "" { + promoLine = fmt.Sprintf("\n Promo: %s", promo) + } + paymentLine := "Stripe Checkout" + if pm != nil { + paymentLine = fmt.Sprintf("%s ****%s", pm.CardType, pm.LastFour) + } + amount := quote.AmountDueAfterCredit.String() + if amount == "" { + amount = quote.UnitPrice.String() + } + recurring := quote.RecurringAmount.String() + if recurring == "" { + recurring = poolPriceForCycle(tier, cycle) + } + + fmt.Printf(` +%s summary: + Plan: %s + RAM: %s + vCPU: %d (max %d per VM) + Disk: %d GB + VMs: %d + Billing: %s + Due today: $%s + Recurring: $%s + Payment: %s%s + +`, action, tier.Name, formatRAMGB(tier.RAMMB), tier.TotalVCPU, tier.MaxVCPUPerInstance, tier.DiskGB, tier.MaxInstances, billingCycleLabel(cycle), amount, recurring, paymentLine, promoLine) + + confirmed := true + err := huh.NewConfirm(). + Title(fmt.Sprintf("%s this pool for $%s?", action, amount)). + Value(&confirmed). + Run() + if err != nil { + return false, err + } + return confirmed, nil +} + +func formatRAMGB(mb int) string { + if mb <= 0 { + return "0 GB" + } + if mb%1024 == 0 { + return fmt.Sprintf("%d GB", mb/1024) + } + return fmt.Sprintf("%.1f GB", float64(mb)/1024.0) +} diff --git a/cmd/pools/checkout_test.go b/cmd/pools/checkout_test.go new file mode 100644 index 0000000..dd22b17 --- /dev/null +++ b/cmd/pools/checkout_test.go @@ -0,0 +1,29 @@ +package pools + +import ( + "testing" + + "github.com/hostodo/odo-cli/v2/pkg/api" +) + +func TestFindPoolTier_ByIDAndName(t *testing.T) { + tiers := []api.PoolTier{ + {ID: 12, Name: "Hostodo Nano"}, + {ID: 13, Name: "Hostodo Micro"}, + } + + got, err := findPoolTier(tiers, "12") + if err != nil || got == nil || got.Name != "Hostodo Nano" { + t.Fatalf("by id: got %+v err %v", got, err) + } + + got, err = findPoolTier(tiers, "micro") + if err != nil || got == nil || got.ID != 13 { + t.Fatalf("by substring: got %+v err %v", got, err) + } + + _, err = findPoolTier(tiers, "Hostodo") + if err == nil { + t.Fatal("expected ambiguous error") + } +} diff --git a/cmd/pools/list.go b/cmd/pools/list.go new file mode 100644 index 0000000..e28f7b3 --- /dev/null +++ b/cmd/pools/list.go @@ -0,0 +1,155 @@ +package pools + +import ( + "encoding/json" + "fmt" + + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/ui" + "github.com/spf13/cobra" +) + +var ( + poolsJSONFlag bool +) + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List your Hostodo pools", + Long: `List Hostodo pools with quota and usage. + +Examples: + odo pools + odo pools list + odo pools list --json`, + RunE: runList, +} + +var showCmd = &cobra.Command{ + Use: "show [pool-id]", + Aliases: []string{"get", "status"}, + Short: "Show a Hostodo pool and its member VMs", + ValidArgsFunction: completePoolID, + Args: cobra.ExactArgs(1), + RunE: runShow, +} + +func init() { + listCmd.Flags().BoolVar(&poolsJSONFlag, "json", false, "Output as JSON") + showCmd.Flags().BoolVar(&poolsJSONFlag, "json", false, "Output as JSON") +} + +func runList(cmd *cobra.Command, args []string) error { + client, err := newAuthenticatedClient() + if err != nil { + return err + } + + pools, err := client.ListResourcePools() + if err != nil { + return fmt.Errorf("failed to list pools: %w", err) + } + + if len(pools) == 0 { + fmt.Println("No Hostodo pools found.") + fmt.Println("Buy one with: odo pools buy") + return nil + } + + if poolsJSONFlag { + return printJSON(pools) + } + + fmt.Println(ui.FormatPoolsTable(pools)) + return nil +} + +func runShow(cmd *cobra.Command, args []string) error { + client, err := newAuthenticatedClient() + if err != nil { + return err + } + + pool, err := resolvePool(client, args[0]) + if err != nil { + return err + } + + if poolsJSONFlag { + return printJSON(pool) + } + + fmt.Println(ui.FormatPoolDetail(pool)) + return nil +} + +func resolvePool(client *api.Client, identifier string) (*api.ResourcePoolDetail, error) { + pool, err := client.GetResourcePool(identifier) + if err == nil && pool.PoolID != "" { + return pool, nil + } + + pools, listErr := client.ListResourcePools() + if listErr != nil { + if err != nil { + return nil, fmt.Errorf("failed to get pool: %w", err) + } + return nil, listErr + } + + var matches []api.ResourcePool + for _, p := range pools { + if p.PoolID == identifier || p.Label() == identifier { + matches = append(matches, p) + continue + } + if len(identifier) >= 4 && (hasPrefixFold(p.PoolID, identifier) || hasPrefixFold(p.Label(), identifier)) { + matches = append(matches, p) + } + } + if len(matches) == 1 { + return client.GetResourcePool(matches[0].PoolID) + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous pool %q — matches %d pools; use the full pool id", identifier, len(matches)) + } + if err != nil { + return nil, fmt.Errorf("resource pool not found: %s", identifier) + } + return nil, fmt.Errorf("resource pool not found: %s", identifier) +} + +func hasPrefixFold(value, prefix string) bool { + if len(value) < len(prefix) { + return false + } + return equalFold(value[:len(prefix)], prefix) +} + +func equalFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if ca >= 'A' && ca <= 'Z' { + ca += 'a' - 'A' + } + if cb >= 'A' && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} + +func marshalIndent(v interface{}) ([]byte, error) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal JSON: %w", err) + } + return data, nil +} diff --git a/cmd/pools/options.go b/cmd/pools/options.go new file mode 100644 index 0000000..78621c4 --- /dev/null +++ b/cmd/pools/options.go @@ -0,0 +1,43 @@ +package pools + +import ( + "fmt" + + "github.com/hostodo/odo-cli/v2/pkg/ui" + "github.com/spf13/cobra" +) + +var optionsCmd = &cobra.Command{ + Use: "options", + Aliases: []string{"tiers", "plans"}, + Short: "List Hostodo pool tiers", + Long: `List available Hostodo pool tiers (Nano→Titan) and your current pool, if any. + +Examples: + odo pools options + odo pools options --json`, + RunE: runOptions, +} + +func init() { + optionsCmd.Flags().BoolVar(&poolsJSONFlag, "json", false, "Output as JSON") +} + +func runOptions(cmd *cobra.Command, args []string) error { + client, err := newAuthenticatedClient() + if err != nil { + return err + } + + options, err := client.ListPoolOptions() + if err != nil { + return fmt.Errorf("failed to list pool options: %w", err) + } + + if poolsJSONFlag { + return printJSON(options) + } + + fmt.Println(ui.FormatPoolOptionsTable(options)) + return nil +} diff --git a/cmd/pools/root.go b/cmd/pools/root.go new file mode 100644 index 0000000..3d2c36d --- /dev/null +++ b/cmd/pools/root.go @@ -0,0 +1,81 @@ +package pools + +import ( + "fmt" + + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/auth" + "github.com/hostodo/odo-cli/v2/pkg/config" + "github.com/spf13/cobra" +) + +// PoolsCmd is the parent command for Hostodo pool operations. +var PoolsCmd = &cobra.Command{ + Use: "pools", + Aliases: []string{"pool", "capacity"}, + Short: "Manage Hostodo resource pools", + Long: `Manage Hostodo capacity pools: list quota, buy or upgrade a pool, and create $0 VMs inside it. + +Examples: + odo pools # List your pools + odo pools show pool::abc # Show pool quota and member VMs + odo pools options # List available pool tiers + odo pools buy # Buy a Hostodo pool + odo pools upgrade # Upgrade an existing pool + odo pools vm # Create a VM inside a pool`, +} + +func init() { + PoolsCmd.AddCommand(listCmd) + PoolsCmd.AddCommand(showCmd) + PoolsCmd.AddCommand(optionsCmd) + PoolsCmd.AddCommand(buyCmd) + PoolsCmd.AddCommand(upgradeCmd) + PoolsCmd.AddCommand(vmCmd) + + PoolsCmd.Flags().BoolVar(&poolsJSONFlag, "json", false, "Output as JSON") + PoolsCmd.RunE = runList +} + +func newAuthenticatedClient() (*api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + if !auth.IsAuthenticated() { + return nil, fmt.Errorf("not authenticated. Run 'odo login' first") + } + client, err := api.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create API client: %w", err) + } + return client, nil +} + +func completePoolID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, err := newAuthenticatedClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + pools, err := client.ListResourcePools() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + ids := make([]string, 0, len(pools)) + for _, pool := range pools { + ids = append(ids, pool.PoolID) + } + return ids, cobra.ShellCompDirectiveNoFileComp +} + +func printJSON(v interface{}) error { + data, err := marshalIndent(v) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} diff --git a/cmd/pools/vm.go b/cmd/pools/vm.go new file mode 100644 index 0000000..7b9a8ad --- /dev/null +++ b/cmd/pools/vm.go @@ -0,0 +1,539 @@ +package pools + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/deploy" + "github.com/hostodo/odo-cli/v2/pkg/ui" + "github.com/hostodo/odo-cli/v2/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + vmPoolFlag string + vmOSFlag string + vmRegionFlag string + vmHostnameFlag string + vmSSHKeyFlag string + vmPlanFlag string + vmVCPUFlag int + vmRAMFlag int + vmDiskFlag int + vmBandwidthFlag int + vmYesFlag bool + vmJSONFlag bool +) + +var vmCmd = &cobra.Command{ + Use: "vm", + Aliases: []string{"deploy", "create-vm", "new-vm"}, + Short: "Create a VM inside a Hostodo pool", + Long: `Create a gen2 VM inside a Hostodo pool for $0 (quota check only). + +Size with --vcpu/--ram/--disk/--bandwidth, or pass a catalog --plan as a shape. +Never accepts payment fields. + +Examples: + odo pools vm + odo pools vm --os "Ubuntu 22.04" --region DET01 --vcpu 1 --ram 1024 --disk 20 --yes + odo pools vm --pool pool::abc --os Debian --region TPA01 --plan EPYC-2G1C32GN --json`, + RunE: runCreatePoolVM, +} + +func init() { + vmCmd.Flags().StringVar(&vmPoolFlag, "pool", "", "Pool ID (defaults to the active pool)") + vmCmd.Flags().StringVar(&vmOSFlag, "os", "", "OS template name") + vmCmd.Flags().StringVar(&vmRegionFlag, "region", "", "Region name") + vmCmd.Flags().StringVar(&vmHostnameFlag, "hostname", "", "Custom hostname") + vmCmd.Flags().StringVar(&vmSSHKeyFlag, "ssh-key", "", "SSH key name") + vmCmd.Flags().StringVar(&vmPlanFlag, "plan", "", "Catalog instance plan name/ID used as a shape") + vmCmd.Flags().IntVar(&vmVCPUFlag, "vcpu", 0, "vCPU count") + vmCmd.Flags().IntVar(&vmRAMFlag, "ram", 0, "RAM in MB") + vmCmd.Flags().IntVar(&vmDiskFlag, "disk", 0, "Disk in GB") + vmCmd.Flags().IntVar(&vmBandwidthFlag, "bandwidth", 0, "Bandwidth in GB") + vmCmd.Flags().BoolVarP(&vmYesFlag, "yes", "y", false, "Skip confirmation") + vmCmd.Flags().BoolVar(&vmJSONFlag, "json", false, "JSON output (requires --os, --region, and size or --plan)") +} + +func runCreatePoolVM(cmd *cobra.Command, args []string) error { + if vmJSONFlag && (vmOSFlag == "" || vmRegionFlag == "") { + return fmt.Errorf("JSON mode requires --os and --region") + } + if vmJSONFlag && vmPlanFlag == "" && (vmVCPUFlag == 0 || vmRAMFlag == 0 || vmDiskFlag == 0) { + return fmt.Errorf("JSON mode requires --vcpu, --ram, and --disk (or --plan)") + } + + client, err := newAuthenticatedClient() + if err != nil { + return err + } + + pool, err := selectPool(client, vmPoolFlag, vmJSONFlag) + if err != nil { + return err + } + if pool.Status != "active" { + return fmt.Errorf("pool %s is %s; only an active pool can create VMs", pool.PoolID, pool.Status) + } + + templates, err := client.ListTemplates() + if err != nil { + return fmt.Errorf("failed to load OS templates: %w", err) + } + regions, err := client.ListRegions() + if err != nil { + return fmt.Errorf("failed to load regions: %w", err) + } + + selectedTemplate, err := selectNamedTemplate(templates, vmOSFlag, vmJSONFlag) + if err != nil { + return err + } + selectedRegion, err := selectNamedRegion(regions, vmRegionFlag, vmJSONFlag) + if err != nil { + return err + } + + req := api.CreatePoolVMRequest{ + PoolID: pool.PoolID, + TemplateID: selectedTemplate.ID, + RegionID: selectedRegion.ID, + } + + if vmPlanFlag != "" { + plans, err := client.ListPlans() + if err != nil { + return fmt.Errorf("failed to load plans: %w", err) + } + plan, err := findInstancePlan(plans, vmPlanFlag) + if err != nil { + return err + } + req.PlanID = plan.ID + } else { + size, err := resolveVMSize(pool, vmJSONFlag) + if err != nil { + return err + } + req.VCPU = size.vcpu + req.RAMMB = size.ramMB + req.DiskGB = size.diskGB + req.BandwidthGB = size.bandwidthGB + } + + hostname, err := resolvePoolHostname(client, vmHostnameFlag) + if err != nil { + return err + } + req.Hostname = hostname + + sshKeyID, err := selectSSHKeyID(client, vmSSHKeyFlag, vmJSONFlag) + if err != nil { + return err + } + req.SSHKeyID = sshKeyID + + if !vmYesFlag && !vmJSONFlag { + confirmed, err := confirmPoolVM(pool, selectedTemplate, selectedRegion, req) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled.") + return nil + } + } + + result, err := client.CreatePoolVM(req) + if err != nil { + return fmt.Errorf("failed to create pool VM: %w", err) + } + + if vmJSONFlag { + return printJSON(result) + } + + displayPoolVMResult(result, selectedRegion) + return nil +} + +type vmSize struct { + vcpu int + ramMB int + diskGB int + bandwidthGB int +} + +func selectPool(client *api.Client, flag string, jsonMode bool) (*api.ResourcePoolDetail, error) { + if flag != "" { + return resolvePool(client, flag) + } + + pools, err := client.ListResourcePools() + if err != nil { + return nil, fmt.Errorf("failed to list pools: %w", err) + } + var active []api.ResourcePool + for _, p := range pools { + if p.Status == "active" { + active = append(active, p) + } + } + if len(active) == 0 { + return nil, fmt.Errorf("no active Hostodo pool. Buy one with: odo pools buy") + } + if len(active) == 1 { + return client.GetResourcePool(active[0].PoolID) + } + if jsonMode { + return nil, fmt.Errorf("multiple pools found. Use --pool to specify which one") + } + + options := make([]string, len(active)) + for i, p := range active { + options[i] = fmt.Sprintf("%s (%s) %s RAM remaining", p.PoolID, p.Label(), formatRAMGB(p.Remaining.RAMMB)) + } + var selected string + err = huh.NewSelect[string](). + Title("Choose a pool:"). + Options(huh.NewOptions(options...)...). + Value(&selected). + Height(10). + Run() + if err != nil { + return nil, err + } + poolID := strings.Fields(selected)[0] + return client.GetResourcePool(poolID) +} + +func resolveVMSize(pool *api.ResourcePoolDetail, jsonMode bool) (*vmSize, error) { + if vmVCPUFlag != 0 || vmRAMFlag != 0 || vmDiskFlag != 0 || vmBandwidthFlag != 0 { + if vmVCPUFlag == 0 || vmRAMFlag == 0 || vmDiskFlag == 0 { + return nil, fmt.Errorf("--vcpu, --ram, and --disk are required together") + } + bw := vmBandwidthFlag + if bw == 0 { + bw = 1024 + } + return &vmSize{vcpu: vmVCPUFlag, ramMB: vmRAMFlag, diskGB: vmDiskFlag, bandwidthGB: bw}, nil + } + if jsonMode { + return nil, fmt.Errorf("JSON mode requires --vcpu, --ram, and --disk (or --plan)") + } + + maxVCPU := max(1, min(pool.Quota.MaxVCPUPerInstance, pool.Remaining.VCPU)) + if pool.Quota.MaxVCPUPerInstance == 0 { + maxVCPU = max(1, pool.Remaining.VCPU) + } + maxRAM := max(512, pool.Remaining.RAMMB) + maxDisk := max(10, pool.Remaining.DiskGB) + maxBW := max(1, pool.Remaining.BandwidthGB) + + vcpu := min(1, maxVCPU) + ramMB := min(1024, maxRAM) + diskGB := min(20, maxDisk) + bandwidthGB := min(1024, maxBW) + + vcpuStr := strconv.Itoa(vcpu) + ramGBStr := strconv.Itoa(max(1, ramMB/1024)) + diskStr := strconv.Itoa(diskGB) + bwStr := strconv.Itoa(bandwidthGB) + + err := huh.NewInput(). + Title(fmt.Sprintf("vCPU (remaining %d, max %d per VM)", pool.Remaining.VCPU, maxVCPU)). + Value(&vcpuStr). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 { + return fmt.Errorf("must be an integer >= 1") + } + return nil + }). + Run() + if err != nil { + return nil, err + } + err = huh.NewInput(). + Title(fmt.Sprintf("RAM in GB (remaining %s)", formatRAMGB(pool.Remaining.RAMMB))). + Value(&ramGBStr). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 { + return fmt.Errorf("must be an integer >= 1") + } + return nil + }). + Run() + if err != nil { + return nil, err + } + err = huh.NewInput(). + Title(fmt.Sprintf("Disk in GB (remaining %d GB)", pool.Remaining.DiskGB)). + Value(&diskStr). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 10 { + return fmt.Errorf("must be an integer >= 10") + } + return nil + }). + Run() + if err != nil { + return nil, err + } + err = huh.NewInput(). + Title(fmt.Sprintf("Bandwidth in GB (remaining %d GB)", pool.Remaining.BandwidthGB)). + Value(&bwStr). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 { + return fmt.Errorf("must be an integer >= 1") + } + return nil + }). + Run() + if err != nil { + return nil, err + } + + vcpu, _ = strconv.Atoi(strings.TrimSpace(vcpuStr)) + ramGB, _ := strconv.Atoi(strings.TrimSpace(ramGBStr)) + diskGB, _ = strconv.Atoi(strings.TrimSpace(diskStr)) + bandwidthGB, _ = strconv.Atoi(strings.TrimSpace(bwStr)) + return &vmSize{vcpu: vcpu, ramMB: ramGB * 1024, diskGB: diskGB, bandwidthGB: bandwidthGB}, nil +} + +func resolvePoolHostname(client *api.Client, flag string) (string, error) { + if flag != "" { + if err := deploy.Validate(flag); err != nil { + return "", fmt.Errorf("invalid hostname: %w", err) + } + return flag, nil + } + hostname, err := deploy.Generate(client.CheckHostnameExists) + if err != nil { + return "", fmt.Errorf("failed to generate hostname: %w", err) + } + return hostname, nil +} + +func selectSSHKeyID(client *api.Client, flag string, jsonMode bool) (int, error) { + keys, err := client.ListSSHKeys() + if err != nil || len(keys) == 0 { + return 0, nil + } + if flag != "" { + for _, key := range keys { + if strings.EqualFold(key.Name, flag) { + return key.ID, nil + } + } + return 0, fmt.Errorf("SSH key %q not found", flag) + } + if len(keys) == 1 { + if !jsonMode { + fmt.Printf("Using SSH key: %s\n", keys[0].Name) + } + return keys[0].ID, nil + } + if jsonMode { + return 0, fmt.Errorf("multiple SSH keys found. Use --ssh-key to specify which one") + } + options := make([]string, len(keys)+1) + options[0] = "None" + for i, key := range keys { + fingerprint, ferr := utils.CalculateSSHFingerprint(key.PublicKey) + if ferr != nil { + fingerprint = "(error)" + } + options[i+1] = fmt.Sprintf("%s (%s)", key.Name, fingerprint) + } + var selected string + err = huh.NewSelect[string](). + Title("Choose an SSH key:"). + Options(huh.NewOptions(options...)...). + Value(&selected). + Height(10). + Run() + if err != nil { + return 0, err + } + if selected == "None" { + return 0, nil + } + name := strings.Split(selected, " (")[0] + for _, key := range keys { + if key.Name == name { + return key.ID, nil + } + } + return 0, nil +} + +func selectNamedTemplate(templates []api.Template, flag string, jsonMode bool) (*api.Template, error) { + if flag != "" { + tmpl, err := findNamed(templates, flag, func(t api.Template) string { return t.Name }, "OS template") + if err != nil { + return nil, err + } + return tmpl, nil + } + if jsonMode { + return nil, fmt.Errorf("JSON mode requires --os") + } + names := make([]string, len(templates)) + for i, t := range templates { + names[i] = t.Name + } + var selected string + err := huh.NewSelect[string](). + Title("Choose an OS:"). + Options(huh.NewOptions(names...)...). + Value(&selected). + Height(15). + Run() + if err != nil { + return nil, err + } + tmpl, _ := findNamed(templates, selected, func(t api.Template) string { return t.Name }, "OS template") + return tmpl, nil +} + +func selectNamedRegion(regions []api.Region, flag string, jsonMode bool) (*api.Region, error) { + if flag != "" { + region, err := findNamed(regions, flag, func(r api.Region) string { return r.Name }, "region") + if err != nil { + return nil, err + } + return region, nil + } + if jsonMode { + return nil, fmt.Errorf("JSON mode requires --region") + } + names := make([]string, len(regions)) + for i, r := range regions { + names[i] = r.Name + } + var selected string + err := huh.NewSelect[string](). + Title("Choose a region:"). + Options(huh.NewOptions(names...)...). + Value(&selected). + Height(15). + Run() + if err != nil { + return nil, err + } + region, _ := findNamed(regions, selected, func(r api.Region) string { return r.Name }, "region") + return region, nil +} + +func findNamed[T any](items []T, name string, label func(T) string, kind string) (*T, error) { + for i := range items { + if strings.EqualFold(label(items[i]), name) { + return &items[i], nil + } + } + lower := strings.ToLower(name) + var matches []*T + for i := range items { + if strings.Contains(strings.ToLower(label(items[i])), lower) { + matches = append(matches, &items[i]) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = label(*m) + } + return nil, fmt.Errorf("ambiguous %s %q — matches: %s", kind, name, strings.Join(names, ", ")) + } + names := make([]string, len(items)) + for i, item := range items { + names[i] = label(item) + } + return nil, fmt.Errorf("no %s matching %q. Available: %s", kind, name, strings.Join(names, ", ")) +} + +func findInstancePlan(plans []api.Plan, name string) (*api.Plan, error) { + if id, err := strconv.Atoi(name); err == nil { + for i := range plans { + if plans[i].ID == id { + return &plans[i], nil + } + } + } + plan, err := findNamed(plans, name, func(p api.Plan) string { return p.Name }, "plan") + if err != nil { + return nil, err + } + return plan, nil +} + +func confirmPoolVM(pool *api.ResourcePoolDetail, tmpl *api.Template, region *api.Region, req api.CreatePoolVMRequest) (bool, error) { + sizeLine := "" + if req.PlanID != 0 { + sizeLine = fmt.Sprintf(" Plan: #%d\n", req.PlanID) + } else { + sizeLine = fmt.Sprintf(" Size: %d vCPU, %s RAM, %d GB disk, %d GB BW\n", req.VCPU, formatRAMGB(req.RAMMB), req.DiskGB, req.BandwidthGB) + } + fmt.Printf(` +Create pool VM: + Pool: %s (%s) + OS: %s + Region: %s + Hostname: %s +%s Charge: $0 (uses pool quota) + +`, pool.PoolID, pool.Label(), tmpl.Name, region.Name, req.Hostname, sizeLine) + + confirmed := true + err := huh.NewConfirm(). + Title("Create this VM in the pool?"). + Value(&confirmed). + Run() + if err != nil { + return false, err + } + return confirmed, nil +} + +func displayPoolVMResult(result *api.CreatePoolVMResponse, region *api.Region) { + inst := result.Instance + content := fmt.Sprintf(`Pool VM created + +Hostname: %s +IP Address: %s +Status: %s +Region: %s +Pool: %s + +Quota remaining: %s RAM, %d vCPU, %d GB disk, %d VMs`, + inst.Hostname, + inst.MainIP, + inst.Status, + region.Name, + inst.PoolID, + formatRAMGB(result.Quota.Remaining.RAMMB), + result.Quota.Remaining.VCPU, + result.Quota.Remaining.DiskGB, + result.Quota.Remaining.Instances, + ) + card := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(1, 2) + fmt.Println("\n" + ui.SuccessStyle.Render("✓ VM created") + "\n" + card.Render(content) + "\n") + if inst.Hostname != "" { + fmt.Printf("SSH: odo ssh %s\n", inst.Hostname) + } +} diff --git a/cmd/root.go b/cmd/root.go index e03d7a7..241084e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/hostodo/odo-cli/v2/cmd/auth" "github.com/hostodo/odo-cli/v2/cmd/instances" + "github.com/hostodo/odo-cli/v2/cmd/pools" "github.com/hostodo/odo-cli/v2/pkg/config" "github.com/spf13/cobra" ) @@ -51,6 +52,14 @@ Support: odo tickets show # Show ticket details and replies odo tickets departments # List support departments +Pools: + odo pools # List Hostodo pools + odo pools show # Show pool quota and member VMs + odo pools options # List pool tiers + odo pools buy # Buy a Hostodo pool + odo pools upgrade # Upgrade an existing pool + odo pools vm # Create a $0 VM inside a pool + SSH Keys: odo keys list # List your SSH keys odo keys add # Add a new SSH key @@ -100,6 +109,9 @@ func init() { // Support ticket commands rootCmd.AddCommand(ticketsCmd) + // Hostodo resource pools + rootCmd.AddCommand(pools.PoolsCmd) + // SSH key management rootCmd.AddCommand(keysCmd) diff --git a/pkg/api/client.go b/pkg/api/client.go index d936d72..9d931b1 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -151,11 +151,7 @@ func parseResponse(resp *http.Response, v interface{}) error { } if resp.StatusCode >= 400 { - var errorResp ErrorResponse - if err := json.Unmarshal(body, &errorResp); err == nil { - return fmt.Errorf("API error (%d): %s", resp.StatusCode, errorResp.Detail) - } - return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + return parseAPIError(resp.StatusCode, body) } if v != nil { diff --git a/pkg/api/errors.go b/pkg/api/errors.go new file mode 100644 index 0000000..0d4bac8 --- /dev/null +++ b/pkg/api/errors.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" +) + +func parseAPIError(status int, body []byte) error { + msg := extractAPIErrorMessage(body) + if msg == "" { + msg = strings.TrimSpace(string(body)) + } + if msg == "" { + msg = "request failed" + } + return fmt.Errorf("API error (%d): %s", status, msg) +} + +func extractAPIErrorMessage(body []byte) string { + var errorResp ErrorResponse + if err := json.Unmarshal(body, &errorResp); err == nil { + if errorResp.Detail != "" { + return errorResp.Detail + } + if errorResp.Message != "" { + return errorResp.Message + } + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return "" + } + if msg := stringFromRaw(obj["detail"]); msg != "" { + return msg + } + if msg := stringFromRaw(obj["message"]); msg != "" { + return msg + } + var nested map[string]json.RawMessage + if raw, ok := obj["detail"]; ok && json.Unmarshal(raw, &nested) == nil { + if msg := stringFromRaw(nested["message"]); msg != "" { + return msg + } + if msg := stringFromRaw(nested["detail"]); msg != "" { + return msg + } + } + return "" +} + +func stringFromRaw(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return strings.TrimSpace(s) + } + return "" +} diff --git a/pkg/api/models.go b/pkg/api/models.go index c1f2893..1b09235 100644 --- a/pkg/api/models.go +++ b/pkg/api/models.go @@ -1,6 +1,9 @@ package api -import "encoding/json" +import ( + "encoding/json" + "strings" +) // LoginRequest represents the login credentials type LoginRequest struct { @@ -345,3 +348,183 @@ type TicketReplyRequest struct { Content string `json:"content"` InternalNote bool `json:"internal_note,omitempty"` } + +// PoolQuota is used/remaining/limit capacity for a Hostodo pool. +type PoolQuota struct { + Instances int `json:"instances"` + VCPU int `json:"vcpu"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + BandwidthGB int `json:"bandwidth_gb"` + IPs int `json:"ips"` + MaxVCPUPerInstance int `json:"max_vcpu_per_instance,omitempty"` +} + +// ResourcePool is a Hostodo capacity pool summary. +type ResourcePool struct { + PoolID string `json:"pool_id"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + Enforcement string `json:"enforcement"` + PlanID int `json:"plan_id"` + BillingAmount string `json:"billing_amount"` + BillingCycle string `json:"billing_cycle"` + NextDueDate string `json:"next_due_date"` + AutorenewalEnabled bool `json:"autorenewal_enabled"` + Quota PoolQuota `json:"quota"` + Usage PoolQuota `json:"usage"` + Remaining PoolQuota `json:"remaining"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Label is the customer-facing pool name, falling back to pool_id. +func (p ResourcePool) Label() string { + name := strings.TrimSpace(p.DisplayName) + if name != "" { + return name + } + return p.PoolID +} + +// ResourcePoolMember is a VM billed against a pool. +type ResourcePoolMember struct { + InstanceID string `json:"instance_id"` + Hostname string `json:"hostname"` + Status string `json:"status"` + MainIP string `json:"main_ip"` + VCPU int `json:"vcpu"` + RAM int `json:"ram"` + Disk int `json:"disk"` + Bandwidth int `json:"bandwidth"` + Region string `json:"region"` + PlanName string `json:"plan_name"` +} + +// ResourcePoolDetail is a pool plus member VMs and region counts. +type ResourcePoolDetail struct { + ResourcePool + Members []ResourcePoolMember `json:"members"` + Regions []PoolRegionCount `json:"regions"` + DowngradeBlockers []string `json:"downgrade_blockers"` +} + +// PoolRegionCount is how many pool VMs live in a region. +type PoolRegionCount struct { + Region string `json:"region"` + Count int `json:"count"` +} + +// ResourcePoolsResponse is the paginated pool list. +type ResourcePoolsResponse struct { + Results []ResourcePool `json:"results"` + Count int `json:"count"` +} + +// PoolTier is a buyable/upgradable Hostodo pool plan. +type PoolTier struct { + ID int `json:"id"` + Name string `json:"name"` + PriceMonthly string `json:"price_monthly"` + PriceAnnually string `json:"price_annually"` + PriceSemiannually string `json:"price_semiannually"` + PriceBiennially string `json:"price_biennially"` + PriceTriennially string `json:"price_triennially"` + RAMMB int `json:"ram_mb"` + TotalVCPU int `json:"total_vcpu"` + MaxVCPUPerInstance int `json:"max_vcpu_per_instance"` + DiskGB int `json:"disk_gb"` + BandwidthGB int `json:"bandwidth_gb"` + MaxInstances int `json:"max_instances"` + MaxIPs int `json:"max_ips"` + DollarsPerGB float64 `json:"dollars_per_gb"` + SelfServe bool `json:"self_serve"` + Flag string `json:"flag"` + IsCurrent bool `json:"is_current"` +} + +// PoolOptionsResponse is the pool catalog plus the caller's current pool. +type PoolOptionsResponse struct { + BillingCycles []string `json:"billing_cycles"` + CurrentPoolID string `json:"current_pool_id"` + Tiers []PoolTier `json:"tiers"` +} + +// PoolCheckoutRequest buys or upgrades a Hostodo pool. +type PoolCheckoutRequest struct { + PlanID int `json:"plan_id"` + TargetPlanID int `json:"target_plan_id,omitempty"` + BillingCycle string `json:"billing_cycle,omitempty"` + PaymentMethod string `json:"payment_method,omitempty"` + PaymentMethodID string `json:"payment_method_id,omitempty"` + Promocode string `json:"promocode,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + QuoteOnly bool `json:"quote_only,omitempty"` + Confirm bool `json:"confirm,omitempty"` +} + +// PoolQuote is a purchase/upgrade price quote. +type PoolQuote struct { + PlanID int `json:"plan_id"` + PlanName string `json:"plan_name"` + BillingCycle string `json:"billing_cycle"` + Mode string `json:"mode"` + ExistingPoolID string `json:"existing_pool_id"` + UnitPrice json.Number `json:"unit_price"` + Subtotal json.Number `json:"subtotal"` + RecurringAmount json.Number `json:"recurring_amount"` + CreditsAvailable json.Number `json:"credits_available"` + CreditsApplied json.Number `json:"credits_applied_if_created"` + AmountDueAfterCredit json.Number `json:"amount_due_after_credit"` + PromocodeApplied bool `json:"promocode_applied"` + InvoiceDate string `json:"invoice_date"` + NextDueDate string `json:"next_due_date"` + Quota PoolQuota `json:"quota"` +} + +// PoolCheckoutResponse is returned after buying or upgrading a pool. +type PoolCheckoutResponse struct { + Mode string `json:"mode"` + OrderNumber string `json:"order_number"` + InvoiceNumber string `json:"invoice_number"` + AmountDue string `json:"amount_due"` + UnitPrice string `json:"unit_price"` + PlanID int `json:"plan_id"` + PlanName string `json:"plan_name"` + PoolID string `json:"pool_id"` + ExistingPoolID string `json:"existing_pool_id"` + CheckoutURL string `json:"checkout_url"` + Checkout map[string]interface{} `json:"checkout"` + PaymentMethod string `json:"payment_method"` +} + +// CreatePoolVMRequest creates a $0 gen2 VM inside a pool. +type CreatePoolVMRequest struct { + PoolID string `json:"pool_id"` + Hostname string `json:"hostname"` + RegionID int `json:"region_id,omitempty"` + Region string `json:"region,omitempty"` + TemplateID int `json:"template_id"` + VCPU int `json:"vcpu,omitempty"` + RAMMB int `json:"ram_mb,omitempty"` + DiskGB int `json:"disk_gb,omitempty"` + BandwidthGB int `json:"bandwidth_gb,omitempty"` + PlanID int `json:"plan_id,omitempty"` + SSHKeyID int `json:"ssh_key_id,omitempty"` +} + +// CreatePoolVMResponse is returned after creating a pool VM. +type CreatePoolVMResponse struct { + Instance struct { + InstanceID string `json:"instance_id"` + PoolID string `json:"pool_id"` + Status string `json:"status"` + Hostname string `json:"hostname"` + MainIP string `json:"main_ip"` + Bandwidth int `json:"bandwidth"` + } `json:"instance"` + Quota struct { + Used PoolQuota `json:"used"` + Remaining PoolQuota `json:"remaining"` + } `json:"quota"` +} diff --git a/pkg/api/pools.go b/pkg/api/pools.go new file mode 100644 index 0000000..1369a7e --- /dev/null +++ b/pkg/api/pools.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "time" +) + +// ListResourcePools retrieves Hostodo pools for the authenticated user. +func (c *Client) ListResourcePools() ([]ResourcePool, error) { + resp, err := c.Get("/client/resource-pools/?limit=200") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if resp.StatusCode >= 400 { + return nil, parseAPIError(resp.StatusCode, body) + } + + var paginated ResourcePoolsResponse + if err := json.Unmarshal(body, &paginated); err == nil && paginated.Results != nil { + return paginated.Results, nil + } + + var pools []ResourcePool + if err := json.Unmarshal(body, &pools); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + return pools, nil +} + +// GetResourcePool retrieves one Hostodo pool, including member VMs. +func (c *Client) GetResourcePool(poolID string) (*ResourcePoolDetail, error) { + path := fmt.Sprintf("/client/resource-pools/%s/", url.PathEscape(poolID)) + resp, err := c.Get(path) + if err != nil { + return nil, err + } + + var pool ResourcePoolDetail + if err := parseResponse(resp, &pool); err != nil { + return nil, err + } + return &pool, nil +} + +// ListPoolOptions retrieves pool tiers and the caller's current pool, if any. +func (c *Client) ListPoolOptions() (*PoolOptionsResponse, error) { + resp, err := c.Get("/client/resource-pools/options/") + if err != nil { + return nil, err + } + + var options PoolOptionsResponse + if err := parseResponse(resp, &options); err != nil { + return nil, err + } + return &options, nil +} + +// QuotePoolCheckout quotes a pool purchase or upgrade without creating an order. +func (c *Client) QuotePoolCheckout(req PoolCheckoutRequest) (*PoolQuote, error) { + req.QuoteOnly = true + resp, err := c.Post("/client/resource-pools/checkout/", req) + if err != nil { + return nil, err + } + + var quote PoolQuote + if err := parseResponse(resp, "e); err != nil { + return nil, err + } + return "e, nil +} + +// CheckoutResourcePool buys a Hostodo pool (or upgrades if one already exists). +func (c *Client) CheckoutResourcePool(req PoolCheckoutRequest) (*PoolCheckoutResponse, error) { + resp, err := c.Post("/client/resource-pools/checkout/", req) + if err != nil { + return nil, err + } + + var result PoolCheckoutResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + return &result, nil +} + +// UpgradeResourcePool upgrades an existing Hostodo pool. +func (c *Client) UpgradeResourcePool(poolID string, req PoolCheckoutRequest) (*PoolCheckoutResponse, error) { + if req.TargetPlanID == 0 { + req.TargetPlanID = req.PlanID + } + path := fmt.Sprintf("/client/resource-pools/%s/upgrade/", url.PathEscape(poolID)) + resp, err := c.Post(path, req) + if err != nil { + return nil, err + } + + var result PoolCheckoutResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + if result.PoolID == "" { + result.PoolID = poolID + } + return &result, nil +} + +// CreatePoolVM provisions a $0 gen2 VM inside a Hostodo pool. +// The API creates the VM synchronously, so this uses a long timeout. +func (c *Client) CreatePoolVM(req CreatePoolVMRequest) (*CreatePoolVMResponse, error) { + resp, err := c.doRequestWithTimeout("POST", "/client/instances/create_in_pool/", req, 10*time.Minute) + if err != nil { + return nil, err + } + + var result CreatePoolVMResponse + if err := parseResponse(resp, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/pkg/api/pools_test.go b/pkg/api/pools_test.go new file mode 100644 index 0000000..c58a065 --- /dev/null +++ b/pkg/api/pools_test.go @@ -0,0 +1,281 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func samplePool() ResourcePool { + return ResourcePool{ + PoolID: "pool::abc123", + DisplayName: "Lab", + Status: "active", + PlanID: 12, + BillingAmount: "20.00", + BillingCycle: "monthly", + Quota: PoolQuota{Instances: 4, VCPU: 4, RAMMB: 8192, DiskGB: 80, BandwidthGB: 8192, IPs: 4, MaxVCPUPerInstance: 2}, + Usage: PoolQuota{Instances: 1, VCPU: 1, RAMMB: 1024, DiskGB: 20, BandwidthGB: 1024, IPs: 1}, + Remaining: PoolQuota{Instances: 3, VCPU: 3, RAMMB: 7168, DiskGB: 60, BandwidthGB: 7168, IPs: 3}, + } +} + +func TestListResourcePools_Paginated(t *testing.T) { + injectToken(t) + + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/client/resource-pools/") { + http.NotFound(w, r) + return + } + writeJSON(w, 200, ResourcePoolsResponse{Count: 1, Results: []ResourcePool{samplePool()}}) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + pools, err := client.ListResourcePools() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pools) != 1 || pools[0].PoolID != "pool::abc123" { + t.Fatalf("unexpected pools: %+v", pools) + } + if pools[0].Label() != "Lab" { + t.Errorf("expected label Lab, got %s", pools[0].Label()) + } +} + +func TestGetResourcePool_IncludesMembers(t *testing.T) { + injectToken(t) + + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/client/resource-pools/pool::abc123/" { + http.NotFound(w, r) + return + } + detail := ResourcePoolDetail{ + ResourcePool: samplePool(), + Members: []ResourcePoolMember{ + {InstanceID: "ins::1", Hostname: "brave-tiger", MainIP: "1.2.3.4", Status: "active", VCPU: 1, RAM: 1024, Disk: 20, Region: "DET01"}, + }, + } + writeJSON(w, 200, detail) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + pool, err := client.GetResourcePool("pool::abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pool.PoolID != "pool::abc123" || len(pool.Members) != 1 || pool.Members[0].Hostname != "brave-tiger" { + t.Fatalf("unexpected pool: %+v", pool) + } +} + +func TestListPoolOptions(t *testing.T) { + injectToken(t) + + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/client/resource-pools/options/" { + http.NotFound(w, r) + return + } + writeJSON(w, 200, PoolOptionsResponse{ + CurrentPoolID: "pool::abc123", + BillingCycles: []string{"monthly", "annually"}, + Tiers: []PoolTier{ + {ID: 12, Name: "Hostodo Nano", PriceMonthly: "20.00", RAMMB: 8192, TotalVCPU: 4, Flag: "current"}, + {ID: 13, Name: "Hostodo Micro", PriceMonthly: "40.00", RAMMB: 16384, TotalVCPU: 8, Flag: "upgrade"}, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + options, err := client.ListPoolOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if options.CurrentPoolID != "pool::abc123" || len(options.Tiers) != 2 { + t.Fatalf("unexpected options: %+v", options) + } +} + +func TestQuotePoolCheckout_SendsQuoteOnly(t *testing.T) { + injectToken(t) + + var captured PoolCheckoutRequest + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/client/resource-pools/checkout/" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&captured) + writeJSON(w, 200, PoolQuote{PlanID: 12, PlanName: "Hostodo Nano", Mode: "purchase", AmountDueAfterCredit: "20.00"}) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + quote, err := client.QuotePoolCheckout(PoolCheckoutRequest{PlanID: 12, BillingCycle: "monthly"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !captured.QuoteOnly { + t.Fatal("expected quote_only=true") + } + if quote.PlanName != "Hostodo Nano" || quote.AmountDueAfterCredit.String() != "20.00" { + t.Errorf("unexpected quote: %+v", quote) + } +} + +func TestCheckoutResourcePool(t *testing.T) { + injectToken(t) + + var captured PoolCheckoutRequest + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&captured) + writeJSON(w, 201, PoolCheckoutResponse{ + Mode: "purchase", + OrderNumber: "ORD-1", + InvoiceNumber: "INV-1", + AmountDue: "20.00", + PlanName: "Hostodo Nano", + CheckoutURL: "https://checkout.stripe.com/c/pay/cs_test", + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + result, err := client.CheckoutResourcePool(PoolCheckoutRequest{ + PlanID: 12, + BillingCycle: "monthly", + PaymentMethod: "saved_card", + IdempotencyKey: "abc", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured.QuoteOnly { + t.Fatal("checkout must not send quote_only") + } + if result.OrderNumber != "ORD-1" || result.CheckoutURL == "" { + t.Errorf("unexpected checkout: %+v", result) + } +} + +func TestUpgradeResourcePool_UsesUpgradePath(t *testing.T) { + injectToken(t) + + var path string + var captured PoolCheckoutRequest + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + json.NewDecoder(r.Body).Decode(&captured) + writeJSON(w, 200, PoolCheckoutResponse{Mode: "upgrade", PlanName: "Hostodo Micro", PoolID: "pool::abc123"}) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + result, err := client.UpgradeResourcePool("pool::abc123", PoolCheckoutRequest{PlanID: 13, BillingCycle: "monthly"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/client/resource-pools/pool::abc123/upgrade/" { + t.Errorf("wrong path: %s", path) + } + if captured.TargetPlanID != 13 { + t.Errorf("expected target_plan_id=13, got %d", captured.TargetPlanID) + } + if result.Mode != "upgrade" || result.PoolID != "pool::abc123" { + t.Errorf("unexpected result: %+v", result) + } +} + +func TestCreatePoolVM_RejectsPaymentFieldsByNotSendingThem(t *testing.T) { + injectToken(t) + + var captured map[string]interface{} + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/client/instances/create_in_pool/" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&captured) + writeJSON(w, 201, CreatePoolVMResponse{}) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.CreatePoolVM(CreatePoolVMRequest{ + PoolID: "pool::abc123", + Hostname: "brave-tiger", + RegionID: 1, + TemplateID: 2, + VCPU: 1, + RAMMB: 1024, + DiskGB: 20, + BandwidthGB: 1024, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, banned := range []string{"payment_method", "payment_method_id", "billing_cycle"} { + if _, ok := captured[banned]; ok { + t.Errorf("create_in_pool body must not include %s", banned) + } + } + if captured["pool_id"] != "pool::abc123" || captured["hostname"] != "brave-tiger" { + t.Errorf("unexpected body: %+v", captured) + } +} + +func TestCreatePoolVM_QuotaErrorMessage(t *testing.T) { + injectToken(t) + + srv := httptest.NewServer(authMiddleware(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 400, map[string]string{ + "code": "quota_exceeded", + "message": "Not enough RAM remaining in this pool", + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.CreatePoolVM(CreatePoolVMRequest{PoolID: "pool::abc123", Hostname: "x", RegionID: 1, TemplateID: 2}) + if err == nil { + t.Fatal("expected quota error") + } + if !strings.Contains(err.Error(), "Not enough RAM remaining in this pool") { + t.Errorf("expected pool error message, got: %v", err) + } +} + +func TestExtractAPIErrorMessage(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {name: "detail", body: `{"detail":"Promocode not found"}`, want: "Promocode not found"}, + {name: "message", body: `{"code":"quota_exceeded","message":"Not enough RAM"}`, want: "Not enough RAM"}, + {name: "nested", body: `{"detail":{"message":"pool inactive"}}`, want: "pool inactive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractAPIErrorMessage([]byte(tc.body)) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestResourcePoolLabelFallback(t *testing.T) { + p := ResourcePool{PoolID: "pool::xyz", DisplayName: " "} + if p.Label() != "pool::xyz" { + t.Errorf("expected pool id fallback, got %q", p.Label()) + } +} diff --git a/pkg/ui/formatters.go b/pkg/ui/formatters.go index 555a734..6761004 100644 --- a/pkg/ui/formatters.go +++ b/pkg/ui/formatters.go @@ -340,3 +340,220 @@ func FormatSSHKeysTable(displayKeys []SSHKeyDisplay) string { return sb.String() } + +func formatRAMGB(mb int) string { + if mb <= 0 { + return "0 GB" + } + if mb%1024 == 0 { + return fmt.Sprintf("%d GB", mb/1024) + } + return fmt.Sprintf("%.1f GB", float64(mb)/1024.0) +} + +func formatBandwidth(gb int) string { + if gb <= 0 { + return "0 GB" + } + if gb >= 1024 && gb%1024 == 0 { + return fmt.Sprintf("%d TB", gb/1024) + } + if gb >= 1024 { + return fmt.Sprintf("%.1f TB", float64(gb)/1024.0) + } + return fmt.Sprintf("%d GB", gb) +} + +func usedOf(used, total int, unit string) string { + if unit == "" { + return fmt.Sprintf("%d/%d", used, total) + } + return fmt.Sprintf("%d/%d %s", used, total, unit) +} + +// FormatPoolsTable formats Hostodo pools as an ASCII table. +func FormatPoolsTable(pools []api.ResourcePool) string { + if len(pools) == 0 { + return "No Hostodo pools found" + } + + const ( + idWidth = 16 + nameWidth = 18 + statusWidth = 10 + ramWidth = 14 + vcpuWidth = 10 + diskWidth = 12 + vmsWidth = 8 + billWidth = 16 + ) + + var sb strings.Builder + header := fmt.Sprintf( + "%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s", + idWidth, "POOL ID", + nameWidth, "NAME", + statusWidth, "STATUS", + ramWidth, "RAM", + vcpuWidth, "VCPU", + diskWidth, "DISK", + vmsWidth, "VMS", + billWidth, "BILLING", + ) + sb.WriteString(header + "\n") + sb.WriteString(strings.Repeat("-", len(header)) + "\n") + + for _, pool := range pools { + billing := "-" + if pool.BillingAmount != "" { + cycle := pool.BillingCycle + if cycle == "" { + cycle = "monthly" + } + billing = "$" + pool.BillingAmount + "/" + cycle + } + row := fmt.Sprintf( + "%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s", + idWidth, truncate(pool.PoolID, idWidth), + nameWidth, truncate(pool.Label(), nameWidth), + statusWidth, truncate(pool.Status, statusWidth), + ramWidth, truncate(formatRAMGB(pool.Usage.RAMMB)+"/"+formatRAMGB(pool.Quota.RAMMB), ramWidth), + vcpuWidth, truncate(usedOf(pool.Usage.VCPU, pool.Quota.VCPU, ""), vcpuWidth), + diskWidth, truncate(usedOf(pool.Usage.DiskGB, pool.Quota.DiskGB, "GB"), diskWidth), + vmsWidth, truncate(usedOf(pool.Usage.Instances, pool.Quota.Instances, ""), vmsWidth), + billWidth, truncate(billing, billWidth), + ) + sb.WriteString(row + "\n") + } + + return sb.String() +} + +// FormatPoolDetail formats a single Hostodo pool including members. +func FormatPoolDetail(pool *api.ResourcePoolDetail) string { + var sb strings.Builder + sb.WriteString(TitleStyle.Render("Pool: "+pool.Label()) + "\n") + sb.WriteString(fmt.Sprintf(" ID: %s\n", pool.PoolID)) + sb.WriteString(fmt.Sprintf(" Status: %s\n", GetStatusStyle(pool.Status).Render(pool.Status))) + if pool.BillingAmount != "" { + cycle := pool.BillingCycle + if cycle == "" { + cycle = "monthly" + } + sb.WriteString(fmt.Sprintf(" Billing: $%s / %s\n", pool.BillingAmount, cycle)) + } + if pool.NextDueDate != "" { + sb.WriteString(fmt.Sprintf(" Next due: %s\n", pool.NextDueDate)) + } + sb.WriteString(fmt.Sprintf(" Auto-renew: %t\n", pool.AutorenewalEnabled)) + sb.WriteString("\n") + + sb.WriteString(HeaderStyle.Render("Quota") + "\n") + sb.WriteString(fmt.Sprintf(" RAM: %s / %s\n", formatRAMGB(pool.Usage.RAMMB), formatRAMGB(pool.Quota.RAMMB))) + sb.WriteString(fmt.Sprintf(" vCPU: %d / %d (max %d per VM)\n", pool.Usage.VCPU, pool.Quota.VCPU, pool.Quota.MaxVCPUPerInstance)) + sb.WriteString(fmt.Sprintf(" Disk: %d / %d GB\n", pool.Usage.DiskGB, pool.Quota.DiskGB)) + sb.WriteString(fmt.Sprintf(" Bandwidth: %s / %s\n", formatBandwidth(pool.Usage.BandwidthGB), formatBandwidth(pool.Quota.BandwidthGB))) + sb.WriteString(fmt.Sprintf(" VMs: %d / %d\n", pool.Usage.Instances, pool.Quota.Instances)) + sb.WriteString(fmt.Sprintf(" IPs: %d / %d\n", pool.Usage.IPs, pool.Quota.IPs)) + sb.WriteString("\n") + + sb.WriteString(HeaderStyle.Render("Members") + "\n") + if len(pool.Members) == 0 { + sb.WriteString(" No VMs in this pool. Create one with: odo pools vm\n") + return sb.String() + } + + const ( + hostWidth = 22 + ipWidth = 16 + statusWidth = 12 + vcpuWidth = 6 + ramWidth = 8 + diskWidth = 8 + regionWidth = 10 + ) + header := fmt.Sprintf( + " %-*s %-*s %-*s %*s %*s %*s %-*s", + hostWidth, "HOSTNAME", + ipWidth, "IP", + statusWidth, "STATUS", + vcpuWidth, "VCPU", + ramWidth, "RAM", + diskWidth, "DISK", + regionWidth, "REGION", + ) + sb.WriteString(header + "\n") + sb.WriteString(" " + strings.Repeat("-", len(header)-2) + "\n") + for _, m := range pool.Members { + sb.WriteString(fmt.Sprintf( + " %-*s %-*s %-*s %*d %*s %*s %-*s\n", + hostWidth, truncate(m.Hostname, hostWidth), + ipWidth, truncate(m.MainIP, ipWidth), + statusWidth, truncate(m.Status, statusWidth), + vcpuWidth, m.VCPU, + ramWidth, formatRAMGB(m.RAM), + diskWidth, fmt.Sprintf("%d GB", m.Disk), + regionWidth, truncate(m.Region, regionWidth), + )) + } + return sb.String() +} + +// FormatPoolOptionsTable formats pool tiers as an ASCII table. +func FormatPoolOptionsTable(options *api.PoolOptionsResponse) string { + if options == nil || len(options.Tiers) == 0 { + return "No Hostodo pool tiers available" + } + + var sb strings.Builder + if options.CurrentPoolID != "" { + sb.WriteString(fmt.Sprintf("Current pool: %s\n\n", options.CurrentPoolID)) + } else { + sb.WriteString("No active Hostodo pool.\n\n") + } + + const ( + idWidth = 6 + nameWidth = 18 + flagWidth = 12 + ramWidth = 8 + vcpuWidth = 6 + diskWidth = 8 + vmsWidth = 6 + priceWidth = 10 + ) + header := fmt.Sprintf( + "%-*s %-*s %-*s %-*s %-*s %-*s %-*s %*s", + idWidth, "ID", + nameWidth, "NAME", + flagWidth, "FLAG", + ramWidth, "RAM", + vcpuWidth, "VCPU", + diskWidth, "DISK", + vmsWidth, "VMS", + priceWidth, "PRICE/MO", + ) + sb.WriteString(header + "\n") + sb.WriteString(strings.Repeat("-", len(header)) + "\n") + for _, tier := range options.Tiers { + flag := tier.Flag + if flag == "" && tier.IsCurrent { + flag = "current" + } + if flag == "" { + flag = "available" + } + sb.WriteString(fmt.Sprintf( + "%-*s %-*s %-*s %-*s %-*s %-*s %-*s %*s\n", + idWidth, fmt.Sprintf("%d", tier.ID), + nameWidth, truncate(tier.Name, nameWidth), + flagWidth, truncate(flag, flagWidth), + ramWidth, truncate(formatRAMGB(tier.RAMMB), ramWidth), + vcpuWidth, fmt.Sprintf("%d", tier.TotalVCPU), + diskWidth, fmt.Sprintf("%d GB", tier.DiskGB), + vmsWidth, fmt.Sprintf("%d", tier.MaxInstances), + priceWidth, "$"+tier.PriceMonthly, + )) + } + return sb.String() +} diff --git a/pkg/ui/styles.go b/pkg/ui/styles.go index 0bb7780..b4700fd 100644 --- a/pkg/ui/styles.go +++ b/pkg/ui/styles.go @@ -85,7 +85,7 @@ var ( // GetStatusStyle returns the appropriate style for a status func GetStatusStyle(status string) lipgloss.Style { switch status { - case "running": + case "running", "active": return StatusRunningStyle case "stopped": return StatusStoppedStyle From 0effd27ccde8eba532a67bd214c87681e0298748 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:14:32 +0000 Subject: [PATCH 2/2] test: cover pool commands and emit [] for empty JSON lists Add cobra/httptest coverage for list, show, options, buy, upgrade, and pool VM create. Empty `odo pools list --json` now prints [] instead of the human empty-state message. Co-authored-by: me --- cmd/pools/commands_test.go | 368 +++++++++++++++++++++++++++++++++++++ cmd/pools/list.go | 11 +- 2 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 cmd/pools/commands_test.go diff --git a/cmd/pools/commands_test.go b/cmd/pools/commands_test.go new file mode 100644 index 0000000..4d6ca8d --- /dev/null +++ b/cmd/pools/commands_test.go @@ -0,0 +1,368 @@ +package pools + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/hostodo/odo-cli/v2/pkg/api" + "github.com/hostodo/odo-cli/v2/pkg/auth" + "github.com/hostodo/odo-cli/v2/pkg/config" +) + +func samplePool() api.ResourcePool { + return api.ResourcePool{ + PoolID: "pool::abc123", + DisplayName: "Lab", + Status: "active", + PlanID: 12, + BillingAmount: "20.00", + BillingCycle: "monthly", + Quota: api.PoolQuota{Instances: 4, VCPU: 4, RAMMB: 8192, DiskGB: 80, BandwidthGB: 8192, IPs: 4, MaxVCPUPerInstance: 2}, + Usage: api.PoolQuota{Instances: 1, VCPU: 1, RAMMB: 1024, DiskGB: 20, BandwidthGB: 1024, IPs: 1}, + Remaining: api.PoolQuota{Instances: 3, VCPU: 3, RAMMB: 7168, DiskGB: 60, BandwidthGB: 7168, IPs: 3}, + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func injectToken(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + if err := os.MkdirAll(dir+"/.odo", 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir+"/.odo/token", []byte("test-bearer-token"), 0600); err != nil { + t.Fatal(err) + } + auth.ResetDefaultStore() + t.Cleanup(func() { auth.ResetDefaultStore() }) +} + +func pointAtServer(t *testing.T, serverURL string) { + t.Helper() + config.SetAllowHTTPAPIURL(true) + if err := config.SetAPIURLOverride(serverURL); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = config.SetAPIURLOverride("") + config.SetAllowHTTPAPIURL(false) + }) +} + +func mockPoolAPI(t *testing.T) *httptest.Server { + t.Helper() + pool := samplePool() + mux := http.NewServeMux() + + mux.HandleFunc("/client/resource-pools/options/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.PoolOptionsResponse{ + CurrentPoolID: pool.PoolID, + BillingCycles: []string{"monthly", "annually"}, + Tiers: []api.PoolTier{ + {ID: 12, Name: "Hostodo Nano", PriceMonthly: "20.00", PriceAnnually: "200.00", RAMMB: 8192, TotalVCPU: 4, DiskGB: 80, MaxInstances: 4, Flag: "current"}, + {ID: 13, Name: "Hostodo Micro", PriceMonthly: "40.00", PriceAnnually: "400.00", RAMMB: 16384, TotalVCPU: 8, DiskGB: 160, MaxInstances: 8, Flag: "upgrade"}, + }, + }) + }) + mux.HandleFunc("/client/resource-pools/checkout/", func(w http.ResponseWriter, r *http.Request) { + var req api.PoolCheckoutRequest + json.NewDecoder(r.Body).Decode(&req) + if req.QuoteOnly { + writeJSON(w, 200, api.PoolQuote{ + PlanID: req.PlanID, + PlanName: "Hostodo Nano", + BillingCycle: req.BillingCycle, + Mode: "purchase", + UnitPrice: "20.00", + AmountDueAfterCredit: "20.00", + RecurringAmount: "20.00", + }) + return + } + writeJSON(w, 201, api.PoolCheckoutResponse{ + Mode: "purchase", + OrderNumber: "ORD-1", + InvoiceNumber: "INV-1", + AmountDue: "20.00", + PlanID: req.PlanID, + PlanName: "Hostodo Nano", + }) + }) + mux.HandleFunc("/client/resource-pools/pool::abc123/upgrade/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.PoolCheckoutResponse{ + Mode: "upgrade", + PlanName: "Hostodo Micro", + PoolID: pool.PoolID, + AmountDue: "12.50", + }) + }) + mux.HandleFunc("/client/resource-pools/pool::abc123/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.ResourcePoolDetail{ + ResourcePool: pool, + Members: []api.ResourcePoolMember{ + {InstanceID: "ins::1", Hostname: "brave-tiger", MainIP: "1.2.3.4", Status: "active", VCPU: 1, RAM: 1024, Disk: 20, Region: "DET01"}, + }, + }) + }) + mux.HandleFunc("/client/resource-pools/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.ResourcePoolsResponse{Count: 1, Results: []api.ResourcePool{pool}}) + }) + mux.HandleFunc("/client/templates/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.TemplatesResponse{Results: []api.Template{{ID: 2, Name: "Ubuntu 22.04"}}}) + }) + mux.HandleFunc("/client/regions/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.RegionsResponse{Results: []api.Region{{ID: 1, Name: "DET01"}}}) + }) + mux.HandleFunc("/client/ssh-keys/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, []api.SSHKey{}) + }) + mux.HandleFunc("/client/instances/create_in_pool/", func(w http.ResponseWriter, r *http.Request) { + var req api.CreatePoolVMRequest + json.NewDecoder(r.Body).Decode(&req) + writeJSON(w, 201, api.CreatePoolVMResponse{ + Instance: struct { + InstanceID string `json:"instance_id"` + PoolID string `json:"pool_id"` + Status string `json:"status"` + Hostname string `json:"hostname"` + MainIP string `json:"main_ip"` + Bandwidth int `json:"bandwidth"` + }{ + InstanceID: "ins::new", + PoolID: req.PoolID, + Status: "active", + Hostname: req.Hostname, + MainIP: "5.6.7.8", + }, + Quota: struct { + Used api.PoolQuota `json:"used"` + Remaining api.PoolQuota `json:"remaining"` + }{ + Used: api.PoolQuota{Instances: 2, VCPU: 2, RAMMB: 2048, DiskGB: 40}, + Remaining: api.PoolQuota{Instances: 2, VCPU: 2, RAMMB: 6144, DiskGB: 40}, + }, + }) + }) + mux.HandleFunc("/v1/billing/payment-methods/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, api.PaymentMethodsResponse{Results: []api.PaymentMethod{{ + PaymentMethodID: "pm_123", + LastFour: "4242", + CardType: "Visa", + CustomerDefault: true, + }}}) + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + writeJSON(w, 401, api.ErrorResponse{Detail: "no token"}) + return + } + mux.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv +} + +func executePools(t *testing.T, args ...string) (string, error) { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + PoolsCmd.SilenceUsage = true + PoolsCmd.SilenceErrors = true + PoolsCmd.SetArgs(args) + runErr := PoolsCmd.Execute() + + w.Close() + os.Stdout = orig + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String(), runErr +} + +func TestPoolsList_JSON(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "list", "--json") + if err != nil { + t.Fatalf("list failed: %v\n%s", err, out) + } + if !strings.Contains(out, "pool::abc123") || !strings.Contains(out, "Lab") { + t.Fatalf("expected pool JSON, got:\n%s", out) + } +} + +func TestPoolsList_EmptyJSON(t *testing.T) { + injectToken(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/client/resource-pools/" || strings.HasPrefix(r.URL.Path, "/client/resource-pools/") { + writeJSON(w, 200, api.ResourcePoolsResponse{Count: 0, Results: []api.ResourcePool{}}) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "list", "--json") + if err != nil { + t.Fatalf("empty list failed: %v\n%s", err, out) + } + if strings.Contains(out, "No Hostodo pools found") { + t.Fatalf("JSON mode should not print the empty-state message, got:\n%s", out) + } + var pools []api.ResourcePool + if err := json.Unmarshal([]byte(out), &pools); err != nil { + t.Fatalf("expected JSON array, got:\n%s", out) + } + if len(pools) != 0 { + t.Fatalf("expected empty array, got:\n%s", out) + } +} + +func TestPoolsShow_JSONIncludesMembers(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "show", "pool::abc123", "--json") + if err != nil { + t.Fatalf("show failed: %v\n%s", err, out) + } + if !strings.Contains(out, "brave-tiger") || !strings.Contains(out, "1.2.3.4") { + t.Fatalf("expected member VM in show output, got:\n%s", out) + } +} + +func TestPoolsOptions_JSON(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "options", "--json") + if err != nil { + t.Fatalf("options failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Hostodo Micro") || !strings.Contains(out, "current_pool_id") { + t.Fatalf("expected tiers JSON, got:\n%s", out) + } +} + +func TestPoolsBuy_JSON(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "buy", "--plan", "12", "--billing-cycle", "monthly", "--yes", "--json") + if err != nil { + t.Fatalf("buy failed: %v\n%s", err, out) + } + if !strings.Contains(out, "ORD-1") || !strings.Contains(out, "purchase") { + t.Fatalf("expected checkout JSON, got:\n%s", out) + } +} + +func TestPoolsUpgrade_JSON(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "upgrade", "pool::abc123", "--plan", "Hostodo Micro", "--billing-cycle", "monthly", "--yes", "--json") + if err != nil { + t.Fatalf("upgrade failed: %v\n%s", err, out) + } + if !strings.Contains(out, "upgrade") || !strings.Contains(out, "Hostodo Micro") { + t.Fatalf("expected upgrade JSON, got:\n%s", out) + } +} + +func TestPoolsVM_JSON(t *testing.T) { + injectToken(t) + srv := mockPoolAPI(t) + pointAtServer(t, srv.URL) + + out, err := executePools(t, "vm", + "--pool", "pool::abc123", + "--os", "Ubuntu 22.04", + "--region", "DET01", + "--vcpu", "1", + "--ram", "1024", + "--disk", "20", + "--bandwidth", "1024", + "--hostname", "pool-test-box", + "--yes", + "--json", + ) + if err != nil { + t.Fatalf("vm failed: %v\n%s", err, out) + } + if !strings.Contains(out, "pool-test-box") || !strings.Contains(out, "5.6.7.8") { + t.Fatalf("expected created VM JSON, got:\n%s", out) + } +} + +func TestPoolsList_RequiresAuth(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + auth.ResetDefaultStore() + t.Cleanup(func() { auth.ResetDefaultStore() }) + config.SetAllowHTTPAPIURL(true) + _ = config.SetAPIURLOverride("http://127.0.0.1:1") + t.Cleanup(func() { + _ = config.SetAPIURLOverride("") + config.SetAllowHTTPAPIURL(false) + }) + + _, err := executePools(t, "list", "--json") + if err == nil { + t.Fatal("expected auth error") + } + if !strings.Contains(err.Error(), "not authenticated") { + t.Fatalf("expected not authenticated, got: %v", err) + } +} + +func TestPoolsShow_NotFound(t *testing.T) { + injectToken(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/client/resource-pools/") && r.Method == http.MethodGet { + if strings.Contains(r.URL.Path, "missing") { + writeJSON(w, 404, api.ErrorResponse{Detail: "Not found."}) + return + } + writeJSON(w, 200, api.ResourcePoolsResponse{Results: []api.ResourcePool{}}) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + pointAtServer(t, srv.URL) + + _, err := executePools(t, "show", "missing") + if err == nil { + t.Fatal("expected not found error") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found, got: %v", err) + } +} diff --git a/cmd/pools/list.go b/cmd/pools/list.go index e28f7b3..9cbdb1a 100644 --- a/cmd/pools/list.go +++ b/cmd/pools/list.go @@ -51,16 +51,19 @@ func runList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list pools: %w", err) } + if poolsJSONFlag { + if pools == nil { + pools = []api.ResourcePool{} + } + return printJSON(pools) + } + if len(pools) == 0 { fmt.Println("No Hostodo pools found.") fmt.Println("Buy one with: odo pools buy") return nil } - if poolsJSONFlag { - return printJSON(pools) - } - fmt.Println(ui.FormatPoolsTable(pools)) return nil }