github.com/drud/ddev@v1.21.5-alpha1.0.20230226034409-94fcc4b94453/pkg/ddevapp/snapshot.go (about)

     1  package ddevapp
     2  
     3  import (
     4  	"fmt"
     5  	"github.com/drud/ddev/pkg/dockerutil"
     6  	"github.com/drud/ddev/pkg/fileutil"
     7  	"github.com/drud/ddev/pkg/globalconfig"
     8  	"github.com/drud/ddev/pkg/nodeps"
     9  	"github.com/drud/ddev/pkg/output"
    10  	"github.com/drud/ddev/pkg/util"
    11  	"io/fs"
    12  	"os"
    13  	"path"
    14  	"path/filepath"
    15  	"regexp"
    16  	"sort"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  )
    21  
    22  // DeleteSnapshot removes the snapshot tarball or directory inside a project
    23  func (app *DdevApp) DeleteSnapshot(snapshotName string) error {
    24  	var err error
    25  	err = app.ProcessHooks("pre-delete-snapshot")
    26  	if err != nil {
    27  		return fmt.Errorf("failed to process pre-delete-snapshot hooks: %v", err)
    28  	}
    29  
    30  	snapshotFullName, err := GetSnapshotFileFromName(snapshotName, app)
    31  	if err != nil {
    32  		return err
    33  	}
    34  
    35  	snapshotFullPath := path.Join("db_snapshots", snapshotFullName)
    36  	hostSnapshot := app.GetConfigPath(snapshotFullPath)
    37  
    38  	if !fileutil.FileExists(hostSnapshot) {
    39  		return fmt.Errorf("no snapshot '%s' currently exists in project '%s'", snapshotName, app.Name)
    40  	}
    41  	if err = os.RemoveAll(hostSnapshot); err != nil {
    42  		return fmt.Errorf("failed to remove snapshot '%s': %v", hostSnapshot, err)
    43  	}
    44  
    45  	util.Success("Deleted database snapshot '%s'", snapshotName)
    46  	err = app.ProcessHooks("post-delete-snapshot")
    47  	if err != nil {
    48  		return fmt.Errorf("failed to process post-delete-snapshot hooks: %v", err)
    49  	}
    50  
    51  	return nil
    52  }
    53  
    54  // GetLatestSnapshot returns the latest created snapshot of a project
    55  func (app *DdevApp) GetLatestSnapshot() (string, error) {
    56  	var snapshots []string
    57  
    58  	snapshots, err := app.ListSnapshots()
    59  	if err != nil {
    60  		return "", err
    61  	}
    62  
    63  	if len(snapshots) == 0 {
    64  		return "", fmt.Errorf("no snapshots found")
    65  	}
    66  
    67  	return snapshots[0], nil
    68  }
    69  
    70  // ListSnapshots returns a list of the names of all project snapshots
    71  func (app *DdevApp) ListSnapshots() ([]string, error) {
    72  	var err error
    73  	var snapshots []string
    74  
    75  	snapshotDir := app.GetConfigPath("db_snapshots")
    76  
    77  	if !fileutil.FileExists(snapshotDir) {
    78  		return snapshots, nil
    79  	}
    80  
    81  	fileNames, err := fileutil.ListFilesInDir(snapshotDir)
    82  	if err != nil {
    83  		return snapshots, err
    84  	}
    85  
    86  	var files []fs.FileInfo
    87  	for _, n := range fileNames {
    88  		f, err := os.Stat(filepath.Join(snapshotDir, n))
    89  		if err != nil {
    90  			return snapshots, err
    91  		}
    92  		files = append(files, f)
    93  	}
    94  
    95  	// Sort snapshots by last modification time
    96  	// we need that to detect the latest snapshot
    97  	// first snapshot is the latest
    98  	sort.Slice(files, func(i, j int) bool {
    99  		return files[i].ModTime().After(files[j].ModTime())
   100  	})
   101  
   102  	m := regexp.MustCompile(`-(mariadb|mysql|postgres)_[0-9.]*\.gz$`)
   103  
   104  	for _, f := range files {
   105  		if f.IsDir() || strings.HasSuffix(f.Name(), ".gz") {
   106  			n := m.ReplaceAll([]byte(f.Name()), []byte(""))
   107  			snapshots = append(snapshots, string(n))
   108  		}
   109  	}
   110  
   111  	return snapshots, nil
   112  }
   113  
   114  // RestoreSnapshot restores a mariadb snapshot of the db to be loaded
   115  // The project must be stopped and docker volume removed and recreated for this to work.
   116  func (app *DdevApp) RestoreSnapshot(snapshotName string) error {
   117  	var err error
   118  	err = app.ProcessHooks("pre-restore-snapshot")
   119  	if err != nil {
   120  		return fmt.Errorf("failed to process pre-restore-snapshot hooks: %v", err)
   121  	}
   122  
   123  	currentDBVersion := app.Database.Type + "_" + app.Database.Version
   124  
   125  	snapshotFile, err := GetSnapshotFileFromName(snapshotName, app)
   126  	if err != nil {
   127  		return fmt.Errorf("no snapshot found for name %s: %v", snapshotName, err)
   128  	}
   129  	snapshotFileOrDir := filepath.Join("db_snapshots", snapshotFile)
   130  
   131  	hostSnapshotFileOrDir := app.GetConfigPath(snapshotFileOrDir)
   132  
   133  	if !fileutil.FileExists(hostSnapshotFileOrDir) {
   134  		return fmt.Errorf("failed to find a snapshot at %s", hostSnapshotFileOrDir)
   135  	}
   136  
   137  	snapshotDBVersion := ""
   138  
   139  	// If the snapshot is a directory, (old obsolete style) then
   140  	// look for db_mariadb_version.txt in the directory to get the version.
   141  	if fileutil.IsDirectory(hostSnapshotFileOrDir) {
   142  		// Find out the mariadb version that correlates to the snapshot.
   143  		versionFile := filepath.Join(hostSnapshotFileOrDir, "db_mariadb_version.txt")
   144  		if fileutil.FileExists(versionFile) {
   145  			snapshotDBVersion, err = fileutil.ReadFileIntoString(versionFile)
   146  			if err != nil {
   147  				return fmt.Errorf("unable to read the version file in the snapshot (%s): %v", versionFile, err)
   148  			}
   149  			snapshotDBVersion = strings.Trim(snapshotDBVersion, "\r\n\t ")
   150  			snapshotDBVersion = fullDBFromVersion(snapshotDBVersion)
   151  		} else {
   152  			snapshotDBVersion = "unknown"
   153  		}
   154  	} else {
   155  		m1 := regexp.MustCompile(`((mysql|mariadb|postgres)_[0-9.]+)\.gz$`)
   156  		matches := m1.FindStringSubmatch(snapshotFile)
   157  		if len(matches) > 2 {
   158  			snapshotDBVersion = matches[1]
   159  		} else {
   160  			return fmt.Errorf("unable to determine database type/version from snapshot %s", snapshotFile)
   161  		}
   162  
   163  		if !(strings.HasPrefix(snapshotDBVersion, "mariadb_") || strings.HasPrefix(snapshotDBVersion, "mysql_") || strings.HasPrefix(snapshotDBVersion, "postgres_")) {
   164  			return fmt.Errorf("unable to determine database type/version from snapshot name %s", snapshotFile)
   165  		}
   166  	}
   167  
   168  	if snapshotDBVersion != currentDBVersion {
   169  		return fmt.Errorf("snapshot '%s' is a DB server '%s' snapshot and is not compatible with the configured ddev DB server version (%s).  Please restore it using the DB version it was created with, and then you can try upgrading the ddev DB version", snapshotName, snapshotDBVersion, currentDBVersion)
   170  	}
   171  
   172  	status, _ := app.SiteStatus()
   173  	start := time.Now()
   174  
   175  	// For mariadb/mysql restart container and wait for restore
   176  	if status == SiteRunning || status == SitePaused {
   177  		util.Success("Stopping db container for snapshot restore of '%s'...", snapshotFile)
   178  		util.Success("With large snapshots this may take a long time.\nThis will normally time out after %d seconds (max of all container timeouts)\nbut you can increase it by changing default_container_timeout.", app.FindMaxTimeout())
   179  		dbContainer, err := GetContainer(app, "db")
   180  		if err != nil || dbContainer == nil {
   181  			return fmt.Errorf("no container found for db; err=%v", err)
   182  		}
   183  		err = dockerutil.RemoveContainer(dbContainer.ID, 20)
   184  		if err != nil {
   185  			return fmt.Errorf("failed to remove db container: %v", err)
   186  		}
   187  	}
   188  
   189  	// If we have no bind mounts, we need to copy our snapshot into the snapshots volme
   190  	// With bind mounts, they'll already be there in the /mnt/ddev_config/db_snapshots folder
   191  	if globalconfig.DdevGlobalConfig.NoBindMounts {
   192  		uid, _, _ := util.GetContainerUIDGid()
   193  		// for postgres, must be written with postgres user
   194  		if app.Database.Type == nodeps.Postgres {
   195  			uid = "999"
   196  		}
   197  
   198  		// If the snapshot is an old-style directory-based snapshot, then we have to copy into a subdirectory
   199  		// named for the snapshot
   200  		subdir := ""
   201  		if fileutil.IsDirectory(hostSnapshotFileOrDir) {
   202  			subdir = snapshotName
   203  		}
   204  
   205  		err = dockerutil.CopyIntoVolume(filepath.Join(app.GetConfigPath("db_snapshots"), snapshotFile), "ddev-"+app.Name+"-snapshots", subdir, uid, "", true)
   206  		if err != nil {
   207  			return err
   208  		}
   209  	}
   210  
   211  	restoreCmd := "restore_snapshot " + snapshotFile
   212  	if app.Database.Type == nodeps.Postgres {
   213  		confdDir := path.Join(nodeps.PostgresConfigDir, "conf.d")
   214  		targetConfName := path.Join(confdDir, "recovery.conf")
   215  		v, _ := strconv.Atoi(app.Database.Version)
   216  		// Before postgres v12 the recovery info went into its own file
   217  		if v < 12 {
   218  			targetConfName = path.Join(nodeps.PostgresConfigDir, "recovery.conf")
   219  		}
   220  		restoreCmd = fmt.Sprintf(`bash -c 'chmod 700 /var/lib/postgresql/data && mkdir -p %s && rm -rf /var/lib/postgresql/data/* && tar -C /var/lib/postgresql/data -zxf /mnt/snapshots/%s && touch /var/lib/postgresql/data/recovery.signal && cat /var/lib/postgresql/recovery.conf >>%s && postgres -c config_file=%s/postgresql.conf -c hba_file=%s/pg_hba.conf'`, confdDir, snapshotFile, targetConfName, nodeps.PostgresConfigDir, nodeps.PostgresConfigDir)
   221  	}
   222  	_ = os.Setenv("DDEV_DB_CONTAINER_COMMAND", restoreCmd)
   223  	// nolint: errcheck
   224  	defer os.Unsetenv("DDEV_DB_CONTAINER_COMMAND")
   225  	err = app.Start()
   226  	if err != nil {
   227  		return fmt.Errorf("failed to start project for RestoreSnapshot: %v", err)
   228  	}
   229  
   230  	// On mysql/mariadb the snapshot restore doesn't actually complete right away after
   231  	// the mariabackup/xtrabackup returns.
   232  	if app.Database.Type != nodeps.Postgres {
   233  		output.UserOut.Printf("Waiting for snapshot restore to complete...\nYou can also follow the restore progress in another terminal window with `ddev logs -s db -f %s`", app.Name)
   234  		// Now it's up, but we need to find out when it finishes loading.
   235  		for {
   236  			// We used to use killall -1 mysqld here
   237  			// also used to use "pidof mysqld", but apparently the
   238  			// server may not quite be ready when its pid appears
   239  			out, _, err := app.Exec(&ExecOpts{
   240  				Cmd:     `(echo "SHOW VARIABLES like 'v%';" | mysql 2>/dev/null) || true`,
   241  				Service: "db",
   242  				Tty:     false,
   243  			})
   244  			if err != nil {
   245  				return err
   246  			}
   247  			if out != "" {
   248  				break
   249  			}
   250  			time.Sleep(1 * time.Second)
   251  			fmt.Print(".")
   252  		}
   253  	}
   254  	util.Success("\nDatabase snapshot %s was restored in %vs", snapshotName, int(time.Since(start).Seconds()))
   255  	err = app.ProcessHooks("post-restore-snapshot")
   256  	if err != nil {
   257  		return fmt.Errorf("failed to process post-restore-snapshot hooks: %v", err)
   258  	}
   259  	return nil
   260  }
   261  
   262  // GetSnapshotFileFromName returns the filename corresponding to the snapshot name
   263  func GetSnapshotFileFromName(name string, app *DdevApp) (string, error) {
   264  	snapshotsDir := app.GetConfigPath("db_snapshots")
   265  	snapshotFullPath := filepath.Join(snapshotsDir, name)
   266  	// If old-style directory-based snapshot, then just use the name, no massaging required
   267  	if fileutil.IsDirectory(snapshotFullPath) {
   268  		return name, nil
   269  	}
   270  	// But if it's a gzipped tarball, we have to get the filename.
   271  	files, err := fileutil.ListFilesInDir(snapshotsDir)
   272  	if err != nil {
   273  		return "", err
   274  	}
   275  	for _, file := range files {
   276  		if strings.HasPrefix(file, name+"-") {
   277  			return file, nil
   278  		}
   279  	}
   280  	return "", fmt.Errorf("snapshot %s not found in %s", name, snapshotsDir)
   281  }