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  }