Repository: digitalocean/doctl
Pull request #1608 (2024-11-05 14:41:31)

APPS-9765 Add command to restart apps
This PR adds a command to restart apps, with an option to restart specific components only.

~TODO: update godo version in go.mod once godo is released with Restart method.~ (this is done)


Patch:
diff --git a/args.go b/args.go
index 0accf8fa..32c56272 100644
--- a/args.go
+++ b/args.go
@@ -58,6 +58,8 @@ const (
 	ArgNoPrefix = "no-prefix"
 	// ArgAppForceRebuild forces a deployment rebuild
 	ArgAppForceRebuild = "force-rebuild"
+	// ArgAppComponents is a list of components to restart.
+	ArgAppComponents = "components"
 	// ArgAppAlertDestinations is a path to an app alert destination file.
 	ArgAppAlertDestinations = "app-alert-destinations"
 	// ArgClusterName is a cluster name argument.
diff --git a/commands/apps.go b/commands/apps.go
index 8a78ea94..5be52ca8 100644
--- a/commands/apps.go
+++ b/commands/apps.go
@@ -127,6 +127,21 @@ This permanently deletes the app and all of its associated deployments.`,
 	AddBoolFlag(deleteApp, doctl.ArgForce, doctl.ArgShortForce, false, "Delete the App without a confirmation prompt")
 	deleteApp.Example = `The following example deletes an app with the ID ` + "`" + `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` + "`" + `: doctl apps delete f81d4fae-7dec-11d0-a765-00a0c91e6bf6`
 
+	restartApp := CmdBuilder(
+		cmd,
+		RunAppsRestart,
+		"restart <app id>",
+		"Restarts an app",
+		`Restarts the specified app or some of its components.`,
+		Writer,
+		aliasOpt("r"),
+		displayerType(&displayers.Deployments{}),
+	)
+	AddStringSliceFlag(restartApp, doctl.ArgAppComponents, "", nil, "The components to restart. If not provided, all components are restarted.")
+	AddBoolFlag(restartApp, doctl.ArgCommandWait, "", false,
+		"Boolean that specifies whether to wait for the restart to complete before allowing further terminal input. This can be helpful for scripting.")
+	restartApp.Example = `The following example restarts an app with the ID ` + "`" + `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` + "`" + `. Additionally, the command returns the app's ID and status: doctl apps restart f81d4fae-7dec-11d0-a765-00a0c91e6bf6 --format ID,Status`
+
 	deploymentCreate := CmdBuilder(
 		cmd,
 		RunAppsCreateDeployment,
@@ -469,6 +484,48 @@ func RunAppsDelete(c *CmdConfig) error {
 	return nil
 }
 
+// RunAppsRestart restarts an app.
+func RunAppsRestart(c *CmdConfig) error {
+	if len(c.Args) < 1 {
+		return doctl.NewMissingArgsErr(c.NS)
+	}
+	appID := c.Args[0]
+	components, err := c.Doit.GetStringSlice(c.NS, doctl.ArgAppComponents)
+	if err != nil {
+		return err
+	}
+
+	wait, err := c.Doit.GetBool(c.NS, doctl.ArgCommandWait)
+	if err != nil {
+		return err
+	}
+
+	deployment, err := c.Apps().Restart(appID, components)
+	if err != nil {
+		return err
+	}
+
+	var errs error
+
+	if wait {
+		apps := c.Apps()
+		notice("Restart is in progress, waiting for the restart to complete")
+		err := waitForActiveDeployment(apps, appID, deployment.ID)
+		if err != nil {
+			errs = multierror.Append(errs, fmt.Errorf("app deployment couldn't enter `running` state: %v", err))
+			if err := c.Display(displayers.Deployments{deployment}); err != nil {
+				errs = multierror.Append(errs, err)
+			}
+			return errs
+		}
+		deployment, _ = c.Apps().GetDeployment(appID, deployment.ID)
+	}
+
+	notice("Restarted")
+
+	return c.Display(displayers.Deployments{deployment})
+}
+
 // RunAppsCreateDeployment creates a deployment for an app.
 func RunAppsCreateDeployment(c *CmdConfig) error {
 	if len(c.Args) < 1 {
diff --git a/do/apps.go b/do/apps.go
index 184d2145..d5bc0a4a 100644
--- a/do/apps.go
+++ b/do/apps.go
@@ -28,6 +28,7 @@ type AppsService interface {
 	Delete(appID string) error
 	Propose(req *godo.AppProposeRequest) (*godo.AppProposeResponse, error)
 
+	Restart(appID string, components []string) (*godo.Deployment, error)
 	CreateDeployment(appID string, forceRebuild bool) (*godo.Deployment, error)
 	GetDeployment(appID, deploymentID string) (*godo.Deployment, error)
 	ListDeployments(appID string) ([]*godo.Deployment, error)
@@ -132,6 +133,16 @@ func (s *appsService) Propose(req *godo.AppProposeRequest) (*godo.AppProposeResp
 	return res, nil
 }
 
+func (s *appsService) Restart(appID string, components []string) (*godo.Deployment, error) {
+	deployment, _, err := s.client.Apps.Restart(s.ctx, appID, &godo.AppRestartRequest{
+		Components: components,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return deployment, nil
+}
+
 func (s *appsService) CreateDeployment(appID string, forceRebuild bool) (*godo.Deployment, error) {
 	deployment, _, err := s.client.Apps.CreateDeployment(s.ctx, appID, &godo.DeploymentCreateRequest{
 		ForceBuild: forceRebuild,
diff --git a/do/mocks/AppsService.go b/do/mocks/AppsService.go
index e71d8ef1..f4fdd985 100644
--- a/do/mocks/AppsService.go
+++ b/do/mocks/AppsService.go
@@ -294,6 +294,21 @@ func (mr *MockAppsServiceMockRecorder) Propose(req any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Propose", reflect.TypeOf((*MockAppsService)(nil).Propose), req)
 }
 
+// Restart mocks base method.
+func (m *MockAppsService) Restart(appID string, components []string) (*godo.Deployment, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Restart", appID, components)
+	ret0, _ := ret[0].(*godo.Deployment)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Restart indicates an expected call of Restart.
+func (mr *MockAppsServiceMockRecorder) Restart(appID, components any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Restart", reflect.TypeOf((*MockAppsService)(nil).Restart), appID, components)
+}
+
 // Update mocks base method.
 func (m *MockAppsService) Update(appID string, req *godo.AppUpdateRequest) (*godo.App, error) {
 	m.ctrl.T.Helper()
diff --git a/go.mod b/go.mod
index 324d29da..cbc99b2f 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.130.0
+	github.com/digitalocean/godo v1.130.1-0.20241119155329-45ad288c38bd
 	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 ad87a391..987729c0 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.130.0 h1:DbJg0wvBxTkYjY5Q9S1mwzAZLd5Wht3r57yFH4yeMCk=
-github.com/digitalocean/godo v1.130.0/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc=
+github.com/digitalocean/godo v1.130.1-0.20241119155329-45ad288c38bd h1:3TCd+SNAbaRHQSiWmMJWtPitvZt2lTq3th87CxMl9Xo=
+github.com/digitalocean/godo v1.130.1-0.20241119155329-45ad288c38bd/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/apps.gen.go b/vendor/github.com/digitalocean/godo/apps.gen.go
index 0232ae94..6fc029cb 100644
--- a/vendor/github.com/digitalocean/godo/apps.gen.go
+++ b/vendor/github.com/digitalocean/godo/apps.gen.go
@@ -466,8 +466,7 @@ type AppLogDestinationSpecPapertrail struct {
 type AppMaintenanceSpec struct {
 	// Indicates whether maintenance mode should be enabled for the app.
 	Enabled bool `json:"enabled,omitempty"`
-	// Indicates whether the app should be archived. Setting this to true implies that enabled is set to true.
-	// Note that this feature is currently in closed beta.
+	// Indicates whether the app should be archived. Setting this to true implies that enabled is set to true. Note that this feature is currently in closed beta.
 	Archive bool `json:"archive,omitempty"`
 }
 
@@ -1004,6 +1003,7 @@ const (
 	DeploymentCauseDetailsDigitalOceanUserActionName_RollbackApp           DeploymentCauseDetailsDigitalOceanUserActionName = "ROLLBACK_APP"
 	DeploymentCauseDetailsDigitalOceanUserActionName_RevertAppRollback     DeploymentCauseDetailsDigitalOceanUserActionName = "REVERT_APP_ROLLBACK"
 	DeploymentCauseDetailsDigitalOceanUserActionName_UpgradeBuildpack      DeploymentCauseDetailsDigitalOceanUserActionName = "UPGRADE_BUILDPACK"
+	DeploymentCauseDetailsDigitalOceanUserActionName_Restart               DeploymentCauseDetailsDigitalOceanUserActionName = "RESTART"
 )
 
 // AppDomain struct for AppDomain
diff --git a/vendor/github.com/digitalocean/godo/apps.go b/vendor/github.com/digitalocean/godo/apps.go
index 24fe738e..97b0cbd7 100644
--- a/vendor/github.com/digitalocean/godo/apps.go
+++ b/vendor/github.com/digitalocean/godo/apps.go
@@ -35,6 +35,7 @@ type AppsService interface {
 	Delete(ctx context.Context, appID string) (*Response, error)
 	Propose(ctx context.Context, propose *AppProposeRequest) (*AppProposeResponse, *Response, error)
 
+	Restart(ctx context.Context, appID string, opts *AppRestartRequest) (*Deployment, *Response, error)
 	GetDeployment(ctx context.Context, appID, deploymentID string) (*Deployment, *Response, error)
 	ListDeployments(ctx context.Context, appID string, opts *ListOptions) ([]*Deployment, *Response, error)
 	CreateDeployment(ctx context.Context, appID string, create ...*DeploymentCreateRequest) (*Deployment, *Response, error)
@@ -95,6 +96,11 @@ type DeploymentCreateRequest struct {
 	ForceBuild bool `json:"force_build"`
 }
 
+// AppRestartRequest represents a request to restart an app.
+type AppRestartRequest struct {
+	Components []string `json:"components"`
+}
+
 // AlertDestinationUpdateRequest represents a request to update alert destinations.
 type AlertDestinationUpdateRequest struct {
 	Emails        []string                `json:"emails"`
@@ -285,6 +291,22 @@ func (s *AppsServiceOp) Propose(ctx context.Context, propose *AppProposeRequest)
 	return res, resp, nil
 }
 
+// Restart restarts an app.
+func (s *AppsServiceOp) Restart(ctx context.Context, appID string, opts *AppRestartRequest) (*Deployment, *Response, error) {
+	path := fmt.Sprintf("%s/%s/restart", appsBasePath, appID)
+
+	req, err := s.client.NewRequest(ctx, http.MethodPost, path, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+	root := new(deploymentRoot)
+	resp, err := s.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+	return root.Deployment, resp, nil
+}
+
 // GetDeployment gets an app deployment.
 func (s *AppsServiceOp) GetDeployment(ctx context.Context, appID, deploymentID string) (*Deployment, *Response, error) {
 	path := fmt.Sprintf("%s/%s/deployments/%s", appsBasePath, appID, deploymentID)
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 73832a0d..98a5ad39 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.130.0
+# github.com/digitalocean/godo v1.130.1-0.20241119155329-45ad288c38bd
 ## explicit; go 1.22
 github.com/digitalocean/godo
 github.com/digitalocean/godo/metrics


Test patch:
diff --git a/commands/apps_test.go b/commands/apps_test.go
index cea98533..c4e705a0 100644
--- a/commands/apps_test.go
+++ b/commands/apps_test.go
@@ -36,6 +36,7 @@ func TestAppsCommand(t *testing.T) {
 		"list-regions",
 		"logs",
 		"propose",
+		"restart",
 		"spec",
 		"tier",
 		"list-alerts",
@@ -344,6 +345,110 @@ func TestRunAppsCreateDeploymentWithWait(t *testing.T) {
 	})
 }
 
+func TestRunAppsRestart(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		appID := uuid.New().String()
+		deployment := &godo.Deployment{
+			ID:   uuid.New().String(),
+			Spec: &testAppSpec,
+			Services: []*godo.DeploymentService{{
+				Name:             "service",
+				SourceCommitHash: "commit",
+			}},
+			Cause: "Manual",
+			Phase: godo.DeploymentPhase_PendingDeploy,
+			Progress: &godo.DeploymentProgress{
+				PendingSteps: 1,
+				RunningSteps: 0,
+				SuccessSteps: 0,
+				ErrorSteps:   0,
+				TotalSteps:   1,
+
+				Steps: []*godo.DeploymentProgressStep{{
+					Name:      "name",
+					Status:    "pending",
+					StartedAt: time.Now(),
+				}},
+			},
+			CreatedAt: time.Now(),
+			UpdatedAt: time.Now(),
+		}
+
+		tm.apps.EXPECT().Restart(appID, []string{"component1", "component2"}).Times(1).Return(deployment, nil)
+
+		config.Args = append(config.Args, appID)
+		config.Doit.Set(config.NS, doctl.ArgAppComponents, []string{"component1", "component2"})
+
+		err := RunAppsRestart(config)
+		require.NoError(t, err)
+	})
+}
+
+func TestRunAppsRestartWithWait(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		appID := uuid.New().String()
+		deployment := &godo.Deployment{
+			ID:   uuid.New().String(),
+			Spec: &testAppSpec,
+			Services: []*godo.DeploymentService{{
+				Name:             "service",
+				SourceCommitHash: "commit",
+			}},
+			Cause: "Manual",
+			Phase: godo.DeploymentPhase_PendingDeploy,
+			Progress: &godo.DeploymentProgress{
+				PendingSteps: 1,
+				RunningSteps: 0,
+				SuccessSteps: 0,
+				ErrorSteps:   0,
+				TotalSteps:   1,
+
+				Steps: []*godo.DeploymentProgressStep{{
+					Name:      "name",
+					Status:    "pending",
+					StartedAt: time.Now(),
+				}},
+			},
+			CreatedAt: time.Now(),
+			UpdatedAt: time.Now(),
+		}
+		activeDeployment := &godo.Deployment{
+			ID:   uuid.New().String(),
+			Spec: &testAppSpec,
+			Services: []*godo.DeploymentService{{
+				Name:             "service",
+				SourceCommitHash: "commit",
+			}},
+			Cause: "Manual",
+			Phase: godo.DeploymentPhase_Active,
+			Progress: &godo.DeploymentProgress{
+				PendingSteps: 1,
+				RunningSteps: 0,
+				SuccessSteps: 1,
+				ErrorSteps:   0,
+				TotalSteps:   1,
+
+				Steps: []*godo.DeploymentProgressStep{{
+					Name:      "name",
+					Status:    "pending",
+					StartedAt: time.Now(),
+				}},
+			},
+			CreatedAt: time.Now(),
+			UpdatedAt: time.Now(),
+		}
+
+		tm.apps.EXPECT().Restart(appID, nil).Times(1).Return(deployment, nil)
+		tm.apps.EXPECT().GetDeployment(appID, deployment.ID).Times(2).Return(activeDeployment, nil)
+
+		config.Args = append(config.Args, appID)
+		config.Doit.Set(config.NS, doctl.ArgCommandWait, true)
+
+		err := RunAppsRestart(config)
+		require.NoError(t, err)
+	})
+}
+
 func TestRunAppsGetDeployment(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 		appID := uuid.New().String()