Repository: digitalocean/doctl
Pull request #1301 (2022-10-31 21:12:11)

apps: build versioning, add `list-buildpacks` and `upgrade-buildpack`


Patch:
diff --git a/args.go b/args.go
index 601c67d1..84b92087 100644
--- a/args.go
+++ b/args.go
@@ -48,6 +48,8 @@ const (
 	ArgAppDevConfig = "dev-config"
 	// ArgBuildCommand is an optional build command to set for local development.
 	ArgBuildCommand = "build-command"
+	// ArgBuildpack is a buildpack id.
+	ArgBuildpack = "buildpack"
 	// ArgAppLogFollow follow logs.
 	ArgAppLogFollow = "follow"
 	// ArgAppLogTail tail logs.
@@ -84,6 +86,8 @@ const (
 	ArgNodePoolNodeIDs = "node-ids"
 	// ArgMaintenanceWindow is a cluster's maintenance window argument
 	ArgMaintenanceWindow = "maintenance-window"
+	// ArgMajorVersion is a major version number.
+	ArgMajorVersion = "major-version"
 	// ArgAutoUpgrade is a cluster's auto-upgrade argument.
 	ArgAutoUpgrade = "auto-upgrade"
 	// ArgHA is a cluster's highly available control plane argument.
@@ -226,6 +230,8 @@ const (
 	ArgTemplate = "template"
 	// ArgTimeout is a timeout duration
 	ArgTimeout = "timeout"
+	// ArgTriggerDeployment indicates whether to trigger a deployment
+	ArgTriggerDeployment = "trigger-deployment"
 	// ArgVersion is the version of the command to use
 	ArgVersion = "version"
 	// ArgVerbose enables verbose output
diff --git a/commands/apps.go b/commands/apps.go
index bfa373fc..51b468bf 100644
--- a/commands/apps.go
+++ b/commands/apps.go
@@ -227,6 +227,32 @@ Only basic information is included with the text output format. For complete app
 	)
 	AddStringFlag(updateAlertDestinations, doctl.ArgAppAlertDestinations, "", "", "Path to an alert destinations file in JSON or YAML format.")
 
+	CmdBuilder(
+		cmd,
+		RunAppListBuildpacks,
+		"list-buildpacks",
+		"List buildpacks",
+		`List all buildpacks available on App Platform`,
+		Writer,
+		displayerType(&displayers.Buildpacks{}),
+	)
+
+	upgradeBuildpack := CmdBuilder(
+		cmd,
+		RunAppUpgradeBuildpack,
+		"upgrade-buildpack <app id>",
+		"Upgrade buildpack",
+		`Upgrade a buildpack for an app`,
+		Writer,
+		displayerType(&displayers.Deployments{}),
+	)
+	AddStringFlag(upgradeBuildpack,
+		doctl.ArgBuildpack, "", "", "The ID of the buildpack to upgrade. Use the list-buildpacks command to list available buildpacks.", requiredOpt())
+	AddIntFlag(upgradeBuildpack,
+		doctl.ArgMajorVersion, "", 0, "The major version to upgrade to. If empty, will upgrade to the latest available.")
+	AddBoolFlag(upgradeBuildpack,
+		doctl.ArgTriggerDeployment, "", true, "Whether to trigger a new deployment to apply the upgrade")
+
 	cmd.AddCommand(appsSpec())
 	cmd.AddCommand(appsTier())
 
@@ -959,6 +985,54 @@ func parseAppAlert(destinations []byte) (*godo.AlertDestinationUpdateRequest, er
 	return &alertDestinations, nil
 }
 
+// RunAppListBuildpacks lists buildpacks
+func RunAppListBuildpacks(c *CmdConfig) error {
+	bps, err := c.Apps().ListBuildpacks()
+	if err != nil {
+		return err
+	}
+	return c.Display(displayers.Buildpacks(bps))
+}
+
+// RunAppUpgradeBuildpack upgrades a buildpack for an app
+func RunAppUpgradeBuildpack(c *CmdConfig) error {
+	if len(c.Args) < 1 {
+		return doctl.NewMissingArgsErr(c.NS)
+	}
+
+	appID := c.Args[0]
+	buildpack, err := c.Doit.GetString(c.NS, doctl.ArgBuildpack)
+	if err != nil {
+		return err
+	}
+	majorVersion, err := c.Doit.GetInt(c.NS, doctl.ArgMajorVersion)
+	if err != nil {
+		return err
+	}
+	triggerDeployment, err := c.Doit.GetBool(c.NS, doctl.ArgTriggerDeployment)
+	if err != nil {
+		return err
+	}
+
+	components, dep, err := c.Apps().UpgradeBuildpack(appID, godo.UpgradeBuildpackOptions{
+		BuildpackID:       buildpack,
+		MajorVersion:      int32(majorVersion),
+		TriggerDeployment: triggerDeployment,
+	})
+	if err != nil {
+		return err
+	}
+
+	fmt.Fprintf(os.Stderr, "upgraded buildpack %s. %d components were affected: %v.\n", buildpack, len(components), components)
+
+	if dep != nil {
+		fmt.Fprint(os.Stderr, "triggered a new deployment to apply the upgrade:\n\n")
+		return c.Display(displayers.Deployments([]*godo.Deployment{dep}))
+	}
+
+	return nil
+}
+
 func getIDByName(apps []*godo.App, name string) (string, error) {
 	for _, app := range apps {
 		if app.Spec.Name == name {
diff --git a/commands/command.go b/commands/command.go
index b98c9017..7aa2b68c 100644
--- a/commands/command.go
+++ b/commands/command.go
@@ -96,7 +96,7 @@ func cmdBuilderWithInit(parent *Command, cr CmdRunner, cliText, shortdesc string
 	}
 
 	if cols := c.fmtCols; cols != nil {
-		formatHelp := fmt.Sprintf("Columns for output in a comma-separated list. Possible values: `%s`",
+		formatHelp := fmt.Sprintf("Columns for output in a comma-separated list. Possible values: ```%s`.",
 			strings.Join(cols, "`"+", "+"`"))
 		AddStringFlag(c, doctl.ArgFormat, "", "", formatHelp)
 		AddBoolFlag(c, doctl.ArgNoHeader, "", false, "Return raw data with no headers")
diff --git a/commands/displayers/apps.go b/commands/displayers/apps.go
index 4fb6e855..db6953ab 100644
--- a/commands/displayers/apps.go
+++ b/commands/displayers/apps.go
@@ -102,9 +102,13 @@ func (d Deployments) KV() []map[string]interface{} {
 	out := make([]map[string]interface{}, len(d))
 
 	for i, deployment := range d {
-		progress := fmt.Sprintf("%d/%d", deployment.Progress.SuccessSteps, deployment.Progress.TotalSteps)
-		if deployment.Progress.ErrorSteps > 0 {
-			progress = fmt.Sprintf("%s (errors: %d)", progress, deployment.Progress.ErrorSteps)
+		var progress string
+		if deployment.Progress != nil {
+			p := deployment.Progress
+			progress = fmt.Sprintf("%d/%d", p.SuccessSteps, p.TotalSteps)
+			if p.ErrorSteps > 0 {
+				progress = fmt.Sprintf("%s (errors: %d)", progress, p.ErrorSteps)
+			}
 		}
 
 		out[i] = map[string]interface{}{
@@ -453,3 +457,45 @@ func (a AppAlerts) JSON(w io.Writer) error {
 	e.SetIndent("", "  ")
 	return e.Encode(a)
 }
+
+type Buildpacks []*godo.Buildpack
+
+var _ Displayable = (*Buildpacks)(nil)
+
+func (b Buildpacks) Cols() []string {
+	return []string{
+		"Name",
+		"ID",
+		"Version",
+		"Documentation",
+	}
+}
+
+func (b Buildpacks) ColMap() map[string]string {
+	return map[string]string{
+		"Name":          "Name",
+		"ID":            "ID",
+		"Version":       "Version",
+		"Documentation": "Documentation",
+	}
+}
+
+func (b Buildpacks) KV() []map[string]interface{} {
+	out := make([]map[string]interface{}, len(b))
+
+	for i, bp := range b {
+		out[i] = map[string]interface{}{
+			"Name":          bp.Name,
+			"ID":            bp.ID,
+			"Version":       bp.Version,
+			"Documentation": bp.DocsLink,
+		}
+	}
+	return out
+}
+
+func (b Buildpacks) JSON(w io.Writer) error {
+	e := json.NewEncoder(w)
+	e.SetIndent("", "  ")
+	return e.Encode(b)
+}
diff --git a/do/apps.go b/do/apps.go
index 63c01c38..5c050757 100644
--- a/do/apps.go
+++ b/do/apps.go
@@ -44,6 +44,9 @@ type AppsService interface {
 
 	ListAlerts(appID string) ([]*godo.AppAlert, error)
 	UpdateAlertDestinations(appID, alertID string, update *godo.AlertDestinationUpdateRequest) (*godo.AppAlert, error)
+
+	ListBuildpacks() ([]*godo.Buildpack, error)
+	UpgradeBuildpack(appID string, options godo.UpgradeBuildpackOptions) (affectedComponents []string, deployment *godo.Deployment, err error)
 }
 
 type appsService struct {
@@ -238,3 +241,19 @@ func (s *appsService) UpdateAlertDestinations(appID, alertID string, update *god
 	}
 	return alert, nil
 }
+
+func (s *appsService) ListBuildpacks() ([]*godo.Buildpack, error) {
+	bps, _, err := s.client.Apps.ListBuildpacks(s.ctx)
+	if err != nil {
+		return nil, err
+	}
+	return bps, nil
+}
+
+func (s *appsService) UpgradeBuildpack(appID string, options godo.UpgradeBuildpackOptions) ([]string, *godo.Deployment, error) {
+	res, _, err := s.client.Apps.UpgradeBuildpack(s.ctx, appID, options)
+	if err != nil {
+		return nil, nil, err
+	}
+	return res.AffectedComponents, res.Deployment, nil
+}
diff --git a/do/mocks/AppsService.go b/do/mocks/AppsService.go
index 89ca6ebd..40cb8e1e 100644
--- a/do/mocks/AppsService.go
+++ b/do/mocks/AppsService.go
@@ -183,6 +183,21 @@ func (mr *MockAppsServiceMockRecorder) ListAlerts(appID interface{}) *gomock.Cal
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAlerts", reflect.TypeOf((*MockAppsService)(nil).ListAlerts), appID)
 }
 
+// ListBuildpacks mocks base method.
+func (m *MockAppsService) ListBuildpacks() ([]*godo.Buildpack, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListBuildpacks")
+	ret0, _ := ret[0].([]*godo.Buildpack)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListBuildpacks indicates an expected call of ListBuildpacks.
+func (mr *MockAppsServiceMockRecorder) ListBuildpacks() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBuildpacks", reflect.TypeOf((*MockAppsService)(nil).ListBuildpacks))
+}
+
 // ListDeployments mocks base method.
 func (m *MockAppsService) ListDeployments(appID string) ([]*godo.Deployment, error) {
 	m.ctrl.T.Helper()
@@ -287,3 +302,19 @@ func (mr *MockAppsServiceMockRecorder) UpdateAlertDestinations(appID, alertID, u
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAlertDestinations", reflect.TypeOf((*MockAppsService)(nil).UpdateAlertDestinations), appID, alertID, update)
 }
+
+// UpgradeBuildpack mocks base method.
+func (m *MockAppsService) UpgradeBuildpack(appID string, options godo.UpgradeBuildpackOptions) ([]string, *godo.Deployment, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpgradeBuildpack", appID, options)
+	ret0, _ := ret[0].([]string)
+	ret1, _ := ret[1].(*godo.Deployment)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// UpgradeBuildpack indicates an expected call of UpgradeBuildpack.
+func (mr *MockAppsServiceMockRecorder) UpgradeBuildpack(appID, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeBuildpack", reflect.TypeOf((*MockAppsService)(nil).UpgradeBuildpack), appID, options)
+}
diff --git a/scripts/regenmocks.sh b/scripts/regenmocks.sh
index 945c348f..b52b80f5 100755
--- a/scripts/regenmocks.sh
+++ b/scripts/regenmocks.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 
 # regenerated generated mocks
 
@@ -6,7 +6,7 @@ set -euo pipefail
 
 cd "do"
 
-GO111MODULE=off go get -u github.com/golang/mock/mockgen
+go install -mod=readonly github.com/golang/mock/mockgen@latest
 
 mockgen -source account.go -package=mocks AccountService > mocks/AccountService.go
 mockgen -source actions.go -package=mocks ActionService > mocks/ActionService.go
@@ -41,4 +41,4 @@ mockgen -source ../pkg/listen/listen.go -package=mocks Listen > mocks/Listen.go
 mockgen -source monitoring.go -package=mocks MonitoringService > mocks/MonitoringService.go
 mockgen -source reserved_ip_actions.go -package=mocks ReservedIPActionsService > mocks/ReservedIPActionsService.go
 mockgen -source reserved_ips.go -package=mocks ReservedIPsService > mocks/ReservedIPsService.go
-mockgen -source serverless.go -package=mocks ServerlessService > mocks/ServerlessService.go
\ No newline at end of file
+mockgen -source serverless.go -package=mocks ServerlessService > mocks/ServerlessService.go


Test patch:
diff --git a/commands/apps_test.go b/commands/apps_test.go
index 825325bf..f5775c41 100644
--- a/commands/apps_test.go
+++ b/commands/apps_test.go
@@ -38,6 +38,8 @@ func TestAppsCommand(t *testing.T) {
 		"tier",
 		"list-alerts",
 		"update-alert-destinations",
+		"list-buildpacks",
+		"upgrade-buildpack",
 	)
 }
 
@@ -114,6 +116,16 @@ var (
 			},
 		},
 	}
+
+	testBuildpack = &godo.Buildpack{
+		Name:         "Go",
+		ID:           "digitalocean/go",
+		Version:      "1.2.3",
+		MajorVersion: 1,
+		Latest:       true,
+		DocsLink:     "ftp://docs/go",
+		Description:  []string{"Install Go"},
+	}
 )
 
 func TestRunAppsCreate(t *testing.T) {
@@ -775,3 +787,37 @@ func TestRunAppsUpdateAlertDestinations(t *testing.T) {
 		require.NoError(t, err)
 	})
 }
+
+func TestRunAppsListBuildpacks(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.apps.EXPECT().ListBuildpacks().Times(1).Return([]*godo.Buildpack{testBuildpack}, nil)
+
+		err := RunAppListBuildpacks(config)
+		require.NoError(t, err)
+	})
+}
+
+func TestRunAppsUpgradeBuildpack(t *testing.T) {
+	deployment := &godo.Deployment{
+		ID:        uuid.New().String(),
+		Spec:      &testAppSpec,
+		CreatedAt: time.Now(),
+		UpdatedAt: time.Now(),
+	}
+
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		appID := uuid.New().String()
+		tm.apps.EXPECT().UpgradeBuildpack(appID, godo.UpgradeBuildpackOptions{
+			BuildpackID:       "buildpack-id",
+			MajorVersion:      3,
+			TriggerDeployment: true,
+		}).Times(1).Return([]string{"www", "api"}, deployment, nil)
+
+		config.Args = append(config.Args, appID)
+		config.Doit.Set(config.NS, doctl.ArgBuildpack, "buildpack-id")
+		config.Doit.Set(config.NS, doctl.ArgMajorVersion, 3)
+		config.Doit.Set(config.NS, doctl.ArgTriggerDeployment, true)
+		err := RunAppUpgradeBuildpack(config)
+		require.NoError(t, err)
+	})
+}
diff --git a/integration/apps_test.go b/integration/apps_test.go
index 925b531c..db1f2afd 100644
--- a/integration/apps_test.go
+++ b/integration/apps_test.go
@@ -3,6 +3,7 @@ package integration
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
@@ -13,6 +14,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/MakeNowJust/heredoc"
 	"github.com/digitalocean/godo"
 	"github.com/gorilla/websocket"
 	"github.com/sclevine/spec"
@@ -100,6 +102,15 @@ var (
 		CreatedAt:            testAppTime,
 		UpdatedAt:            testAppTime,
 	}
+	testBuildpack = &godo.Buildpack{
+		Name:         "Go",
+		ID:           "digitalocean/go",
+		Version:      "1.2.3",
+		MajorVersion: 1,
+		Latest:       true,
+		DocsLink:     "ftp://docs/go",
+		Description:  []string{"Install Go"},
+	}
 	testAppResponse = struct {
 		App *godo.App `json:"app"`
 	}{
@@ -1214,3 +1225,138 @@ services:
 		expect.Equal(expectedOutput, strings.TrimSpace(string(output)))
 	})
 })
+
+var _ = suite("apps/list-buildpacks", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Header().Add("content-type", "application/json")
+
+			switch req.URL.Path {
+			case "/v2/apps/buildpacks":
+				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
+				}
+
+				json.NewEncoder(w).Encode(struct {
+					Buildpacks []*godo.Buildpack `json:"buildpacks"`
+				}{
+					Buildpacks: []*godo.Buildpack{testBuildpack},
+				})
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	it("lists buildpacks", func() {
+		cmd := exec.Command(builtBinaryPath,
+			"-t", "some-magic-token",
+			"-u", server.URL,
+			"apps",
+			"list-buildpacks",
+		)
+
+		output, err := cmd.CombinedOutput()
+		expect.NoError(err)
+
+		expect.Equal(heredoc.Doc(`
+			Name    ID                 Version    Documentation
+			Go      digitalocean/go    1.2.3      ftp://docs/go
+		`), string(output))
+	})
+})
+
+var _ = suite("apps/upgrade-buildpack", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			w.Header().Add("content-type", "application/json")
+
+			switch req.URL.Path {
+			case fmt.Sprintf("/v2/apps/%s/upgrade_buildpack", testAppUUID):
+				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 r godo.UpgradeBuildpackOptions
+				err := json.NewDecoder(req.Body).Decode(&r)
+				if err != nil {
+					w.WriteHeader(http.StatusBadRequest)
+					return
+				}
+				assert.Equal(t, godo.UpgradeBuildpackOptions{
+					BuildpackID:       "digitalocean/go",
+					MajorVersion:      3,
+					TriggerDeployment: true,
+				}, r)
+
+				json.NewEncoder(w).Encode(&godo.UpgradeBuildpackResponse{
+					AffectedComponents: []string{"api", "www"},
+					Deployment:         testDeployment,
+				})
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	it("upgrades buildpacks", func() {
+		cmd := exec.Command(builtBinaryPath,
+			"-t", "some-magic-token",
+			"-u", server.URL,
+			"apps",
+			"upgrade-buildpack",
+			"--buildpack", "digitalocean/go",
+			"--major-version", "3",
+			testAppUUID,
+		)
+
+		output, err := cmd.CombinedOutput()
+
+		expect.Equal(heredoc.Doc(`
+			upgraded buildpack digitalocean/go. 2 components were affected: [api www].
+			triggered a new deployment to apply the upgrade:
+			
+			ID                                      Cause     Progress    Created At                       Updated At
+			f4e37431-a0f4-458f-8f9f-5c9a61d8562f    Manual    0/1         1970-01-01 00:00:01 +0000 UTC    1970-01-01 00:00:01 +0000 UTC
+		`), string(output))
+		expect.NoError(err)
+	})
+})