Repository: digitalocean/doctl (go)

Issue:
create droplet in a specific project
digitalocean can group droplets into "projects". 

"doctl compute droplet create" will create droplets into the default project.

how can I create a droplet in a specific project?


Pull request description:
droplet create: assign to project after create
Fixes #630

Adds a new optional flag to the `doctl compute droplet create` command, `--project-id`, that allows you to pass a project UUID. If a project UUID is passed, that Droplet will be assigned to that project after it is created.

This pattern of create-then-assign can probably be expanded to any resource that returns an URN, but since we've received quite a few requests for this functionality for Droplets, I figured this is a good starting point.

Patch:
diff --git a/commands/droplets.go b/commands/droplets.go
index 42ad6963..56e11189 100644
--- a/commands/droplets.go
+++ b/commands/droplets.go
@@ -65,9 +65,9 @@ func Droplet() *Command {
 		aliasOpt("b"), displayerType(&displayers.Image{}))
 	cmdDropletBackups.Example = `The following example retrieves a list of backups for a Droplet with the ID ` + "`" + `386734086` + "`" + `: doctl compute droplet backups 386734086`
 
-	dropletCreateLongDesc := `Creates a new Droplet on your account. The command requires values for the ` + "`" + `--size` + "`" + `, and ` + "`" + `--image` + "`" + ` flags. 
+	dropletCreateLongDesc := `Creates a new Droplet on your account. The command requires values for the ` + "`" + `--size` + "`" + `, and ` + "`" + `--image` + "`" + ` flags.
 
-To retrieve a list of size slugs, use the ` + "`" + `doctl compute size list` + "`" + ` command. To retrieve a list of image slugs, use the ` + "`" + `doctl compute image list` + "`" + ` command.	
+To retrieve a list of size slugs, use the ` + "`" + `doctl compute size list` + "`" + ` command. To retrieve a list of image slugs, use the ` + "`" + `doctl compute image list` + "`" + ` command.
 
 If you do not specify a region, the Droplet is created in the default region for your account. If you do not specify any SSH keys, we email a temporary password to your account's email address.`
 
@@ -88,6 +88,7 @@ If you do not specify a region, the Droplet is created in the default region for
 		requiredOpt())
 	AddStringFlag(cmdDropletCreate, doctl.ArgTagName, "", "", "Applies a tag to the Droplet")
 	AddStringFlag(cmdDropletCreate, doctl.ArgVPCUUID, "", "", "The UUID of a non-default VPC to create the Droplet in")
+	AddStringFlag(cmdDropletCreate, doctl.ArgProjectID, "", "", "The UUID of the project to assign the Droplet to")
 	AddStringSliceFlag(cmdDropletCreate, doctl.ArgTagNames, "", []string{}, "Applies a list of tags to the Droplet")
 	AddBoolFlag(cmdDropletCreate, doctl.ArgDropletAgent, "", false, "Specifies whether or not the Droplet monitoring agent should be installed. By default, the agent is installed on new Droplets but installation errors are ignored. Set `--droplet-agent=false` to prevent installation. Set to `true` to make installation errors fatal.")
 	AddStringSliceFlag(cmdDropletCreate, doctl.ArgVolumeList, "", []string{}, "A list of block storage volume IDs to attach to the Droplet")
@@ -230,6 +231,11 @@ func RunDropletCreate(c *CmdConfig) error {
 		return err
 	}
 
+	projectUUID, err := c.Doit.GetString(c.NS, doctl.ArgProjectID)
+	if err != nil {
+		return err
+	}
+
 	tagNames, err := c.Doit.GetStringSlice(c.NS, doctl.ArgTagNames)
 	if err != nil {
 		return err
@@ -329,6 +335,16 @@ func RunDropletCreate(c *CmdConfig) error {
 		}
 	}
 
+	if projectUUID != "" {
+		dropletURNs := make([]string, 0, len(createdList))
+		for _, createdDroplet := range createdList {
+			dropletURNs = append(dropletURNs, createdDroplet.URN())
+		}
+		if _, err := c.Projects().AssignResources(projectUUID, dropletURNs); err != nil {
+			return err
+		}
+	}
+
 	return c.Display(item)
 }
 
@@ -775,7 +791,7 @@ func dropletOneClicks() *Command {
 	}
 
 	cmdDropletOneClickList := CmdBuilder(cmd, RunDropletOneClickList, "list", "Retrieve a list of Droplet 1-Click applications", `Retrieves a list of Droplet 1-Click application slugs.
-	 
+
 You can use 1-click slugs to create Droplets by using them as the argument for the `+"`"+`--image`+"`"+` flag in the `+"`"+`doctl compute droplet create`+"`"+` command. For example, the following command creates a Droplet with an Openblocks installation on it: `+"`"+`doctl compute droplet create example-droplet --image openblocks --size s-2vcpu-2gb --region nyc1`+"`"+``, Writer,
 		aliasOpt("ls"), displayerType(&displayers.OneClick{}))
 	cmdDropletOneClickList.Example = `The following example retrieves a list of 1-clicks for Droplets: doctl compute droplet 1-click list`


Test patch:
diff --git a/commands/droplets_test.go b/commands/droplets_test.go
index 20844a92..b62e859f 100644
--- a/commands/droplets_test.go
+++ b/commands/droplets_test.go
@@ -199,6 +199,33 @@ coreos:
 	})
 }
 
+func TestDropletCreateWithProjectID(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		projectUUID := "00000000-0000-4000-8000-000000000000"
+
+		dcr := &godo.DropletCreateRequest{
+			Name:    "droplet",
+			Region:  "dev0",
+			Size:    "1gb",
+			Image:   godo.DropletCreateImage{ID: 0, Slug: "image"},
+			SSHKeys: []godo.DropletCreateSSHKey{},
+		}
+		tm.droplets.EXPECT().Create(dcr, false).Return(&testDroplet, nil)
+		tm.projects.EXPECT().
+			AssignResources(projectUUID, []string{testDroplet.URN()}).
+			Return(do.ProjectResources{}, nil)
+
+		config.Args = append(config.Args, "droplet")
+		config.Doit.Set(config.NS, doctl.ArgRegionSlug, "dev0")
+		config.Doit.Set(config.NS, doctl.ArgSizeSlug, "1gb")
+		config.Doit.Set(config.NS, doctl.ArgImage, "image")
+		config.Doit.Set(config.NS, doctl.ArgProjectID, projectUUID)
+
+		err := RunDropletCreate(config)
+		assert.NoError(t, err)
+	})
+}
+
 func TestDropletDelete(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 		tm.droplets.EXPECT().Delete(1).Return(nil)
diff --git a/integration/droplet_create_test.go b/integration/droplet_create_test.go
index 042c47ee..136e64b0 100644
--- a/integration/droplet_create_test.go
+++ b/integration/droplet_create_test.go
@@ -19,11 +19,16 @@ type dropletRequest struct {
 	Name string `json:"name"`
 }
 
+type assignResourcesRequest struct {
+	Resources []string `json:"resources"`
+}
+
 var _ = suite("compute/droplet/create", func(t *testing.T, when spec.G, it spec.S) {
 	var (
-		expect  *require.Assertions
-		server  *httptest.Server
-		reqBody []byte
+		expect                 *require.Assertions
+		server                 *httptest.Server
+		reqBody                []byte
+		assignResourcesReqBody []byte
 	)
 
 	it.Before(func() {
@@ -64,6 +69,30 @@ var _ = suite("compute/droplet/create", func(t *testing.T, when spec.G, it spec.
 				// since we've successfully tested all the behavior
 				// at this point
 				w.Write([]byte(dropletCreateResponse))
+			case "/v2/projects/00000000-0000-4000-8000-000000000000/resources":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodPost {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				var err error
+				assignResourcesReqBody, err = io.ReadAll(req.Body)
+				expect.NoError(err)
+
+				var arr assignResourcesRequest
+				err = json.Unmarshal(assignResourcesReqBody, &arr)
+				expect.NoError(err)
+
+				expect.Greater(len(arr.Resources), 0)
+				expect.Equal(arr.Resources[0], "do:droplet:1111")
+
+				w.Write([]byte(assignResourcesResponse))
 			default:
 				dump, err := httputil.DumpRequest(req, true)
 				if err != nil {
@@ -133,6 +162,42 @@ var _ = suite("compute/droplet/create", func(t *testing.T, when spec.G, it spec.
 		})
 	})
 
+	when("a project id is passed", func() {
+		it("creates a droplet and moves it to that project", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"compute",
+				"droplet",
+				"create",
+				"some-droplet-name",
+				"--image", "a-test-image",
+				"--region", "a-test-region",
+				"--size", "a-test-size",
+				"--project-id", "00000000-0000-4000-8000-000000000000",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(dropletCreateOutput), strings.TrimSpace(string(output)))
+
+			request := &struct {
+				Name   string
+				Image  string
+				Region string
+				Size   string
+			}{}
+
+			err = json.Unmarshal(reqBody, request)
+			expect.NoError(err)
+
+			expect.Equal("some-droplet-name", request.Name)
+			expect.Equal("a-test-image", request.Image)
+			expect.Equal("a-test-region", request.Region)
+			expect.Equal("a-test-size", request.Size)
+		})
+	})
+
 	when("missing required arguments", func() {
 		base := []string{
 			"-t", "some-magic-token",
@@ -208,6 +273,20 @@ const (
 	actionCompletedResponse = `
 {"action": "id": 1, "status": "completed"}
 `
+	assignResourcesResponse = `{
+  "resources": [
+    {
+      "urn": "do:droplet:1111",
+      "assigned_at": "2024-01-01T00:00:00Z",
+      "links": {
+        "self": "https://api.digitalocean.com/v2/droplets/1111"
+      },
+      "status": "ok"
+    }
+  ]
+}
+`
+
 	dropletCreateOutput = `
 ID      Name                 Public IPv4    Private IPv4    Public IPv6    Memory    VCPUs    Disk    Region              Image                          VPC UUID                                Status    Tags    Features    Volumes
 1111    some-droplet-name    1.2.3.4        7.7.7.7                        12        13       15      some-region-slug    some-distro some-image-name    00000000-0000-4000-8000-000000000000    active    yes     remotes     some-volume-id