Repository: digitalocean/doctl (go)

Issue:
Add --wait flag to load-balancer create command
**What is the problem this feature would solve? Please describe.**

`doctl` aims to be useful in scripting. One way it does this is by providing a `--wait` flag for long running, async operations to block until until they are complete rather than making the user implement polling in their own script. Some examples include:

- `doctl compute droplet create --wait`
- `doctl kubernetes cluster create --wait`
- `doctl apps create --wait`

Currently the `doctl compute load-balancer create` command does not have a `--wait` flag.

**Describe the solution you'd like**

Add a `--wait` flag to the `doctl compute load-balancer create` command. It should poll the [`/v2/load_balancers/$LOAD_BALANCER_ID` endpoint](https://docs.digitalocean.com/reference/api/api-reference/#operation/loadBalancers_get), blocking until the load balancer's `status` attribute moves from `new` to `active`.

**Additional context**

Following the code path starting here demonstrates how `doctl apps create`'s  `--wait` flag is implemented:

https://github.com/digitalocean/doctl/blob/de1918ad1ee4b1e523c335fa9254e85d415d18aa/commands/apps.go#L282


Pull request description:
Add --wait flag to load_balancer create
## Description

Adds `--wait` flag to the doctl compute load-balancer create command. 

It polls the [/v2/load_balancers/$LOAD_BALANCER_ID endpoint](https://docs.digitalocean.com/reference/api/api-reference/#operation/loadBalancers_get), blocking until the load balancer's status attribute moves from new to active.

## Motivation and context

Closes #1264 

## Types of changes

Feature enhancement.

## Checklist:

- [x] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document.
- [x] My pull request addresses exactly one patch/feature.
- [x] I have created a branch for this patch/feature.
- [x] Each individual commit in the pull request is meaningful.
- [x] I have added tests to cover my changes.
- [ ] If my change requires a change to the documentation, I have updated it accordingly.

Patch:
diff --git a/commands/load_balancers.go b/commands/load_balancers.go
index 7a5f1950..66a67bbc 100644
--- a/commands/load_balancers.go
+++ b/commands/load_balancers.go
@@ -15,9 +15,11 @@ package commands
 
 import (
 	"fmt"
+	"os"
 	"reflect"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/digitalocean/doctl"
 	"github.com/digitalocean/doctl/commands/displayers"
@@ -98,6 +100,7 @@ With the load-balancer command, you can list, create, or delete load balancers,
 		"A comma-separated list of key-value pairs representing recent health check results, e.g.: `protocol:http,port:80,path:/index.html,check_interval_seconds:10,response_timeout_seconds:5,healthy_threshold:5,unhealthy_threshold:3`")
 	AddStringFlag(cmdRecordCreate, doctl.ArgForwardingRules, "", "",
 		forwardingRulesTxt)
+	AddBoolFlag(cmdRecordCreate, doctl.ArgCommandWait, "", false, "Boolean that specifies whether to wait for a load balancer to complete before returning control to the terminal")
 
 	cmdRecordUpdate := CmdBuilder(cmd, RunLoadBalancerUpdate, "update <id>",
 		"Update a load balancer's configuration", `Use this command to update the configuration of a specified load balancer.`, Writer, aliasOpt("u"))
@@ -201,6 +204,28 @@ func RunLoadBalancerCreate(c *CmdConfig) error {
 		return err
 	}
 
+	wait, err := c.Doit.GetBool(c.NS, doctl.ArgCommandWait)
+	if err != nil {
+		return err
+	}
+
+	if wait {
+		lbs := c.LoadBalancers()
+		notice("Load balancer creation is in progress, waiting for load balancer to become active")
+
+		err := waitForActiveLoadBalancer(lbs, lb.ID)
+		if err != nil {
+			return fmt.Errorf(
+				"load balancer couldn't enter `active` state: %v",
+				err,
+			)
+		}
+
+		lb, _ = lbs.Get(lb.ID)
+	}
+
+	notice("Load balancer created")
+
 	item := &displayers.LoadBalancer{LoadBalancers: do.LoadBalancers{*lb}}
 	return c.Display(item)
 }
@@ -513,3 +538,45 @@ func buildRequestFromArgs(c *CmdConfig, r *godo.LoadBalancerRequest) error {
 
 	return nil
 }
+
+func waitForActiveLoadBalancer(lbs do.LoadBalancersService, lbID string) error {
+	const maxAttempts = 180
+	const wantStatus = "active"
+	const errStatus = "errored"
+	attempts := 0
+	printNewLineSet := false
+
+	for i := 0; i < maxAttempts; i++ {
+		if attempts != 0 {
+			fmt.Fprint(os.Stderr, ".")
+			if !printNewLineSet {
+				printNewLineSet = true
+				defer fmt.Fprintln(os.Stderr)
+			}
+		}
+
+		lb, err := lbs.Get(lbID)
+		if err != nil {
+			return err
+		}
+
+		if lb.Status == errStatus {
+			return fmt.Errorf(
+				"load balancer (%s) entered status `errored`",
+				lbID,
+			)
+		}
+
+		if lb.Status == wantStatus {
+			return nil
+		}
+
+		attempts++
+		time.Sleep(10 * time.Second)
+	}
+
+	return fmt.Errorf(
+		"timeout waiting for load balancer (%s) to become active",
+		lbID,
+	)
+}


Test patch:
diff --git a/integration/lb_create_test.go b/integration/lb_create_test.go
index 28914584..8f250b1c 100644
--- a/integration/lb_create_test.go
+++ b/integration/lb_create_test.go
@@ -15,6 +15,7 @@ import (
 )
 
 var _ = suite("compute/load-balancer/create", func(t *testing.T, when spec.G, it spec.S) {
+	const testUUID = "4de7ac8b-495b-4884-9a69-1050c6793cd6"
 	var (
 		expect   *require.Assertions
 		server   *httptest.Server
@@ -45,6 +46,19 @@ var _ = suite("compute/load-balancer/create", func(t *testing.T, when spec.G, it
 				expect.JSONEq(lbCreateRequest, string(reqBody))
 
 				w.Write([]byte(lbCreateResponse))
+			case "/v2/load_balancers/" + testUUID:
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(lbWaitGetResponse))
 			default:
 				dump, err := httputil.DumpRequest(req, true)
 				if err != nil {
@@ -85,6 +99,17 @@ var _ = suite("compute/load-balancer/create", func(t *testing.T, when spec.G, it
 			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
 			expect.Equal(strings.TrimSpace(lbCreateOutput), strings.TrimSpace(string(output)))
 		})
+		when("the wait flag is passed", func() {
+			it("creates a load balancer and polls for status", func() {
+				baseArgsWithWait := append([]string{"--wait"}, baseArgs...)
+				args := append([]string{"create"}, baseArgsWithWait...)
+				cmd.Args = append(cmd.Args, args...)
+
+				output, err := cmd.CombinedOutput()
+				expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+				expect.Equal(strings.TrimSpace(lbWaitCreateOutput), strings.TrimSpace(string(output)))
+			})
+		})
 	})
 
 	when("command is c", func() {
@@ -101,9 +126,18 @@ var _ = suite("compute/load-balancer/create", func(t *testing.T, when spec.G, it
 
 const (
 	lbCreateOutput = `
+Notice: Load balancer created
 ID                                      IP    Name             Status    Created At              Algorithm      Region    Size        Size Unit    VPC UUID                                Tag    Droplet IDs        SSL     Sticky Sessions                                Health Check                                                                                                            Forwarding Rules    Disable Lets Encrypt DNS Records
 4de7ac8b-495b-4884-9a69-1050c6793cd6          example-lb-01    new       2017-02-01T22:22:58Z    round_robin    nyc3      lb-small    <nil>        00000000-0000-4000-8000-000000000000           3164444,3164445    true    type:none,cookie_name:,cookie_ttl_seconds:0    protocol:,port:0,path:,check_interval_seconds:0,response_timeout_seconds:0,healthy_threshold:0,unhealthy_threshold:0                        true
 `
+
+	lbWaitCreateOutput = `
+Notice: Load balancer creation is in progress, waiting for load balancer to become active
+Notice: Load balancer created
+ID                                      IP    Name             Status    Created At              Algorithm      Region    Size        Size Unit    VPC UUID                                Tag    Droplet IDs        SSL     Sticky Sessions                                Health Check                                                                                                            Forwarding Rules    Disable Lets Encrypt DNS Records
+4de7ac8b-495b-4884-9a69-1050c6793cd6          example-lb-01    active    2017-02-01T22:22:58Z    round_robin    nyc3      lb-small    <nil>        00000000-0000-4000-8000-000000000000           3164444,3164445    true    type:none,cookie_name:,cookie_ttl_seconds:0    protocol:,port:0,path:,check_interval_seconds:0,response_timeout_seconds:0,healthy_threshold:0,unhealthy_threshold:0                        true
+`
+
 	lbCreateResponse = `
 {
   "load_balancer": {
@@ -158,4 +192,43 @@ ID                                      IP    Name             Status    Created
   "disable_lets_encrypt_dns_records": true,
   "vpc_uuid": "00000000-0000-4000-8000-000000000000"
 }`
+
+	lbWaitGetResponse = `
+{
+  "load_balancer": {
+    "id": "4de7ac8b-495b-4884-9a69-1050c6793cd6",
+    "name": "example-lb-01",
+    "ip": "",
+    "algorithm": "round_robin",
+    "status": "active",
+    "created_at": "2017-02-01T22:22:58Z",
+    "forwarding_rules": [],
+    "health_check": {},
+    "sticky_sessions": {
+      "type": "none"
+    },
+    "region": {
+      "name": "New York 3",
+      "slug": "nyc3",
+      "sizes": [
+        "s-32vcpu-192gb"
+      ],
+      "features": [
+        "install_agent"
+      ],
+      "available": true
+	},
+	"size": "lb-small",
+    "vpc_uuid": "00000000-0000-4000-8000-000000000000",
+    "tag": "",
+    "droplet_ids": [
+      3164444,
+      3164445
+    ],
+    "redirect_http_to_https": true,
+    "enable_proxy_protocol": true,
+	"disable_lets_encrypt_dns_records": true,
+    "enable_backend_keepalive": true
+  }
+}`
 )