github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/snapshot.go (about) 1 package ddevapp 2 3 import ( 4 "fmt" 5 "github.com/ddev/ddev/pkg/dockerutil" 6 "github.com/ddev/ddev/pkg/fileutil" 7 "github.com/ddev/ddev/pkg/globalconfig" 8 "github.com/ddev/ddev/pkg/nodeps" 9 "github.com/ddev/ddev/pkg/output" 10 "github.com/ddev/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) 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 PostgreSQL, must be written with PostgreSQL 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 PostgreSQL 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 // Allow extra time by default for the snapshot restore. This is arbitrary but may help. 226 origTimeout := app.DefaultContainerTimeout 227 app.DefaultContainerTimeout = "600" 228 err = app.Start() 229 if err != nil { 230 return fmt.Errorf("failed to start project for RestoreSnapshot: %v", err) 231 } 232 233 // On mysql/mariadb the snapshot restore doesn't actually complete right away after 234 // the mariabackup/xtrabackup returns. 235 if app.Database.Type != nodeps.Postgres { 236 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) 237 // Now it's up, but we need to find out when it finishes loading. 238 for { 239 // We used to use killall -1 mysqld here 240 // also used to use "pidof mysqld", but apparently the 241 // server may not quite be ready when its pid appears 242 out, _, err := app.Exec(&ExecOpts{ 243 Cmd: `(echo "SHOW VARIABLES like 'v%';" | mysql 2>/dev/null) || true`, 244 Service: "db", 245 Tty: false, 246 }) 247 if err != nil { 248 return err 249 } 250 if out != "" { 251 break 252 } 253 time.Sleep(1 * time.Second) 254 fmt.Print(".") 255 } 256 } 257 app.DefaultContainerTimeout = origTimeout 258 util.Success("\nDatabase snapshot %s was restored in %vs", snapshotName, int(time.Since(start).Seconds())) 259 err = app.ProcessHooks("post-restore-snapshot") 260 if err != nil { 261 return fmt.Errorf("failed to process post-restore-snapshot hooks: %v", err) 262 } 263 return nil 264 } 265 266 // GetSnapshotFileFromName returns the filename corresponding to the snapshot name 267 func GetSnapshotFileFromName(name string, app *DdevApp) (string, error) { 268 snapshotsDir := app.GetConfigPath("db_snapshots") 269 snapshotFullPath := filepath.Join(snapshotsDir, name) 270 // If old-style directory-based snapshot, then use the name, no massaging required 271 if fileutil.IsDirectory(snapshotFullPath) { 272 return name, nil 273 } 274 // But if it's a gzipped tarball, we have to get the filename. 275 files, err := fileutil.ListFilesInDir(snapshotsDir) 276 if err != nil { 277 return "", err 278 } 279 for _, file := range files { 280 if strings.HasPrefix(file, name+"-") { 281 return file, nil 282 } 283 } 284 return "", fmt.Errorf("snapshot %s not found in %s", name, snapshotsDir) 285 }