Repository: digitalocean/doctl
Pull request #1601 (2024-10-25 16:58:59)

droplets: support listing GPU Droplets.
Adds a `--gpus` flag to the `doctl compute droplets list` command.


Patch:
diff --git a/args.go b/args.go
index 416e3f1c..4b5290bb 100644
--- a/args.go
+++ b/args.go
@@ -565,4 +565,7 @@ const (
 
 	// ArgTokenValidationServer is the server used to validate an OAuth token
 	ArgTokenValidationServer = "token-validation-server"
+
+	// ArgGPUs specifies to list GPU Droplets
+	ArgGPUs = "gpus"
 )
diff --git a/commands/droplets.go b/commands/droplets.go
index 12a93b30..139eda2f 100644
--- a/commands/droplets.go
+++ b/commands/droplets.go
@@ -113,6 +113,7 @@ If you do not specify a region, the Droplet is created in the default region for
 		aliasOpt("ls"), displayerType(&displayers.Droplet{}))
 	AddStringFlag(cmdRunDropletList, doctl.ArgRegionSlug, "", "", "Retrieves a list of Droplets in a specified region")
 	AddStringFlag(cmdRunDropletList, doctl.ArgTagName, "", "", "Retrieves a list of Droplets with the specified tag name")
+	AddBoolFlag(cmdRunDropletList, doctl.ArgGPUs, "", false, "List GPU Droplets only. By default, only non-GPU Droplets are returned.")
 	cmdRunDropletList.Example = `The following example retrieves a list of all Droplets in the ` + "`" + `nyc1` + "`" + ` region: doctl compute droplet list --region nyc1`
 
 	cmdDropletNeighbors := CmdBuilder(cmd, RunDropletNeighbors, "neighbors <droplet-id>", "List a Droplet's neighbors on your account", `Lists your Droplets that are on the same physical hardware, including the following details:`+dropletDetails, Writer,
@@ -656,6 +657,15 @@ func RunDropletList(c *CmdConfig) error {
 		return err
 	}
 
+	gpus, err := c.Doit.GetBool(c.NS, doctl.ArgGPUs)
+	if err != nil {
+		return err
+	}
+
+	if gpus && tagName != "" {
+		return fmt.Errorf("The --gpus and --tag-name flags are mutually exclusive.")
+	}
+
 	matches := make([]glob.Glob, 0, len(c.Args))
 	for _, globStr := range c.Args {
 		g, err := glob.Compile(globStr)
@@ -669,7 +679,9 @@ func RunDropletList(c *CmdConfig) error {
 	var matchedList do.Droplets
 
 	var list do.Droplets
-	if tagName == "" {
+	if gpus {
+		list, err = ds.ListWithGPUs()
+	} else if tagName == "" {
 		list, err = ds.List()
 	} else {
 		list, err = ds.ListByTag(tagName)
diff --git a/do/droplets.go b/do/droplets.go
index b812977a..e3e325a1 100644
--- a/do/droplets.go
+++ b/do/droplets.go
@@ -53,6 +53,7 @@ type Kernels []Kernel
 type DropletsService interface {
 	List() (Droplets, error)
 	ListByTag(string) (Droplets, error)
+	ListWithGPUs() (Droplets, error)
 	Get(int) (*Droplet, error)
 	Create(*godo.DropletCreateRequest, bool) (*Droplet, error)
 	CreateMultiple(*godo.DropletMultiCreateRequest) (Droplets, error)
@@ -93,18 +94,25 @@ func (ds *dropletsService) List() (Droplets, error) {
 		return si, resp, err
 	}
 
-	si, err := PaginateResp(f)
-	if err != nil {
-		return nil, err
-	}
+	return ds.list(f)
+}
 
-	list := make(Droplets, len(si))
-	for i := range si {
-		a := si[i].(godo.Droplet)
-		list[i] = Droplet{Droplet: &a}
+func (ds *dropletsService) ListWithGPUs() (Droplets, error) {
+	f := func(opt *godo.ListOptions) ([]any, *godo.Response, error) {
+		list, resp, err := ds.client.Droplets.ListWithGPUs(context.TODO(), opt)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		si := make([]any, len(list))
+		for i := range list {
+			si[i] = list[i]
+		}
+
+		return si, resp, err
 	}
 
-	return list, nil
+	return ds.list(f)
 }
 
 func (ds *dropletsService) ListByTag(tagName string) (Droplets, error) {
@@ -122,6 +130,10 @@ func (ds *dropletsService) ListByTag(tagName string) (Droplets, error) {
 		return si, resp, err
 	}
 
+	return ds.list(f)
+}
+
+func (ds *dropletsService) list(f Generator) (Droplets, error) {
 	si, err := PaginateResp(f)
 	if err != nil {
 		return nil, err
diff --git a/do/mocks/DropletsService.go b/do/mocks/DropletsService.go
index 8b36751f..3f8162f1 100644
--- a/do/mocks/DropletsService.go
+++ b/do/mocks/DropletsService.go
@@ -21,6 +21,7 @@ import (
 type MockDropletsService struct {
 	ctrl     *gomock.Controller
 	recorder *MockDropletsServiceMockRecorder
+	isgomock struct{}
 }
 
 // MockDropletsServiceMockRecorder is the mock recorder for MockDropletsService.
@@ -188,6 +189,21 @@ func (mr *MockDropletsServiceMockRecorder) ListByTag(arg0 any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByTag", reflect.TypeOf((*MockDropletsService)(nil).ListByTag), arg0)
 }
 
+// ListWithGPUs mocks base method.
+func (m *MockDropletsService) ListWithGPUs() (do.Droplets, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListWithGPUs")
+	ret0, _ := ret[0].(do.Droplets)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListWithGPUs indicates an expected call of ListWithGPUs.
+func (mr *MockDropletsServiceMockRecorder) ListWithGPUs() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWithGPUs", reflect.TypeOf((*MockDropletsService)(nil).ListWithGPUs))
+}
+
 // Neighbors mocks base method.
 func (m *MockDropletsService) Neighbors(arg0 int) (do.Droplets, error) {
 	m.ctrl.T.Helper()
diff --git a/go.mod b/go.mod
index 3bc53f8d..91b3ac28 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.22
 require (
 	github.com/blang/semver v3.5.1+incompatible
 	github.com/creack/pty v1.1.21
-	github.com/digitalocean/godo v1.128.0
+	github.com/digitalocean/godo v1.128.1-0.20241025145008-2654a9d1e887
 	github.com/docker/cli v24.0.5+incompatible
 	github.com/docker/docker v25.0.6+incompatible
 	github.com/docker/docker-credential-helpers v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index a8e609a9..04b75b62 100644
--- a/go.sum
+++ b/go.sum
@@ -91,8 +91,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/digitalocean/godo v1.128.0 h1:cGn/ibMSRZ9+8etbzMv2MnnCEPTTGlEnx3HHTPwdk1U=
-github.com/digitalocean/godo v1.128.0/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc=
+github.com/digitalocean/godo v1.128.1-0.20241025145008-2654a9d1e887 h1:kdXNbMfHEDbQilcqllKkNrJ85ftyJSvSDpsQvzrhHbg=
+github.com/digitalocean/godo v1.128.1-0.20241025145008-2654a9d1e887/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc=
 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/docker/cli v24.0.5+incompatible h1:WeBimjvS0eKdH4Ygx+ihVq1Q++xg36M/rMi4aXAvodc=
diff --git a/vendor/github.com/digitalocean/godo/droplets.go b/vendor/github.com/digitalocean/godo/droplets.go
index 5f198636..1ed09ec8 100644
--- a/vendor/github.com/digitalocean/godo/droplets.go
+++ b/vendor/github.com/digitalocean/godo/droplets.go
@@ -17,6 +17,7 @@ var errNoNetworks = errors.New("no networks have been defined")
 // See: https://docs.digitalocean.com/reference/api/api-reference/#tag/Droplets
 type DropletsService interface {
 	List(context.Context, *ListOptions) ([]Droplet, *Response, error)
+	ListWithGPUs(context.Context, *ListOptions) ([]Droplet, *Response, error)
 	ListByName(context.Context, string, *ListOptions) ([]Droplet, *Response, error)
 	ListByTag(context.Context, string, *ListOptions) ([]Droplet, *Response, error)
 	Get(context.Context, int) (*Droplet, *Response, error)
@@ -321,6 +322,17 @@ func (s *DropletsServiceOp) List(ctx context.Context, opt *ListOptions) ([]Dropl
 	return s.list(ctx, path)
 }
 
+// ListWithGPUs lists all Droplets with GPUs.
+func (s *DropletsServiceOp) ListWithGPUs(ctx context.Context, opt *ListOptions) ([]Droplet, *Response, error) {
+	path := fmt.Sprintf("%s?type=gpus", dropletBasePath)
+	path, err := addOptions(path, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return s.list(ctx, path)
+}
+
 // ListByName lists all Droplets filtered by name returning only exact matches.
 // It is case-insensitive
 func (s *DropletsServiceOp) ListByName(ctx context.Context, name string, opt *ListOptions) ([]Droplet, *Response, error) {
diff --git a/vendor/github.com/digitalocean/godo/sizes.go b/vendor/github.com/digitalocean/godo/sizes.go
index a3cb7452..72d5321c 100644
--- a/vendor/github.com/digitalocean/godo/sizes.go
+++ b/vendor/github.com/digitalocean/godo/sizes.go
@@ -22,16 +22,44 @@ var _ SizesService = &SizesServiceOp{}
 
 // Size represents a DigitalOcean Size
 type Size struct {
-	Slug         string   `json:"slug,omitempty"`
-	Memory       int      `json:"memory,omitempty"`
-	Vcpus        int      `json:"vcpus,omitempty"`
-	Disk         int      `json:"disk,omitempty"`
-	PriceMonthly float64  `json:"price_monthly,omitempty"`
-	PriceHourly  float64  `json:"price_hourly,omitempty"`
-	Regions      []string `json:"regions,omitempty"`
-	Available    bool     `json:"available,omitempty"`
-	Transfer     float64  `json:"transfer,omitempty"`
-	Description  string   `json:"description,omitempty"`
+	Slug         string     `json:"slug,omitempty"`
+	Memory       int        `json:"memory,omitempty"`
+	Vcpus        int        `json:"vcpus,omitempty"`
+	Disk         int        `json:"disk,omitempty"`
+	PriceMonthly float64    `json:"price_monthly,omitempty"`
+	PriceHourly  float64    `json:"price_hourly,omitempty"`
+	Regions      []string   `json:"regions,omitempty"`
+	Available    bool       `json:"available,omitempty"`
+	Transfer     float64    `json:"transfer,omitempty"`
+	Description  string     `json:"description,omitempty"`
+	GPUInfo      *GPUInfo   `json:"gpu_info,omitempty"`
+	DiskInfo     []DiskInfo `json:"disk_info,omitempty"`
+}
+
+// DiskInfo containing information about the disks available to Droplets created
+// with this size.
+type DiskInfo struct {
+	Type string    `json:"type,omitempty"`
+	Size *DiskSize `json:"size,omitempty"`
+}
+
+// DiskSize provides information about the size of a disk.
+type DiskSize struct {
+	Amount int    `json:"amount,omitempty"`
+	Unit   string `json:"unit,omitempty"`
+}
+
+// GPUInfo provides information about the GPU available to Droplets created with this size.
+type GPUInfo struct {
+	Count int    `json:"count,omitempty"`
+	VRAM  *VRAM  `json:"vram,omitempty"`
+	Model string `json:"model,omitempty"`
+}
+
+// VRAM provides information about the amount of VRAM available to the GPU.
+type VRAM struct {
+	Amount int    `json:"amount,omitempty"`
+	Unit   string `json:"unit,omitempty"`
 }
 
 func (s Size) String() string {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index bee6b11e..1ee0dde8 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -61,7 +61,7 @@ github.com/creack/pty
 # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
 ## explicit
 github.com/davecgh/go-spew/spew
-# github.com/digitalocean/godo v1.128.0
+# github.com/digitalocean/godo v1.128.1-0.20241025145008-2654a9d1e887
 ## explicit; go 1.22
 github.com/digitalocean/godo
 github.com/digitalocean/godo/metrics


Test patch:
diff --git a/commands/droplets_test.go b/commands/droplets_test.go
index b62e859f..2cc7ceed 100644
--- a/commands/droplets_test.go
+++ b/commands/droplets_test.go
@@ -398,6 +398,16 @@ func TestDropletsListByTag(t *testing.T) {
 	})
 }
 
+func TestDropletsListGPUs(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.droplets.EXPECT().ListWithGPUs().Return(testDropletList, nil)
+
+		config.Doit.Set(config.NS, doctl.ArgGPUs, true)
+		err := RunDropletList(config)
+		assert.NoError(t, err)
+	})
+}
+
 func TestDropletsTag(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 		trr := &godo.TagResourcesRequest{
@@ -415,6 +425,15 @@ func TestDropletsTag(t *testing.T) {
 	})
 }
 
+func TestDropletsListGPUsAndTagsExclusive(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		config.Doit.Set(config.NS, doctl.ArgGPUs, true)
+		config.Doit.Set(config.NS, doctl.ArgTagName, "my-tag")
+		err := RunDropletList(config)
+		assert.Error(t, err)
+	})
+}
+
 func TestDropletsTagMultiple(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 		trr := &godo.TagResourcesRequest{
diff --git a/integration/droplet_list_test.go b/integration/droplet_list_test.go
index 29d7e224..0f89cb1a 100644
--- a/integration/droplet_list_test.go
+++ b/integration/droplet_list_test.go
@@ -38,6 +38,14 @@ var _ = suite("compute/droplet/list", func(t *testing.T, when spec.G, it spec.S)
 
 				q := req.URL.Query()
 				tag := q.Get("tag_name")
+				dtype := q.Get("type")
+
+				if tag != "" && dtype != "" {
+					w.Write([]byte(`{"id": "unprocessible_entity", "message":"mutually exclusive query parameters"}`))
+					w.WriteHeader(http.StatusUnprocessableEntity)
+					return
+				}
+
 				if tag == "some-tag" {
 					w.Write([]byte(`{}`))
 					return
@@ -48,6 +56,10 @@ var _ = suite("compute/droplet/list", func(t *testing.T, when spec.G, it spec.S)
 					return
 				}
 
+				if dtype == "gpus" {
+					w.Write([]byte(dropletListGPUsResponse))
+				}
+
 				w.Write([]byte(dropletListResponse))
 			default:
 				dump, err := httputil.DumpRequest(req, true)
@@ -76,6 +88,23 @@ var _ = suite("compute/droplet/list", func(t *testing.T, when spec.G, it spec.S)
 		})
 	})
 
+	when("the gpu flag is passed", func() {
+		it("lists gpu droplets", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"compute",
+				"droplet",
+				"list",
+				"--gpus",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(dropletGPUListOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
 	when("a region is provided", func() {
 		it("filters the returned droplets by region", func() {
 			cmd := exec.Command(builtBinaryPath,
@@ -163,9 +192,30 @@ const (
   }]
 }`
 
+	dropletListGPUsResponse = `
+{
+  "droplets": [{
+    "id": 1111,
+    "name": "gpu-droplet",
+    "image": {
+      "distribution": "Ubuntu",
+      "name": "gpu-h100x8-640gb-200"
+    },
+    "region": {
+      "slug": "tor1"
+    },
+    "status": "active"
+  }]
+}`
+
 	dropletListOutput = `
 ID      Name                 Public IPv4    Private IPv4    Public IPv6    Memory    VCPUs    Disk    Region              Image                          VPC UUID    Status    Tags        Features    Volumes
 1111    some-droplet-name                                                  0         0        0       some-region-slug    some-distro some-image-name                active    test,yes    remotes     some-volume-id
+`
+
+	dropletGPUListOutput = `
+ID      Name           Public IPv4    Private IPv4    Public IPv6    Memory    VCPUs    Disk    Region    Image                          VPC UUID    Status    Tags    Features    Volumes
+1111    gpu-droplet                                                  0         0        0       tor1      Ubuntu gpu-h100x8-640gb-200                active
 `
 
 	dropletListRegionOutput = `