Repository: digitalocean/doctl
Pull request #1352 (2023-02-28 01:17:52)

databases: add "fork" feature
Add the following to doctl:

`doctl databases fork new_db_name --restore-from-cluster-id=original-cluster-id`

This is similar behavior to the control panel experience. We retrieve the db details and pass them in to the database create request for the user. 

Addresses #992 

APICLI-1856


Patch:
diff --git a/args.go b/args.go
index 62670853..ae381f4c 100644
--- a/args.go
+++ b/args.go
@@ -328,8 +328,10 @@ const (
 	// ArgProjectResource is a flag for your resource URNs
 	ArgProjectResource = "resource"
 
-	// ArgDatabaseRestoreFromCluster is a flag for specifying the name of an existing database cluster from which the backup will be restored.
-	ArgDatabaseRestoreFromCluster = "restore-from-cluster"
+	// ArgDatabaseRestoreFromClusterName is a flag for specifying the name of an existing database cluster from which the backup will be restored.
+	ArgDatabaseRestoreFromClusterName = "restore-from-cluster-name"
+	// ArgDatabaseRestoreFromClusterID is a flag for specifying the id of an existing database cluster from which the new database will be forked from.
+	ArgDatabaseRestoreFromClusterID = "restore-from-cluster-id"
 	// ArgDatabaseRestoreFromTimestamp is a flag for specifying the timestamp of an existing database cluster backup in ISO8601 combined date and time format. The most recent backup will be used if excluded.
 	ArgDatabaseRestoreFromTimestamp = "restore-from-timestamp"
 	// ArgDatabaseEngine is a flag for specifying which database engine to use
diff --git a/commands/databases.go b/commands/databases.go
index c907a76c..131cf581 100644
--- a/commands/databases.go
+++ b/commands/databases.go
@@ -78,7 +78,7 @@ There are a number of flags that customize the configuration, all of which are o
 	AddStringFlag(cmdDatabaseCreate, doctl.ArgDatabaseEngine, "", defaultDatabaseEngine, "The database engine to be used for the cluster. Possible values are: `pg` for PostgreSQL, `mysql`, `redis`, and `mongodb`.")
 	AddStringFlag(cmdDatabaseCreate, doctl.ArgVersion, "", "", "The database engine version, e.g. 14 for PostgreSQL version 14")
 	AddStringFlag(cmdDatabaseCreate, doctl.ArgPrivateNetworkUUID, "", "", "The UUID of a VPC to create the database cluster in; the default VPC for the region will be used if excluded")
-	AddStringFlag(cmdDatabaseCreate, doctl.ArgDatabaseRestoreFromCluster, "", "", "The name of an existing database cluster from which the backup will be restored.")
+	AddStringFlag(cmdDatabaseCreate, doctl.ArgDatabaseRestoreFromClusterName, "", "", "The name of an existing database cluster from which the backup will be restored.")
 	AddStringFlag(cmdDatabaseCreate, doctl.ArgDatabaseRestoreFromTimestamp, "", "", "The timestamp of an existing database cluster backup in UTC combined date and time format (2006-01-02 15:04:05 +0000 UTC). The most recent backup will be used if excluded.")
 	AddBoolFlag(cmdDatabaseCreate, doctl.ArgCommandWait, "", false, "Boolean that specifies whether to wait for a database to complete before returning control to the terminal")
 
@@ -122,6 +122,13 @@ Database nodes cannot be resized to smaller sizes due to the risk of data loss.`
 	AddStringFlag(cmdDatabaseMigrate, doctl.ArgRegionSlug, "", "", "The region to which the database cluster should be migrated, e.g. `sfo2` or `nyc3`.", requiredOpt())
 	AddStringFlag(cmdDatabaseMigrate, doctl.ArgPrivateNetworkUUID, "", "", "The UUID of a VPC to create the database cluster in; the default VPC for the region will be used if excluded")
 
+	cmdDatabaseFork := CmdBuilder(cmd, RunDatabaseFork, "fork <name>", "Create a new database cluster by forking an existing database cluster.", `This command forks a database cluster from an existing cluster. example:
+	
+	doctl databases fork new_db_name --restore-from-cluster-id=original-cluster-id`, Writer, aliasOpt("f"))
+	AddStringFlag(cmdDatabaseFork, doctl.ArgDatabaseRestoreFromClusterID, "", "", "The ID of an existing database cluster from which the new database will be forked from", requiredOpt())
+	AddStringFlag(cmdDatabaseFork, doctl.ArgDatabaseRestoreFromTimestamp, "", "", "The timestamp of an existing database cluster backup in UTC combined date and time format (2006-01-02 15:04:05 +0000 UTC). The most recent backup will be used if excluded.")
+	AddBoolFlag(cmdDatabaseFork, doctl.ArgCommandWait, "", false, "Boolean that specifies whether to wait for a database to complete before returning control to the terminal.")
+
 	cmd.AddCommand(databaseReplica())
 	cmd.AddCommand(databaseMaintenanceWindow())
 	cmd.AddCommand(databaseUser())
@@ -245,7 +252,7 @@ func buildDatabaseCreateRequestFromArgs(c *CmdConfig) (*godo.DatabaseCreateReque
 	}
 	r.PrivateNetworkUUID = privateNetworkUUID
 
-	restoreFromCluster, err := c.Doit.GetString(c.NS, doctl.ArgDatabaseRestoreFromCluster)
+	restoreFromCluster, err := c.Doit.GetString(c.NS, doctl.ArgDatabaseRestoreFromClusterName)
 	if err != nil {
 		return nil, err
 	}
@@ -272,6 +279,91 @@ func buildDatabaseCreateRequestFromArgs(c *CmdConfig) (*godo.DatabaseCreateReque
 	return r, nil
 }
 
+// RunDatabaseFork creates a database cluster by forking an existing cluster.
+func RunDatabaseFork(c *CmdConfig) error {
+	if len(c.Args) == 0 {
+		return doctl.NewMissingArgsErr(c.NS)
+	}
+
+	r, err := buildDatabaseForkRequest(c)
+	if err != nil {
+		return err
+	}
+
+	dbs := c.Databases()
+
+	db, err := dbs.Create(r)
+	if err != nil {
+		return err
+	}
+
+	wait, err := c.Doit.GetBool(c.NS, doctl.ArgCommandWait)
+	if err != nil {
+		return err
+	}
+
+	if wait {
+		connection := db.Connection
+		dbs := c.Databases()
+		notice("Database forking is in progress, waiting for database to be online")
+
+		err := waitForDatabaseReady(dbs, db.ID)
+		if err != nil {
+			return fmt.Errorf(
+				"database couldn't enter the `online` state: %v",
+				err,
+			)
+		}
+
+		db, _ = dbs.Get(db.ID)
+		db.Connection = connection
+	}
+
+	notice("Database created")
+
+	return displayDatabases(c, false, *db)
+}
+
+func buildDatabaseForkRequest(c *CmdConfig) (*godo.DatabaseCreateRequest, error) {
+	r := &godo.DatabaseCreateRequest{Name: c.Args[0]}
+
+	existingDatabaseID, err := c.Doit.GetString(c.NS, doctl.ArgDatabaseRestoreFromClusterID)
+	if err != nil {
+		return nil, err
+	}
+
+	existingDatabase, err := c.Databases().Get(existingDatabaseID)
+	if err != nil {
+		return nil, err
+	}
+
+	backUpRestore := &godo.DatabaseBackupRestore{}
+	backUpRestore.DatabaseName = existingDatabase.Name
+	restoreFromTimestamp, err := c.Doit.GetString(c.NS, doctl.ArgDatabaseRestoreFromTimestamp)
+	if err != nil {
+		return nil, err
+	}
+	if restoreFromTimestamp != "" {
+		dateFormatted, err := convertUTCtoISO8601(restoreFromTimestamp)
+		if err != nil {
+			return nil, err
+		}
+		backUpRestore.BackupCreatedAt = dateFormatted
+	}
+
+	r.BackupRestore = backUpRestore
+	r.EngineSlug = existingDatabase.EngineSlug
+	r.NumNodes = existingDatabase.NumNodes
+	r.SizeSlug = existingDatabase.SizeSlug
+	r.Region = existingDatabase.RegionSlug
+	r.Version = existingDatabase.VersionSlug
+	r.PrivateNetworkUUID = existingDatabase.PrivateNetworkUUID
+	r.Tags = existingDatabase.Tags
+	r.ProjectID = existingDatabase.ProjectID
+
+	return r, nil
+}
+
 func convertUTCtoISO8601(restoreFromTimestamp string) (string, error) {
 	// accepts UTC time format from user (to match db list output) and converts it to ISO8601 for api parity.
 	date, error := time.Parse("2006-01-02 15:04:05 +0000 UTC", restoreFromTimestamp)
diff --git a/integration/database_create_restore_from_cluster.go b/integration/database_create_restore_from_cluster.go
index 42447770..18b9543b 100644
--- a/integration/database_create_restore_from_cluster.go
+++ b/integration/database_create_restore_from_cluster.go
@@ -86,7 +86,7 @@ var _ = suite("database/create/backup-restore", func(t *testing.T, when spec.G,
 					"databases",
 					"create",
 					"new-db-name",
-					"--restore-from-cluster", "old-db-name",
+					"--restore-from-cluster-name", "old-db-name",
 					"--restore-from-timestamp", "2023-02-01 17:32:15 +0000 UTC",
 					"--engine", "mysql",
 					"--num-nodes", "100",
@@ -110,7 +110,7 @@ var _ = suite("database/create/backup-restore", func(t *testing.T, when spec.G,
 				"databases",
 				"create",
 				"new-db-name",
-				"--restore-from-cluster", "old-db-name",
+				"--restore-from-cluster-name", "old-db-name",
 				"--restore-from-timestamp", "2009-11-10T23:00:00Z",
 				"--engine", "mysql",
 				"--num-nodes", "100",


Test patch:
diff --git a/commands/databases_test.go b/commands/databases_test.go
index 01a5eb60..423e5c9e 100644
--- a/commands/databases_test.go
+++ b/commands/databases_test.go
@@ -191,6 +191,7 @@ func TestDatabasesCommand(t *testing.T) {
 		"migrate",
 		"resize",
 		"firewalls",
+		"fork",
 		"backups",
 		"replica",
 		"options",
@@ -366,7 +367,7 @@ func TestDatabasesCreateRestoreFromBackUp(t *testing.T) {
 
 		config.Args = append(config.Args, testDBCluster.Name)
 		config.Doit.Set(config.NS, doctl.ArgRegionSlug, testDBCluster.RegionSlug)
-		config.Doit.Set(config.NS, doctl.ArgDatabaseRestoreFromCluster, testBackUpRestore.DatabaseName)
+		config.Doit.Set(config.NS, doctl.ArgDatabaseRestoreFromClusterName, testBackUpRestore.DatabaseName)
 		config.Doit.Set(config.NS, doctl.ArgDatabaseRestoreFromTimestamp, "2023-02-01 17:32:15 +0000 UTC")
 		config.Doit.Set(config.NS, doctl.ArgSizeSlug, testDBCluster.SizeSlug)
 		config.Doit.Set(config.NS, doctl.ArgVersion, testDBCluster.VersionSlug)
@@ -390,6 +391,44 @@ func TestDatabasesCreateRestoreFromBackUp(t *testing.T) {
 	})
 }
 
+func TestDatabasesForkDatabase(t *testing.T) {
+	r := &godo.DatabaseCreateRequest{
+		Name:               testDBCluster.Name,
+		BackupRestore:      testBackUpRestore,
+		Region:             testDBCluster.RegionSlug,
+		Version:            testDBCluster.VersionSlug,
+		EngineSlug:         testDBCluster.EngineSlug,
+		NumNodes:           testDBCluster.NumNodes,
+		SizeSlug:           testDBCluster.SizeSlug,
+		PrivateNetworkUUID: testDBCluster.PrivateNetworkUUID,
+		Tags:               testDBCluster.Tags,
+	}
+
+	// Successful call
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.databases.EXPECT().Get(testDBCluster.ID).Return(&testDBCluster, nil)
+		tm.databases.EXPECT().Create(r).Return(&testDBCluster, nil)
+
+		config.Args = append(config.Args, testDBCluster.Name)
+		config.Doit.Set(config.NS, doctl.ArgDatabaseRestoreFromClusterID, testDBCluster.ID)
+		config.Doit.Set(config.NS, doctl.ArgDatabaseRestoreFromTimestamp, "2023-02-01 17:32:15 +0000 UTC")
+
+		err := RunDatabaseFork(config)
+		assert.NoError(t, err)
+	})
+
+	// Error
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.databases.EXPECT().Create(
+			gomock.AssignableToTypeOf(&godo.DatabaseCreateRequest{}),
+		).Return(nil, errTest)
+
+		config.Args = append(config.Args, testDBCluster.Name)
+		err := RunDatabaseCreate(config)
+		assert.EqualError(t, err, "error")
+	})
+}
+
 func TestDatabasesDelete(t *testing.T) {
 	// Successful
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
diff --git a/integration/database_create_fork_test.go b/integration/database_create_fork_test.go
new file mode 100644
index 00000000..15aa152d
--- /dev/null
+++ b/integration/database_create_fork_test.go
@@ -0,0 +1,130 @@
+package integration
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("database/create/fork", 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":
+				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
+				}
+
+				reqBody, err := ioutil.ReadAll(req.Body)
+				expect.NoError(err)
+
+				request := struct {
+					Name          string      `json:"name"`
+					Engine        string      `json:"engine"`
+					Version       string      `json:"version"`
+					Region        string      `json:"region"`
+					Nodes         int         `json:"num_nodes"`
+					BackupRestore interface{} `json:"backup_restore"`
+				}{}
+
+				err = json.Unmarshal(reqBody, &request)
+				expect.NoError(err)
+
+				t, err := template.New("response").Parse(databaseRestoreBackUpCreateResponse)
+				expect.NoError(err)
+
+				var b []byte
+				buffer := bytes.NewBuffer(b)
+				err = t.Execute(buffer, request)
+				expect.NoError(err)
+
+				w.Write(buffer.Bytes())
+			case "/v2/databases/some-id":
+				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(databaseRestoreBackUpCreateResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("the minimum required flags are provided", func() {
+		it("creates a backup database", func() {
+			aliases := []string{"create", "c"}
+
+			for _, alias := range aliases {
+				cmd := exec.Command(builtBinaryPath,
+					"-t", "some-magic-token",
+					"-u", server.URL,
+					"databases",
+					"fork",
+					"new-db-name",
+					"--restore-from-cluster-id", "some-id",
+					"--restore-from-timestamp", "2023-02-01 17:32:15 +0000 UTC",
+				)
+
+				output, err := cmd.CombinedOutput()
+				expect.NoError(err, fmt.Sprintf("received error output from command %s: %s", alias, output))
+				expect.Equal(strings.TrimSpace(databasesCreateRestoreBackUpOutput), strings.TrimSpace(string(output)))
+			}
+		})
+	})
+
+	when("the wrong time format is passed", func() {
+		it("errors out with wrong time format", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"databases",
+				"fork",
+				"new-db-name",
+				"--restore-from-cluster-id", "some-id",
+				"--restore-from-timestamp", "2009-11-10T23:00:00Z",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.Error(err)
+			expect.Equal(strings.TrimSpace(restoreFromTimestampError), strings.TrimSpace(string(output)))
+		})
+	})
+})