github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/db/db_local/backup.go (about) 1 package db_local 2 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "io/fs" 8 "log" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "sort" 13 "strings" 14 "time" 15 16 "github.com/turbot/go-kit/files" 17 18 "github.com/shirou/gopsutil/process" 19 "github.com/turbot/steampipe/pkg/constants" 20 "github.com/turbot/steampipe/pkg/error_helpers" 21 "github.com/turbot/steampipe/pkg/filepaths" 22 "github.com/turbot/steampipe/pkg/utils" 23 ) 24 25 var ( 26 errDbInstanceRunning = fmt.Errorf("cannot start DB backup - a postgres instance is still running and Steampipe could not kill it. Please kill this manually and restart Steampipe") 27 ) 28 29 const ( 30 backupFormat = "custom" 31 backupDumpFileExtension = "dump" 32 backupTextFileExtension = "sql" 33 ) 34 35 // pgRunningInfo represents a running pg instance that we need to startup to create the 36 // backup archive and the name of the installed database 37 type pgRunningInfo struct { 38 cmd *exec.Cmd 39 port int 40 dbName string 41 } 42 43 // stop is used for shutting down postgres instance spun up for extracting dump 44 // it uses signals as suggested by https://www.postgresql.org/docs/12/server-shutdown.html 45 // to try to shutdown the db process process. 46 // It is not expected that any client is connected to the instance when 'stop' is called. 47 // Connected clients will be forcefully disconnected 48 func (r *pgRunningInfo) stop(ctx context.Context) error { 49 p, err := process.NewProcess(int32(r.cmd.Process.Pid)) 50 if err != nil { 51 return err 52 } 53 return doThreeStepPostgresExit(ctx, p) 54 } 55 56 const ( 57 noMatViewRefreshListFileName = "without_refresh.lst" 58 onlyMatViewRefreshListFileName = "only_refresh.lst" 59 ) 60 61 // prepareBackup creates a backup file of the public schema for the current database, if we are migrating 62 // if a backup was taken, this returns the name of the database that was backed up 63 func prepareBackup(ctx context.Context) (*string, error) { 64 found, location, err := findDifferentPgInstallation(ctx) 65 if err != nil { 66 log.Println("[TRACE] Error while finding different PG Version:", err) 67 return nil, err 68 } 69 // nothing found - nothing to do 70 if !found { 71 return nil, nil 72 } 73 74 // ensure there is no orphaned instance of postgres running 75 // (if the service state file was in-tact, we would already have found it and 76 // failed before now with a suitable message 77 // - to get here the state file must be missing/invalid, so just kill the postgres process) 78 // ignore error - just proceed with installation 79 if err := killRunningDbInstance(ctx); err != nil { 80 return nil, err 81 } 82 83 runConfig, err := startDatabaseInLocation(ctx, location) 84 if err != nil { 85 log.Printf("[TRACE] Error while starting old db in %s: %v", location, err) 86 return nil, err 87 } 88 //nolint:golint,errcheck // this will probably never error - if it does, it's not something we can recover from with code 89 defer runConfig.stop(ctx) 90 91 if err := takeBackup(ctx, runConfig); err != nil { 92 return &runConfig.dbName, err 93 } 94 95 return &runConfig.dbName, nil 96 } 97 98 // killRunningDbInstance searches for a postgres instance running in the install dir 99 // and if found tries to kill it 100 func killRunningDbInstance(ctx context.Context) error { 101 processes, err := FindAllSteampipePostgresInstances(ctx) 102 if err != nil { 103 log.Println("[TRACE] FindAllSteampipePostgresInstances failed with", err) 104 return err 105 } 106 107 for _, p := range processes { 108 cmdLine, err := p.CmdlineWithContext(ctx) 109 if err != nil { 110 continue 111 } 112 113 // check if the name of the process is prefixed with the $STEAMPIPE_INSTALL_DIR 114 // that means this is a steampipe service from this installation directory 115 if strings.HasPrefix(cmdLine, filepaths.SteampipeDir) { 116 log.Println("[TRACE] Terminating running postgres process") 117 if err := p.Kill(); err != nil { 118 error_helpers.ShowWarning(fmt.Sprintf("Failed to kill orphan postgres process PID %d", p.Pid)) 119 return errDbInstanceRunning 120 } 121 } 122 } 123 return nil 124 } 125 126 // backup the old pg instance public schema using pg_dump 127 func takeBackup(ctx context.Context, config *pgRunningInfo) error { 128 cmd := pgDumpCmd( 129 ctx, 130 fmt.Sprintf("--file=%s", filepaths.DatabaseBackupFilePath()), 131 fmt.Sprintf("--format=%s", backupFormat), 132 // of the public schema only 133 "--schema=public", 134 // only backup the database used by steampipe 135 fmt.Sprintf("--dbname=%s", config.dbName), 136 // connection parameters 137 "--host=127.0.0.1", 138 fmt.Sprintf("--port=%d", config.port), 139 fmt.Sprintf("--username=%s", constants.DatabaseSuperUser), 140 ) 141 log.Println("[TRACE] starting pg_dump command:", cmd.String()) 142 143 if output, err := cmd.CombinedOutput(); err != nil { 144 log.Println("[TRACE] pg_dump process output:", string(output)) 145 return err 146 } 147 148 return nil 149 } 150 151 // startDatabaseInLocation starts up the postgres binary in a specific installation directory 152 // returns a pgRunningInfo instance 153 func startDatabaseInLocation(ctx context.Context, location string) (*pgRunningInfo, error) { 154 binaryLocation := filepath.Join(location, "postgres", "bin", "postgres") 155 dataLocation := filepath.Join(location, "data") 156 port, err := utils.GetNextFreePort() 157 if err != nil { 158 return nil, err 159 } 160 cmd := exec.CommandContext( 161 ctx, 162 binaryLocation, 163 // by this time, we are sure that the port is free to listen to 164 "-p", fmt.Sprint(port), 165 "-c", "listen_addresses=127.0.0.1", 166 // NOTE: If quoted, the application name includes the quotes. Worried about 167 // having spaces in the APPNAME, but leaving it unquoted since currently 168 // the APPNAME is hardcoded to be steampipe. 169 "-c", fmt.Sprintf("application_name=%s", constants.AppName), 170 "-c", fmt.Sprintf("cluster_name=%s", constants.AppName), 171 172 // Data Directory 173 "-D", dataLocation, 174 ) 175 176 log.Println("[TRACE]", cmd.String()) 177 178 if err := cmd.Start(); err != nil { 179 return nil, err 180 } 181 182 runConfig := &pgRunningInfo{cmd: cmd, port: port} 183 184 dbName, err := getDatabaseName(ctx, port) 185 if err != nil { 186 runConfig.stop(ctx) 187 return nil, err 188 } 189 190 runConfig.dbName = dbName 191 192 return runConfig, nil 193 } 194 195 // findDifferentPgInstallation checks whether the '$STEAMPIPE_INSTALL_DIR/db' directory contains any database installation 196 // other than desired version. 197 // it's called as part of `prepareBackup` to decide whether `pg_dump` needs to run 198 // it's also called as part of `restoreDBBackup` for removal of the installation once restoration successfully completes 199 func findDifferentPgInstallation(ctx context.Context) (bool, string, error) { 200 dbBaseDirectory := filepaths.EnsureDatabaseDir() 201 entries, err := os.ReadDir(dbBaseDirectory) 202 if err != nil { 203 return false, "", err 204 } 205 for _, de := range entries { 206 if de.IsDir() { 207 // check if it contains a postgres binary - meaning this is a DB installation 208 isDBInstallationDirectory := files.FileExists( 209 filepath.Join( 210 dbBaseDirectory, 211 de.Name(), 212 "postgres", 213 "bin", 214 "postgres", 215 ), 216 ) 217 218 // if not the target DB version 219 if de.Name() != constants.DatabaseVersion && isDBInstallationDirectory { 220 // this is an unknown directory. 221 // this MUST be some other installation 222 return true, filepath.Join(dbBaseDirectory, de.Name()), nil 223 } 224 } 225 } 226 227 return false, "", nil 228 } 229 230 // restoreDBBackup loads the back up file into the database 231 func restoreDBBackup(ctx context.Context) error { 232 backupFilePath := filepaths.DatabaseBackupFilePath() 233 if !files.FileExists(backupFilePath) { 234 // nothing to do here 235 return nil 236 } 237 log.Printf("[TRACE] restoreDBBackup: backup file '%s' found, restoring", backupFilePath) 238 239 // load the db status 240 runningInfo, err := GetState() 241 if err != nil { 242 return err 243 } 244 if runningInfo == nil { 245 return fmt.Errorf("steampipe service is not running") 246 } 247 248 // extract the Table of Contents from the Backup Archive 249 toc, err := getTableOfContentsFromBackup(ctx) 250 if err != nil { 251 return err 252 } 253 254 // create separate TableOfContent files - one containing only DB OBJECT CREATION (with static data) instructions and another containing only REFRESH MATERIALIZED VIEW instructions 255 objectAndStaticDataListFile, matviewRefreshListFile, err := partitionTableOfContents(ctx, toc) 256 if err != nil { 257 return err 258 } 259 defer func() { 260 // remove both files before returning 261 // if the restoration fails, these will be regenerated at the next run 262 os.Remove(objectAndStaticDataListFile) 263 os.Remove(matviewRefreshListFile) 264 }() 265 266 // restore everything, but don't refresh Materialized views. 267 err = runRestoreUsingList(ctx, runningInfo, objectAndStaticDataListFile) 268 if err != nil { 269 return err 270 } 271 272 // 273 // make an attempt at refreshing the materialized views as part of restoration 274 // we are doing this separately, since we do not want the whole restoration to fail if we can't refresh 275 // 276 // we may not be able to restore when the materilized views contain transitive references to unqualified 277 // table names 278 // 279 // since 'pg_dump' always set a blank 'search_path', it will not be able to resolve the aforementioned transitive 280 // dependencies and will inevitably fail to refresh 281 // 282 err = runRestoreUsingList(ctx, runningInfo, matviewRefreshListFile) 283 if err != nil { 284 // 285 // we could not refresh the Materialized views 286 // this is probably because the Materialized views 287 // contain transitive references to unqualified table names 288 // 289 // WARN the user. 290 // 291 error_helpers.ShowWarning("Could not REFRESH Materialized Views while restoring data. Please REFRESH manually.") 292 } 293 294 if err := retainBackup(ctx); err != nil { 295 error_helpers.ShowWarning(fmt.Sprintf("Failed to save backup file: %v", err)) 296 } 297 298 // get the location of the other instance which was backed up 299 found, location, err := findDifferentPgInstallation(ctx) 300 if err != nil { 301 return err 302 } 303 304 // remove it 305 if found { 306 if err := os.RemoveAll(location); err != nil { 307 log.Printf("[WARN] Could not remove old installation at %s.", location) 308 } 309 } 310 311 return nil 312 } 313 314 func runRestoreUsingList(ctx context.Context, info *RunningDBInstanceInfo, listFile string) error { 315 cmd := pgRestoreCmd( 316 ctx, 317 filepaths.DatabaseBackupFilePath(), 318 fmt.Sprintf("--format=%s", backupFormat), 319 // only the public schema is backed up 320 "--schema=public", 321 // Execute the restore as a single transaction (that is, wrap the emitted commands in BEGIN/COMMIT). 322 // This ensures that either all the commands complete successfully, or no changes are applied. 323 // This option implies --exit-on-error. 324 "--single-transaction", 325 // Restore only those archive elements that are listed in list-file, and restore them in the order they appear in the file. 326 fmt.Sprintf("--use-list=%s", listFile), 327 // the database name 328 fmt.Sprintf("--dbname=%s", info.Database), 329 // connection parameters 330 "--host=127.0.0.1", 331 fmt.Sprintf("--port=%d", info.Port), 332 fmt.Sprintf("--username=%s", info.User), 333 ) 334 335 log.Println("[TRACE]", cmd.String()) 336 337 if output, err := cmd.CombinedOutput(); err != nil { 338 log.Println("[TRACE] runRestoreUsingList process:", string(output)) 339 return err 340 } 341 342 return nil 343 } 344 345 // partitionTableOfContents writes back the TableOfContents into a two temporary TableOfContents files: 346 // 347 // 1. without REFRESH MATERIALIZED VIEWS commands and 2. only REFRESH MATERIALIZED VIEWS commands 348 // 349 // This needs to be done because the pg_dump will always set a blank search path in the backup archive 350 // and backed up MATERIALIZED VIEWS may have functions with unqualified table names 351 func partitionTableOfContents(ctx context.Context, tableOfContentsOfBackup []string) (string, string, error) { 352 onlyRefresh, withoutRefresh := utils.Partition(tableOfContentsOfBackup, func(v string) bool { 353 return strings.Contains(strings.ToUpper(v), "MATERIALIZED VIEW DATA") 354 }) 355 356 withoutFile := filepath.Join(filepaths.EnsureDatabaseDir(), noMatViewRefreshListFileName) 357 onlyFile := filepath.Join(filepaths.EnsureDatabaseDir(), onlyMatViewRefreshListFileName) 358 359 err := error_helpers.CombineErrors( 360 os.WriteFile(withoutFile, []byte(strings.Join(withoutRefresh, "\n")), 0644), 361 os.WriteFile(onlyFile, []byte(strings.Join(onlyRefresh, "\n")), 0644), 362 ) 363 364 return withoutFile, onlyFile, err 365 } 366 367 // getTableOfContentsFromBackup uses pg_restore to read the TableOfContents from the 368 // back archive 369 func getTableOfContentsFromBackup(ctx context.Context) ([]string, error) { 370 cmd := pgRestoreCmd( 371 ctx, 372 filepaths.DatabaseBackupFilePath(), 373 fmt.Sprintf("--format=%s", backupFormat), 374 // only the public schema is backed up 375 "--schema=public", 376 "--list", 377 ) 378 log.Println("[TRACE] TableOfContent extraction command: ", cmd.String()) 379 380 b, err := cmd.Output() 381 if err != nil { 382 return nil, err 383 } 384 385 scanner := bufio.NewScanner(strings.NewReader(string(b))) 386 scanner.Split(bufio.ScanLines) 387 388 /* start with an extra comment line */ 389 lines := []string{";"} 390 for scanner.Scan() { 391 line := scanner.Text() 392 if strings.HasPrefix(line, ";") { 393 // no use of comments 394 continue 395 } 396 lines = append(lines, scanner.Text()) 397 } 398 /* an extra comment line at the end */ 399 lines = append(lines, ";") 400 401 return lines, err 402 } 403 404 // retainBackup creates a text dump of the backup binary and saves both in the $STEAMPIPE_INSTALL_DIR/backups directory 405 // the backups are saved as: 406 // 407 // binary: 'database-yyyy-MM-dd-hh-mm-ss.dump' 408 // text: 'database-yyyy-MM-dd-hh-mm-ss.sql' 409 func retainBackup(ctx context.Context) error { 410 now := time.Now() 411 backupBaseFileName := fmt.Sprintf( 412 "database-%s", 413 now.Format("2006-01-02-15-04-05"), 414 ) 415 binaryBackupRetentionFileName := fmt.Sprintf("%s.%s", backupBaseFileName, backupDumpFileExtension) 416 textBackupRetentionFileName := fmt.Sprintf("%s.%s", backupBaseFileName, backupTextFileExtension) 417 418 backupDir := filepaths.EnsureBackupsDir() 419 binaryBackupFilePath := filepath.Join(backupDir, binaryBackupRetentionFileName) 420 textBackupFilePath := filepath.Join(backupDir, textBackupRetentionFileName) 421 422 log.Println("[TRACE] moving database back up to", binaryBackupFilePath) 423 if err := utils.MoveFile(filepaths.DatabaseBackupFilePath(), binaryBackupFilePath); err != nil { 424 return err 425 } 426 log.Println("[TRACE] converting database back up to", textBackupFilePath) 427 txtConvertCmd := pgRestoreCmd( 428 ctx, 429 binaryBackupFilePath, 430 fmt.Sprintf("--file=%s", textBackupFilePath), 431 ) 432 433 if output, err := txtConvertCmd.CombinedOutput(); err != nil { 434 log.Println("[TRACE] pg_restore convertion process output:", string(output)) 435 return err 436 } 437 438 // limit the number of old backups 439 trimBackups() 440 441 return nil 442 } 443 444 func pgDumpCmd(ctx context.Context, args ...string) *exec.Cmd { 445 cmd := exec.CommandContext( 446 ctx, 447 filepaths.PgDumpBinaryExecutablePath(), 448 args..., 449 ) 450 cmd.Env = append(os.Environ(), "PGSSLMODE=disable") 451 452 log.Println("[TRACE] pg_dump command:", cmd.String()) 453 return cmd 454 } 455 456 func pgRestoreCmd(ctx context.Context, args ...string) *exec.Cmd { 457 cmd := exec.CommandContext( 458 ctx, 459 filepaths.PgRestoreBinaryExecutablePath(), 460 args..., 461 ) 462 cmd.Env = append(os.Environ(), "PGSSLMODE=disable") 463 464 log.Println("[TRACE] pg_restore command:", cmd.String()) 465 return cmd 466 } 467 468 // trimBackups trims the number of backups to the most recent constants.MaxBackups 469 func trimBackups() { 470 backupDir := filepaths.BackupsDir() 471 files, err := os.ReadDir(backupDir) 472 if err != nil { 473 error_helpers.ShowWarning(fmt.Sprintf("Failed to trim backups folder: %s", err.Error())) 474 return 475 } 476 477 // retain only the .dump files (just to get the unique backups) 478 files = utils.Filter(files, func(v fs.DirEntry) bool { 479 if v.Type().IsDir() { 480 return false 481 } 482 // retain only the .dump files 483 return strings.HasSuffix(v.Name(), backupDumpFileExtension) 484 }) 485 486 // map to the names of the backups, without extensions 487 names := utils.Map(files, func(v fs.DirEntry) string { 488 return strings.TrimSuffix(v.Name(), filepath.Ext(v.Name())) 489 }) 490 491 // just sorting should work, since these names are suffixed by date of the format yyyy-MM-dd-hh-mm-ss 492 sort.Strings(names) 493 494 for len(names) > constants.MaxBackups { 495 // shift the first element 496 trim := names[0] 497 498 // remove the first element from the array 499 names = names[1:] 500 501 // get back the names 502 dumpFilePath := filepath.Join(backupDir, fmt.Sprintf("%s.%s", trim, backupDumpFileExtension)) 503 textFilePath := filepath.Join(backupDir, fmt.Sprintf("%s.%s", trim, backupTextFileExtension)) 504 505 removeErr := error_helpers.CombineErrors(os.Remove(dumpFilePath), os.Remove(textFilePath)) 506 if removeErr != nil { 507 error_helpers.ShowWarning(fmt.Sprintf("Could not remove backup: %s", trim)) 508 } 509 } 510 }