Repository: digitalocean/doctl
Pull request #1348 (2023-02-10 23:21:09)

Added promote replica support
Recently, databases added a feature in the API to be able to promote a read replica to be a primary cluster.  This terminates the "replicating connection" to the original primary cluster being replicated and takes the replica and turns it into a stand-alone cluster that can be used as a primary, as could be beneficial in the case where the primary cluster fails and the customer needs to failover to the read replica and to do so requires this ability to promote it.

This PR adds the doctl new `replica promote` command as well as the unit tests required.  At present, I did not see any integration tests around the use of replicas so I did not add an integration test for this new function based on that precedent. 

danaelhe: Thanks for this PR! Could you include an integration test as well? (in` /integrations`)
ChiefMateStarbuck: Added necessary integration test

Patch:
diff --git a/commands/databases.go b/commands/databases.go
index 131cf581..9c87303e 100644
--- a/commands/databases.go
+++ b/commands/databases.go
@@ -1297,6 +1297,11 @@ This command requires that you pass in the replica's name, which you can retriev
 	AddBoolFlag(cmdDatabaseReplicaDelete, doctl.ArgForce, doctl.ArgShortForce,
 		false, "Deletes the replica without a confirmation prompt")
 
+	CmdBuilder(cmd, RunDatabaseReplicaPromote,
+		"promote <database-id> <replica-name>", "Promote a read-only database replica to become a primary cluster",
+		`Delete the specified read-only replica for the specified database cluster.`+howToGetReplica+databaseListDetails,
+		Writer, aliasOpt("rm"))
+
 	CmdBuilder(cmd, RunDatabaseReplicaConnectionGet,
 		"connection <database-id> <replica-name>",
 		"Retrieve information for connecting to a read-only database replica",
@@ -1404,6 +1409,17 @@ func RunDatabaseReplicaDelete(c *CmdConfig) error {
 	return errOperationAborted
 }
 
+// RunDatabaseReplicaPromote promotes a read-only replica to become a primary cluster
+func RunDatabaseReplicaPromote(c *CmdConfig) error {
+	if len(c.Args) < 2 {
+		return doctl.NewMissingArgsErr(c.NS)
+	}
+
+	databaseID := c.Args[0]
+	replicaID := c.Args[1]
+	return c.Databases().PromoteReplica(databaseID, replicaID)
+}
+
 func displayDatabaseReplicas(c *CmdConfig, short bool, replicas ...do.DatabaseReplica) error {
 	item := &displayers.DatabaseReplicas{
 		DatabaseReplicas: replicas,
diff --git a/do/databases.go b/do/databases.go
index aa119d3f..c90828e6 100644
--- a/do/databases.go
+++ b/do/databases.go
@@ -130,6 +130,7 @@ type DatabasesService interface {
 	ListReplicas(string) (DatabaseReplicas, error)
 	CreateReplica(string, *godo.DatabaseCreateReplicaRequest) (*DatabaseReplica, error)
 	DeleteReplica(string, string) error
+	PromoteReplica(string, string) error
 	GetReplicaConnection(string, string) (*DatabaseConnection, error)
 
 	GetSQLMode(string) ([]string, error)
@@ -490,6 +491,12 @@ func (ds *databasesService) DeleteReplica(databaseID string, replicaID string) e
 	return err
 }
 
+func (ds *databasesService) PromoteReplica(databaseID string, replicaID string) error {
+	_, err := ds.client.Databases.PromoteReplicaToPrimary(context.TODO(), databaseID, replicaID)
+
+	return err
+}
+
 func (ds *databasesService) GetReplicaConnection(databaseID, replicaID string) (*DatabaseConnection, error) {
 	rep, err := ds.GetReplica(databaseID, replicaID)
 	if err != nil {
diff --git a/do/mocks/DatabasesService.go b/do/mocks/DatabasesService.go
index 64ecd603..847d05be 100644
--- a/do/mocks/DatabasesService.go
+++ b/do/mocks/DatabasesService.go
@@ -449,6 +449,20 @@ func (mr *MockDatabasesServiceMockRecorder) Migrate(arg0, arg1 interface{}) *gom
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Migrate", reflect.TypeOf((*MockDatabasesService)(nil).Migrate), arg0, arg1)
 }
 
+// PromoteReplica mocks base method.
+func (m *MockDatabasesService) PromoteReplica(arg0, arg1 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PromoteReplica", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// PromoteReplica indicates an expected call of PromoteReplica.
+func (mr *MockDatabasesServiceMockRecorder) PromoteReplica(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromoteReplica", reflect.TypeOf((*MockDatabasesService)(nil).PromoteReplica), arg0, arg1)
+}
+
 // ResetUserAuth mocks base method.
 func (m *MockDatabasesService) ResetUserAuth(arg0, arg1 string, arg2 *godo.DatabaseResetUserAuthRequest) (*do.DatabaseUser, error) {
 	m.ctrl.T.Helper()


Test patch:
diff --git a/commands/databases_test.go b/commands/databases_test.go
index 423e5c9e..9a8c6523 100644
--- a/commands/databases_test.go
+++ b/commands/databases_test.go
@@ -255,6 +255,7 @@ func TestDatabaseReplicaCommand(t *testing.T) {
 		"create",
 		"delete",
 		"connection",
+		"promote",
 	)
 }
 
@@ -1109,6 +1110,28 @@ func TestDatabasesReplicaDelete(t *testing.T) {
 	})
 }
 
+func TestDatabasesReplicaPromote(t *testing.T) {
+	// Successful
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.databases.EXPECT().PromoteReplica(testDBCluster.ID, testDBReplica.Name).Return(nil)
+
+		config.Args = append(config.Args, testDBCluster.ID, testDBReplica.Name)
+
+		err := RunDatabaseReplicaPromote(config)
+		assert.NoError(t, err)
+	})
+
+	// Error
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.databases.EXPECT().PromoteReplica(testDBCluster.ID, testDBReplica.Name).Return(errTest)
+
+		config.Args = append(config.Args, testDBCluster.ID, testDBReplica.Name)
+
+		err := RunDatabaseReplicaPromote(config)
+		assert.EqualError(t, err, errTest.Error())
+	})
+}
+
 func TestDatabaseGetSQLModes(t *testing.T) {
 	t.Run("Success", func(t *testing.T) {
 		withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
diff --git a/integration/database_replica_promote_test.go b/integration/database_replica_promote_test.go
new file mode 100644
index 00000000..601b182c
--- /dev/null
+++ b/integration/database_replica_promote_test.go
@@ -0,0 +1,72 @@
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("database/replica", 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) {
+			switch req.URL.Path {
+			case "v2/databases/1111/replicas/2222/promote":
+				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(``))
+				w.WriteHeader(http.StatusNoContent)
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("command is promote", func() {
+		it("promote a replica database to primary", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"databases",
+				"promote",
+				"1111",
+				"2222",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+		})
+
+	})
+})
+
+const (
+	databasesReplicaPromoteOutput = `
+	`
+)