Repository: digitalocean/doctl
Pull request #1346 (2023-02-08 20:12:05)

APICLI-1692: Add backup-restore functionality to db create
Adds the following command:
`doctl db create new_db_name --restore-from-cluster=original-cluster-name —engine="pg1" —num_nodes=100 —seize=biggest —region="nyc3"`

Note: This does not mimic the control panel behavior. This is parity to the API. To mimic the control panel behavior, we'd do something more like this:
`doctl databases fork new_db_name --restore-from-cluster=original-cluster-name`

which will be in another PR.

danaelhe: > This looks great. I'm happy to approve as is but I left a couple of non-blocking comments for discussion first.

Great idea Cesar and good catch- I've updated the implementation to accept UTC format from the user and convert it into ISO861 in the background before passing it to the godo function. Thanks for the thoughtful feedback!

Patch:
diff --git a/.do/.gitignore b/.do/.gitignore
new file mode 100644
index 00000000..ab04d81a
--- /dev/null
+++ b/.do/.gitignore
@@ -0,0 +1,1 @@
+dev-config.yaml
\ No newline at end of file
diff --git a/args.go b/args.go
index 5d345e3b..62670853 100644
--- a/args.go
+++ b/args.go
@@ -328,6 +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"
+	// 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
 	ArgDatabaseEngine = "engine"
 	// ArgDatabaseNumNodes is the number of nodes in the database cluster
diff --git a/commands/databases.go b/commands/databases.go
index 7bda6710..c907a76c 100644
--- a/commands/databases.go
+++ b/commands/databases.go
@@ -78,6 +78,8 @@ 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.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")
 
 	cmdDatabaseDelete := CmdBuilder(cmd, RunDatabaseDelete, "delete <database-id>", "Delete a database cluster", `This command deletes the database cluster with the given ID.
@@ -243,9 +245,44 @@ func buildDatabaseCreateRequestFromArgs(c *CmdConfig) (*godo.DatabaseCreateReque
 	}
 	r.PrivateNetworkUUID = privateNetworkUUID
 
+	restoreFromCluster, err := c.Doit.GetString(c.NS, doctl.ArgDatabaseRestoreFromCluster)
+	if err != nil {
+		return nil, err
+	}
+	if restoreFromCluster != "" {
+		backUpRestore := &godo.DatabaseBackupRestore{}
+		backUpRestore.DatabaseName = restoreFromCluster
+		// only set the restore-from-timestamp if restore-from-cluster is set.
+		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.PrivateNetworkUUID = privateNetworkUUID
+
 	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)
+	if error != nil {
+		return "", fmt.Errorf("Invalid format for --restore-from-timestamp. Must be in UTC format: 2006-01-02 15:04:05 +0000 UTC")
+	}
+	dateFormatted := date.Format(time.RFC3339)
+
+	return dateFormatted, nil
+}
+
 // RunDatabaseDelete deletes a database cluster
 func RunDatabaseDelete(c *CmdConfig) error {
 	if len(c.Args) == 0 {
diff --git a/integration/database_create_restore_from_cluster.go b/integration/database_create_restore_from_cluster.go
new file mode 100644
index 00000000..42447770
--- /dev/null
+++ b/integration/database_create_restore_from_cluster.go
@@ -0,0 +1,171 @@
+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/backup-restore", 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())
+			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",
+					"create",
+					"new-db-name",
+					"--restore-from-cluster", "old-db-name",
+					"--restore-from-timestamp", "2023-02-01 17:32:15 +0000 UTC",
+					"--engine", "mysql",
+					"--num-nodes", "100",
+					"--region", "nyc3",
+					"--size", "biggest",
+					"--version", "what-version",
+				)
+
+				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",
+				"create",
+				"new-db-name",
+				"--restore-from-cluster", "old-db-name",
+				"--restore-from-timestamp", "2009-11-10T23:00:00Z",
+				"--engine", "mysql",
+				"--num-nodes", "100",
+				"--region", "nyc3",
+				"--size", "biggest",
+				"--version", "what-version",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.Error(err)
+			expect.Equal(strings.TrimSpace(restoreFromTimestampError), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	restoreFromTimestampError          = "Error: Invalid format for --restore-from-timestamp. Must be in UTC format: 2006-01-02 15:04:05 +0000 UTC"
+	databasesCreateRestoreBackUpOutput = `
+Notice: Database created
+ID         Name           Engine    Version         Number of Nodes    Region    Status      Size       URI                                                                                     Created At
+some-id    new-db-name    mysql     what-version    100                nyc3      creating    biggest    mysql://doadmin:secret@aaa-bbb-ccc-111-222-333.db.ondigitalocean.com:25060/defaultdb    2019-01-11 18:37:36 +0000 UTC
+`
+	databaseRestoreBackUpCreateRequestBody = `{
+	"name":"new-db-name",
+	"engine": "mysql",
+	"num_nodes": "100",
+	"region": "nyc3",
+	"size": "biggest",
+	"backup_restore":{
+	  "database_name":"old-db-name"
+	}
+  }`
+
+	databaseRestoreBackUpCreateResponse = `
+  {
+	"database": {
+	  "id": "some-id",
+	  "name": "new-db-name",
+	  "engine": "mysql",
+	  "version": "what-version",
+	  "connection": {
+		"uri": "mysql://doadmin:secret@aaa-bbb-ccc-111-222-333.db.ondigitalocean.com:25060/defaultdb"
+	  },
+	  "private_connection": {},
+	  "users": null,
+	  "db_names": null,
+	  "num_nodes": 100,
+	  "region": "nyc3",
+	  "status": "creating",
+	  "created_at": "2019-01-11T18:37:36Z",
+	  "maintenance_window": null,
+	  "size": "biggest",
+	  "tags": [
+		"production"
+	  ]
+	}
+  }`
+)


Test patch:
diff --git a/commands/databases_test.go b/commands/databases_test.go
index 9f08c434..01a5eb60 100644
--- a/commands/databases_test.go
+++ b/commands/databases_test.go
@@ -36,6 +36,11 @@ var (
 		Hour: "10:00",
 	}
 
+	testBackUpRestore = &godo.DatabaseBackupRestore{
+		DatabaseName:    "sunny-db-cluster",
+		BackupCreatedAt: "2023-02-01T17:32:15Z",
+	}
+
 	// doctl mocks
 
 	testDBCluster = do.Database{
@@ -59,6 +64,27 @@ var (
 		},
 	}
 
+	testDBBackUpCluster = do.Database{
+		Database: &godo.Database{
+			ID:                "ea4652de-4fe0-11e9-b7ab-df1ef30eab9e",
+			Name:              "db-replica",
+			RegionSlug:        "nyc1",
+			EngineSlug:        "pg",
+			VersionSlug:       "11",
+			NumNodes:          3,
+			SizeSlug:          "db-s-1vcpu-2gb",
+			DBNames:           []string{"defaultdb"},
+			CreatedAt:         time.Now(),
+			Status:            "online",
+			Connection:        testGODOConnection,
+			MaintenanceWindow: testGODOMainWindow,
+			Users: []godo.DatabaseUser{
+				*testGODOUser,
+			},
+			PrivateNetworkUUID: "1fe49b6c-ac8e-11e9-98cb-3bec94f411bc",
+		},
+	}
+
 	testDBClusters = do.Databases{
 		testDBCluster,
 	}
@@ -322,6 +348,48 @@ func TestDatabasesCreate(t *testing.T) {
 	})
 }
 
+func TestDatabasesCreateRestoreFromBackUp(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,
+	}
+
+	// Successful call
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.databases.EXPECT().Create(r).Return(&testDBCluster, nil)
+
+		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.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)
+		config.Doit.Set(config.NS, doctl.ArgDatabaseEngine, testDBCluster.EngineSlug)
+		config.Doit.Set(config.NS, doctl.ArgDatabaseNumNodes, testDBCluster.NumNodes)
+		config.Doit.Set(config.NS, doctl.ArgPrivateNetworkUUID, testDBCluster.PrivateNetworkUUID)
+
+		err := RunDatabaseCreate(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) {
@@ -1072,3 +1140,12 @@ func TestDatabaseListOptions(t *testing.T) {
 		assert.EqualError(t, err, errTest.Error())
 	})
 }
+
+func TestConvertUTCtoISO8601(t *testing.T) {
+	utcTime := "2023-02-01 17:32:15 +0000 UTC"
+	isoTime, err := convertUTCtoISO8601(utcTime)
+
+	assert.NoError(t, err)
+
+	assert.Equal(t, "2023-02-01T17:32:15Z", isoTime)
+}