diff --git a/cmd/ddev/cmd/snapshot.go b/cmd/ddev/cmd/snapshot.go index 0cc4d59bc6f..233896c6f74 100644 --- a/cmd/ddev/cmd/snapshot.go +++ b/cmd/ddev/cmd/snapshot.go @@ -1,13 +1,21 @@ package cmd import ( + "fmt" + "github.com/drud/ddev/pkg/ddevapp" "github.com/drud/ddev/pkg/util" "github.com/spf13/cobra" + "strings" ) var snapshotAll bool +var snapshotCleanup bool +var snapshotList bool var snapshotName string +// noConfirm: If true, --yes, we won't stop and prompt before each deletion +var snapshotCleanupNoConfirm bool + // DdevSnapshotCommand provides the snapshot command var DdevSnapshotCommand = &cobra.Command{ Use: "snapshot [projectname projectname...]", @@ -15,6 +23,9 @@ var DdevSnapshotCommand = &cobra.Command{ Long: `Uses mariabackup or xtrabackup command to create a database snapshot in the .ddev/db_snapshots folder. These are compatible with server backups using the same tools and can be restored with "ddev restore-snapshot".`, Example: `ddev snapshot ddev snapshot --name some_descriptive_name +ddev snapshot --cleanup +ddev snapshot --cleanup -y +ddev snapshot --list ddev snapshot --all`, Run: func(cmd *cobra.Command, args []string) { apps, err := getRequestedProjects(args, snapshotAll) @@ -26,17 +37,72 @@ ddev snapshot --all`, } for _, app := range apps { - if snapshotNameOutput, err := app.Snapshot(snapshotName); err != nil { - util.Failed("Failed to snapshot %s: %v", app.GetName(), err) - } else { - util.Success("Created snapshot %s", snapshotNameOutput) + + switch { + case snapshotList: + listAppSnapshots(app) + case snapshotCleanup: + deleteAppSnapshot(app) + default: + createAppSnapshot(app) } } }, } +func listAppSnapshots(app *ddevapp.DdevApp) { + if snapshotNames, err := app.ListSnapshots(); err != nil { + util.Failed("Failed to list snapshots %s: %v", app.GetName(), err) + } else { + util.Success("Snapshots of project %s: %s", app.GetName(), strings.Join(snapshotNames, ", ")) + } +} + +func createAppSnapshot(app *ddevapp.DdevApp) { + if snapshotNameOutput, err := app.Snapshot(snapshotName); err != nil { + util.Failed("Failed to snapshot %s: %v", app.GetName(), err) + } else { + util.Success("Created snapshot %s", snapshotNameOutput) + } +} + +func deleteAppSnapshot(app *ddevapp.DdevApp) { + var snapshotsToDelete []string + var prompt string + var err error + + if !snapshotCleanupNoConfirm { + if snapshotName == "" { + prompt = fmt.Sprintf("OK to delete all snapshots of %s.", app.GetName()) + } else { + prompt = fmt.Sprintf("OK to delete the snapshot %s of %s.", snapshotName, app.GetName()) + } + if !util.Confirm(prompt) { + return + } + } + + if snapshotName == "" { + snapshotsToDelete, err = app.ListSnapshots() + if err != nil { + util.Failed("Failed to detect snapshots %s: %v", app.GetName(), err) + } + } else { + snapshotsToDelete = append(snapshotsToDelete, snapshotName) + } + + for _, snapshotToDelete := range snapshotsToDelete { + if err := app.DeleteSnapshot(snapshotToDelete); err != nil { + util.Failed("Failed to delete snapshot %s: %v", app.GetName(), err) + } + } +} + func init() { DdevSnapshotCommand.Flags().BoolVarP(&snapshotAll, "all", "a", false, "Snapshot all running projects") + DdevSnapshotCommand.Flags().BoolVarP(&snapshotList, "list", "l", false, "List snapshots") + DdevSnapshotCommand.Flags().BoolVarP(&snapshotCleanup, "cleanup", "C", false, "Cleanup snapshots") + DdevSnapshotCommand.Flags().BoolVarP(&snapshotCleanupNoConfirm, "yes", "y", false, "Yes - skip confirmation prompt") DdevSnapshotCommand.Flags().StringVarP(&snapshotName, "name", "n", "", "provide a name for the snapshot") RootCmd.AddCommand(DdevSnapshotCommand) } diff --git a/cmd/ddev/cmd/snapshot_test.go b/cmd/ddev/cmd/snapshot_test.go new file mode 100644 index 00000000000..30446b0136d --- /dev/null +++ b/cmd/ddev/cmd/snapshot_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "github.com/drud/ddev/pkg/ddevapp" + "github.com/drud/ddev/pkg/exec" + asrt "github.com/stretchr/testify/assert" + "os" + "testing" +) + +// TestCmdSnapshot runs `ddev snapshot` on the test apps +func TestCmdSnapshot(t *testing.T) { + assert := asrt.New(t) + + testDir, _ := os.Getwd() + fmt.Println(testDir) + site := TestSites[0] + cleanup := site.Chdir() + app, err := ddevapp.NewApp(site.Dir, false, "") + assert.NoError(err) + defer func() { + // Make sure all databases are back to default empty + _ = app.Stop(true, false) + _ = app.Start() + cleanup() + }() + + // Ensure that a snapshot can be created + args := []string{"snapshot", "--name", "test-snapshot"} + out, err := exec.RunCommand(DdevBin, args) + assert.NoError(err) + assert.Contains(string(out), "Created snapshot test-snapshot") + + // Try to delete a not existing snapshot + args = []string{"snapshot", "--name", "not-existing-snapshot", "--cleanup", "--yes"} + out, err = exec.RunCommand(DdevBin, args) + assert.Error(err) + assert.Contains(string(out), "Failed to delete snapshot") + + // Ensure that an existing snapshot can be deleted + args = []string{"snapshot", "--name", "test-snapshot", "--cleanup"} + out, err = exec.RunCommand(DdevBin, args) + assert.NoError(err) + assert.Contains(string(out), "Deleted database snapshot test-snapshot") +} diff --git a/docs/users/cli-usage.md b/docs/users/cli-usage.md index bddfb110393..764ed6fb487 100644 --- a/docs/users/cli-usage.md +++ b/docs/users/cli-usage.md @@ -670,7 +670,12 @@ Restored database snapshot: /Users/rfay/workspace/d8git/.ddev/db_snapshots/d8git Snapshots are stored in the project's .ddev/db_snapshots directory, and the directory can be renamed as necessary. For example, if you rename the above d8git_20180801132403 directory to "working_before_migration", then you can use `ddev restore-snapshot working_before_migration`. -To delete a snapshot, delete its folder from the .ddev/db_snapshots directory. Snapshots are not removed from the filesystem by `ddev delete`. It is safe to remove all the snapshots with `rm -r .ddev/db_snapshots` if you no longer need the snapshots. +To delete a snapshot, delete its folder from the .ddev/db_snapshots directory. Snapshots are not removed from the filesystem by `ddev delete`. It is safe to remove all the snapshots with `rm -r .ddev/db_snapshots` if you no longer need the snapshots. + +All snapshots of a project can be removed with `ddev snapshot --cleanup`. A single snapshot can be removed by `ddev snapshot --cleanup --name `. + +To see all existing snapshots of a project use `ddev snapshot --list`. +All existing snapshots of your system can be listed by adding the `--all` option to the command (`ddev snapshot --list --all`). There are some interesting consequences of restoring huge multi-gigabyte databases. Ddev may show the project as ready and started when in reality tables are still being loaded. You can see this behavior with `ddev logs -s db -f`. diff --git a/pkg/ddevapp/ddevapp.go b/pkg/ddevapp/ddevapp.go index d53b1a2870b..c6de0630a15 100644 --- a/pkg/ddevapp/ddevapp.go +++ b/pkg/ddevapp/ddevapp.go @@ -1528,6 +1528,60 @@ func (app *DdevApp) Snapshot(snapshotName string) (string, error) { return snapshotName, nil } +// DeleteSnapshot removes the snapshot directory inside a project +func (app *DdevApp) DeleteSnapshot(snapshotName string) error { + var err error + err = app.ProcessHooks("pre-delete-snapshot") + if err != nil { + return fmt.Errorf("Failed to process pre-delete-snapshot hooks: %v", err) + } + + snapshotDir := path.Join("db_snapshots", snapshotName) + hostSnapshotDir := filepath.Join(filepath.Dir(app.ConfigPath), snapshotDir) + + if err = fileutil.PurgeDirectory(hostSnapshotDir); err != nil { + return fmt.Errorf("Failed to purge contents of snapshot directory: %v", err) + } + + if err = os.Remove(hostSnapshotDir); err != nil { + return fmt.Errorf("Failed to delete snapshot directory: %v", err) + } + + util.Success("Deleted database snapshot %s in %s", snapshotName, hostSnapshotDir) + err = app.ProcessHooks("post-delete-snapshot") + if err != nil { + return fmt.Errorf("Failed to process post-delete-snapshot hooks: %v", err) + } + + return nil + +} + +// ListSnapshots returns a list of the names of all project snapshots +func (app *DdevApp) ListSnapshots() ([]string, error) { + var err error + var snapshots []string + + snapshotDir := filepath.Join(filepath.Dir(app.ConfigPath), "db_snapshots") + + if !fileutil.FileExists(snapshotDir) { + return snapshots, nil + } + + files, err := ioutil.ReadDir(snapshotDir) + if err != nil { + return snapshots, err + } + + for _, f := range files { + if f.IsDir() { + snapshots = append(snapshots, f.Name()) + } + } + + return snapshots, nil +} + // RestoreSnapshot restores a mariadb snapshot of the db to be loaded // The project must be stopped and docker volume removed and recreated for this to work. func (app *DdevApp) RestoreSnapshot(snapshotName string) error { diff --git a/pkg/ddevapp/ddevapp_test.go b/pkg/ddevapp/ddevapp_test.go index 87dd3625d3a..de97387bb90 100644 --- a/pkg/ddevapp/ddevapp_test.go +++ b/pkg/ddevapp/ddevapp_test.go @@ -1367,6 +1367,47 @@ func TestDdevFullSiteSetup(t *testing.T) { fmt.Print() } +// TestDdevSnapshotCleanup tests creating a snapshot and deleting it. +func TestDdevSnapshotCleanup(t *testing.T) { + assert := asrt.New(t) + app := &ddevapp.DdevApp{} + site := TestSites[0] + switchDir := site.Chdir() + defer switchDir() + + runTime := util.TimeTrack(time.Now(), fmt.Sprintf("TestDdevSnapshotCleanup")) + + testcommon.ClearDockerEnv() + err := app.Init(site.Dir) + assert.NoError(err) + + err = app.StartAndWait(0) + assert.NoError(err) + + // Make a snapshot of d7 tester test 1 + backupsDir := filepath.Join(app.GetConfigPath(""), "db_snapshots") + snapshotName, err := app.Snapshot("d7testerTest1") + assert.NoError(err) + + assert.True(fileutil.FileExists(filepath.Join(backupsDir, snapshotName, "xtrabackup_info")), "Expected that file xtrabackup_info in snapshot exists") + + err = app.Init(site.Dir) + require.NoError(t, err) + + err = app.Start() + require.NoError(t, err) + //nolint: errcheck + defer app.Stop(true, false) + + err = app.DeleteSnapshot("d7testerTest1") + assert.NoError(err) + + // Snapshot data should be deleted + assert.False(fileutil.FileExists(filepath.Join(backupsDir, snapshotName, "xtrabackup_info")), "Expected that file of snapshot is deleted during cleanup") + + runTime() +} + // TestDdevRestoreSnapshot tests creating a snapshot and reverting to it. This runs with Mariadb 10.2 func TestDdevRestoreSnapshot(t *testing.T) { assert := asrt.New(t)