diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 52bcc3f..1693fcb 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -108,12 +108,13 @@ func runInstanceList(cmd *cobra.Command) error { if privateIP == "" { privateIP = vm.NetworkPrivateIP() } + publicIP := vm.GetPublicIPAddress() row := []string{ instanceDisplayID(vm), vm.Name, vm.State, privateIP, - instance.StringVal(vm.PublicIP), + publicIP, regionName, } if expandedOutput { @@ -123,7 +124,7 @@ func runInstanceList(cmd *cobra.Command) error { vm.Name, vm.State, privateIP, - instance.StringVal(vm.PublicIP), + publicIP, regionName, templateName, vm.CreatedAt, @@ -202,8 +203,8 @@ func runInstanceGet(cmd *cobra.Command, slug string) error { regionName = vm.Region.Name } billingCycle := "" - if vm.BillingCycle != nil { - billingCycle = vm.BillingCycle.Name + if vm.Offering != nil && vm.Offering.BillingCycle != nil { + billingCycle = vm.Offering.BillingCycle.Name } storageName := "" if vm.StorageSetting != nil { @@ -215,6 +216,8 @@ func runInstanceGet(cmd *cobra.Command, slug string) error { privateIP = vm.NetworkPrivateIP() } + publicIP := vm.GetPublicIPAddress() + headers := []string{"FIELD", "VALUE"} rows := [][]string{ {"ID", instanceDisplayID(*vm)}, @@ -224,7 +227,7 @@ func runInstanceGet(cmd *cobra.Command, slug string) error { {"State", vm.State}, {"Username", vm.Username}, {"Private IP", privateIP}, - {"Public IP", instance.StringVal(vm.PublicIP)}, + {"Public IP", publicIP}, {"Region", regionName}, {"Template", templateName}, {"OS Family", osFamily}, diff --git a/pkg/api/instance/instance.go b/pkg/api/instance/instance.go index ce6d515..026caa8 100644 --- a/pkg/api/instance/instance.go +++ b/pkg/api/instance/instance.go @@ -60,6 +60,7 @@ type VirtualMachine struct { Hostname string `json:"hostname"` Username string `json:"username"` State string `json:"state"` + IPAddresses []IPAddresses `json:"ipaddresses"` PublicIP *string `json:"public_ip"` PrivateIP *string `json:"private_ip"` FrozenAt *string `json:"frozen_at"` @@ -71,6 +72,7 @@ type VirtualMachine struct { IsVNF bool `json:"is_vnf"` ConsoleURL *string `json:"console_url"` Template *VMTemplate `json:"template"` + Offering *Offering `json:"offering"` BillingCycle *BillingCycle `json:"billing_cycle"` Region *Region `json:"region"` CloudProvider *CloudProvider `json:"cloud_provider"` @@ -107,7 +109,7 @@ type VMNetworkIP struct { // NetworkPrivateIP returns the private IP from the default network, falling back // to the first network with an IP if no default is set. func (vm *VirtualMachine) NetworkPrivateIP() string { - var fallback string + fallback := "-" for _, n := range vm.Networks { if n.Pivot == nil || n.Pivot.IPAddress == "" { continue @@ -115,13 +117,34 @@ func (vm *VirtualMachine) NetworkPrivateIP() string { if n.IsDefault || n.Pivot.IsDefault { return n.Pivot.IPAddress } - if fallback == "" { + if fallback == "-" { fallback = n.Pivot.IPAddress } } return fallback } +// Returns the PublicIP if a VM is assigned one +func (vm *VirtualMachine) GetPublicIPAddress() string { + fallback := "-" + + for _, ip := range vm.IPAddresses { + if ip.IPAddress == "" { + continue + } + + if ip.Type != "" { + return ip.IPAddress + } + + if fallback == "-" { + fallback = ip.IPAddress + } + } + + return fallback +} + // VMTemplate represents the template/OS info on a VM. type VMTemplate struct { ID string `json:"id"` @@ -154,6 +177,13 @@ type OSVersion struct { PricingType string `json:"pricing_type"` } +// IPAddresses represents the IP address of a VM +type IPAddresses struct { + ID string `json:"id"` + IPAddress string `json:"ipaddress"` + Type string `json:"type"` +} + // BillingCycle represents a billing period. type BillingCycle struct { ID string `json:"id"` @@ -162,6 +192,10 @@ type BillingCycle struct { Duration int `json:"duration"` Unit string `json:"unit"` } +type Offering struct { + ID string `json:"id"` + BillingCycle *BillingCycle `json:"billing_cycle"` +} // Region represents a cloud region. type Region struct { @@ -345,7 +379,9 @@ func (s *Service) List(ctx context.Context, region, project string) ([]VirtualMa // resolution) see the full set rather than just the first page. var all []VirtualMachine for page := 1; ; page++ { - q := url.Values{} + q := url.Values{ + "include": {"networks,ipaddresses"}, + } if region != "" { q.Set("filter[region]", region) } diff --git a/pkg/api/instance/instance_test.go b/pkg/api/instance/instance_test.go index f799c20..735c91c 100644 --- a/pkg/api/instance/instance_test.go +++ b/pkg/api/instance/instance_test.go @@ -49,6 +49,26 @@ func TestList(t *testing.T) { } } +func TestListEmptyResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": []interface{}{}, + "total": 0, + }) + })) + defer srv.Close() + + svc := instance.NewService(newClient(srv.URL)) + vms, err := svc.List(context.Background(), "", "") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(vms) != 0 { + t.Errorf("got %d VMs, want 0", len(vms)) + } +} + func TestListPaginatesAllPages(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/virtual-machines" { @@ -121,6 +141,21 @@ func TestGet(t *testing.T) { } } +func TestGetNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Error", + "message": "not found", + }) + })) + defer srv.Close() + + svc := instance.NewService(newClient(srv.URL)) + _, err := svc.Get(context.Background(), "nonexistent") + if err == nil { + t.Error("expected error for not found") + } +} func TestStart(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || r.URL.Path != "/virtual-machines/test-vm/start" {