github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/db/db_local/install.go (about)

     1  package db_local
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"log"
     8  	"os"
     9  	"os/exec"
    10  	"sync"
    11  
    12  	"github.com/fatih/color"
    13  	"github.com/jackc/pgx/v5"
    14  	psutils "github.com/shirou/gopsutil/process"
    15  	filehelpers "github.com/turbot/go-kit/files"
    16  	"github.com/turbot/go-kit/helpers"
    17  	"github.com/turbot/steampipe/pkg/constants"
    18  	"github.com/turbot/steampipe/pkg/filepaths"
    19  	"github.com/turbot/steampipe/pkg/ociinstaller"
    20  	"github.com/turbot/steampipe/pkg/ociinstaller/versionfile"
    21  	"github.com/turbot/steampipe/pkg/statushooks"
    22  	"github.com/turbot/steampipe/pkg/utils"
    23  )
    24  
    25  var ensureMux sync.Mutex
    26  
    27  func noBackupWarning() string {
    28  	warningMessage := `Steampipe database has been upgraded from Postgres 12 to Postgres 14.
    29  
    30  Unfortunately the data in your public schema failed migration using the standard pg_dump and pg_restore tools. Your data has been preserved in the ~/.steampipe/db directory.
    31  
    32  If you need to restore the contents of your public schema, please open an issue at https://github.com/turbot/steampipe.`
    33  
    34  	return fmt.Sprintf("%s: %v\n", color.YellowString("Warning"), warningMessage)
    35  }
    36  
    37  // EnsureDBInstalled makes sure that the embedded postgres database is installed and ready to run
    38  func EnsureDBInstalled(ctx context.Context) (err error) {
    39  	utils.LogTime("db_local.EnsureDBInstalled start")
    40  
    41  	ensureMux.Lock()
    42  
    43  	doneChan := make(chan bool, 1)
    44  	defer func() {
    45  		if r := recover(); r != nil {
    46  			err = helpers.ToError(r)
    47  		}
    48  
    49  		utils.LogTime("db_local.EnsureDBInstalled end")
    50  		ensureMux.Unlock()
    51  		close(doneChan)
    52  	}()
    53  
    54  	if IsDBInstalled() {
    55  		// check if the FDW need updating, and init the db if required
    56  		err := prepareDb(ctx)
    57  		return err
    58  	}
    59  
    60  	// handle the case that the previous db version may still be running
    61  	dbState, err := GetState()
    62  	if err != nil {
    63  		log.Println("[TRACE] Error while loading database state", err)
    64  		return err
    65  	}
    66  	if dbState != nil {
    67  		return fmt.Errorf("cannot install service - a previous version of the Steampipe service is still running. To stop running services, use %s ", constants.Bold("steampipe service stop"))
    68  	}
    69  
    70  	log.Println("[TRACE] calling removeRunningInstanceInfo")
    71  	err = removeRunningInstanceInfo()
    72  	if err != nil && !os.IsNotExist(err) {
    73  		log.Printf("[TRACE] removeRunningInstanceInfo failed: %v", err)
    74  		return fmt.Errorf("Cleanup any Steampipe processes... FAILED!")
    75  	}
    76  
    77  	statushooks.SetStatus(ctx, "Installing database…")
    78  
    79  	err = downloadAndInstallDbFiles(ctx)
    80  	if err != nil {
    81  		return err
    82  	}
    83  
    84  	statushooks.SetStatus(ctx, "Preparing backups…")
    85  
    86  	// call prepareBackup to generate the db dump file if necessary
    87  	// NOTE: this returns the existing database name - we use this when creating the new database
    88  	dbName, err := prepareBackup(ctx)
    89  	if err != nil {
    90  		if errors.Is(err, errDbInstanceRunning) {
    91  			// remove the installation - otherwise, the backup won't get triggered, even if the user stops the service
    92  			os.RemoveAll(filepaths.DatabaseInstanceDir())
    93  			return err
    94  		}
    95  		// ignore all other errors with the backup, displaying a warning instead
    96  		statushooks.Message(ctx, noBackupWarning())
    97  	}
    98  
    99  	// install the fdw
   100  	_, err = installFDW(ctx, true)
   101  	if err != nil {
   102  		log.Printf("[TRACE] installFDW failed: %v", err)
   103  		return fmt.Errorf("Download & install steampipe-postgres-fdw... FAILED!")
   104  	}
   105  
   106  	// run the database installation
   107  	err = runInstall(ctx, dbName)
   108  	if err != nil {
   109  		return err
   110  	}
   111  
   112  	// write a signature after everything gets done!
   113  	// so that we can check for this later on
   114  	statushooks.SetStatus(ctx, "Updating install records…")
   115  	err = updateDownloadedBinarySignature()
   116  	if err != nil {
   117  		log.Printf("[TRACE] updateDownloadedBinarySignature failed: %v", err)
   118  		return fmt.Errorf("Updating install records... FAILED!")
   119  	}
   120  
   121  	return nil
   122  }
   123  
   124  func downloadAndInstallDbFiles(ctx context.Context) error {
   125  	statushooks.SetStatus(ctx, "Prepare database install location…")
   126  	// clear all db files
   127  	err := os.RemoveAll(filepaths.GetDatabaseLocation())
   128  	if err != nil {
   129  		log.Printf("[TRACE] %v", err)
   130  		return fmt.Errorf("Prepare database install location... FAILED!")
   131  	}
   132  
   133  	statushooks.SetStatus(ctx, "Download & install embedded PostgreSQL database…")
   134  	_, err = ociinstaller.InstallDB(ctx, filepaths.GetDatabaseLocation())
   135  	if err != nil {
   136  		log.Printf("[TRACE] %v", err)
   137  		return fmt.Errorf("Download & install embedded PostgreSQL database... FAILED!")
   138  	}
   139  	return nil
   140  }
   141  
   142  // IsDBInstalled checks and reports whether the embedded database binaries are available
   143  func IsDBInstalled() bool {
   144  	utils.LogTime("db_local.IsInstalled start")
   145  	defer utils.LogTime("db_local.IsInstalled end")
   146  	// check that both postgres binary and initdb binary exist
   147  	if _, err := os.Stat(filepaths.GetInitDbBinaryExecutablePath()); os.IsNotExist(err) {
   148  		return false
   149  	}
   150  	if _, err := os.Stat(filepaths.GetPostgresBinaryExecutablePath()); os.IsNotExist(err) {
   151  		return false
   152  	}
   153  	return true
   154  }
   155  
   156  // IsFDWInstalled chceks whether all files required for the Steampipe FDW are available
   157  func IsFDWInstalled() bool {
   158  	fdwSQLFile, fdwControlFile := filepaths.GetFDWSQLAndControlLocation()
   159  	if _, err := os.Stat(fdwSQLFile); os.IsNotExist(err) {
   160  		return false
   161  	}
   162  	if _, err := os.Stat(fdwControlFile); os.IsNotExist(err) {
   163  		return false
   164  	}
   165  	if _, err := os.Stat(filepaths.GetFDWBinaryLocation()); os.IsNotExist(err) {
   166  		return false
   167  	}
   168  	return true
   169  }
   170  
   171  // prepareDb updates the db binaries and FDW if needed, and inits the database if required
   172  func prepareDb(ctx context.Context) error {
   173  	// load the db version info file
   174  	utils.LogTime("db_local.LoadDatabaseVersionFile start")
   175  	versionInfo, err := versionfile.LoadDatabaseVersionFile()
   176  	utils.LogTime("db_local.LoadDatabaseVersionFile end")
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	// check if db needs to be updated
   182  	// this means that the db version number has NOT changed but the package has changed
   183  	// we can just drop in the new binaries
   184  	if dbNeedsUpdate(versionInfo) {
   185  		statushooks.SetStatus(ctx, "Updating database…")
   186  
   187  		// install new db binaries
   188  		if err = downloadAndInstallDbFiles(ctx); err != nil {
   189  			return err
   190  		}
   191  		// write a signature after everything gets done!
   192  		// so that we can check for this later on
   193  		statushooks.SetStatus(ctx, "Updating install records…")
   194  		if err = updateDownloadedBinarySignature(); err != nil {
   195  			log.Printf("[TRACE] updateDownloadedBinarySignature failed: %v", err)
   196  			return fmt.Errorf("Updating install records... FAILED!")
   197  		}
   198  	}
   199  
   200  	// if the FDW is not installed, or needs an update
   201  	if !IsFDWInstalled() || fdwNeedsUpdate(versionInfo) {
   202  		// install fdw
   203  		if _, err := installFDW(ctx, false); err != nil {
   204  			log.Printf("[TRACE] installFDW failed: %v", err)
   205  			return fmt.Errorf("Update steampipe-postgres-fdw... FAILED!")
   206  		}
   207  
   208  		// get the message renderer from the context
   209  		// this allows the interactive client init to inject a custom renderer
   210  		messageRenderer := statushooks.MessageRendererFromContext(ctx)
   211  		messageRenderer("%s updated to %s.", constants.Bold("steampipe-postgres-fdw"), constants.Bold(constants.FdwVersion))
   212  	}
   213  
   214  	if needsInit() {
   215  		statushooks.SetStatus(ctx, "Cleanup any Steampipe processes…")
   216  		killInstanceIfAny(ctx)
   217  		if err := runInstall(ctx, nil); err != nil {
   218  			return err
   219  		}
   220  	}
   221  	return nil
   222  }
   223  
   224  func fdwNeedsUpdate(versionInfo *versionfile.DatabaseVersionFile) bool {
   225  	return versionInfo.FdwExtension.Version != constants.FdwVersion
   226  }
   227  
   228  func dbNeedsUpdate(versionInfo *versionfile.DatabaseVersionFile) bool {
   229  	return versionInfo.EmbeddedDB.ImageDigest != constants.PostgresImageDigest
   230  }
   231  
   232  func installFDW(ctx context.Context, firstSetup bool) (string, error) {
   233  	utils.LogTime("db_local.installFDW start")
   234  	defer utils.LogTime("db_local.installFDW end")
   235  
   236  	state, err := GetState()
   237  	if err != nil {
   238  		return "", err
   239  	}
   240  	if state != nil {
   241  		defer func() {
   242  			if !firstSetup {
   243  				// update the signature
   244  				updateDownloadedBinarySignature()
   245  			}
   246  		}()
   247  	}
   248  	statushooks.SetStatus(ctx, fmt.Sprintf("Download & install %s…", constants.Bold("steampipe-postgres-fdw")))
   249  	return ociinstaller.InstallFdw(ctx, filepaths.GetDatabaseLocation())
   250  }
   251  
   252  func needsInit() bool {
   253  	utils.LogTime("db_local.needsInit start")
   254  	defer utils.LogTime("db_local.needsInit end")
   255  
   256  	// test whether pg_hba.conf exists in our target directory
   257  	return !filehelpers.FileExists(filepaths.GetPgHbaConfLocation())
   258  }
   259  
   260  func runInstall(ctx context.Context, oldDbName *string) error {
   261  	utils.LogTime("db_local.runInstall start")
   262  	defer utils.LogTime("db_local.runInstall end")
   263  
   264  	statushooks.SetStatus(ctx, "Cleaning up…")
   265  
   266  	err := utils.RemoveDirectoryContents(filepaths.GetDataLocation())
   267  	if err != nil {
   268  		log.Printf("[TRACE] %v", err)
   269  		return fmt.Errorf("Prepare database install location... FAILED!")
   270  	}
   271  
   272  	statushooks.SetStatus(ctx, "Initializing database…")
   273  	err = initDatabase()
   274  	if err != nil {
   275  		log.Printf("[TRACE] initDatabase failed: %v", err)
   276  		return fmt.Errorf("Initializing database... FAILED!")
   277  	}
   278  
   279  	statushooks.SetStatus(ctx, "Starting database…")
   280  	port, err := utils.GetNextFreePort()
   281  	if err != nil {
   282  		log.Printf("[TRACE] getNextFreePort failed: %v", err)
   283  		return fmt.Errorf("Starting database... FAILED!")
   284  	}
   285  
   286  	process, err := startServiceForInstall(port)
   287  	if err != nil {
   288  		log.Printf("[TRACE] startServiceForInstall failed: %v", err)
   289  		return fmt.Errorf("Starting database... FAILED!")
   290  	}
   291  
   292  	statushooks.SetStatus(ctx, "Connection to database…")
   293  	client, err := createMaintenanceClient(ctx, port)
   294  	if err != nil {
   295  		return fmt.Errorf("Connection to database... FAILED!")
   296  	}
   297  	defer func() {
   298  		statushooks.SetStatus(ctx, "Completing configuration")
   299  		client.Close(ctx)
   300  		doThreeStepPostgresExit(ctx, process)
   301  	}()
   302  
   303  	statushooks.SetStatus(ctx, "Generating database passwords…")
   304  	// generate a password file for use later
   305  	_, err = readPasswordFile()
   306  	if err != nil {
   307  		log.Printf("[TRACE] readPassword failed: %v", err)
   308  		return fmt.Errorf("Generating database passwords... FAILED!")
   309  	}
   310  
   311  	// resolve the name of the database that is to be installed
   312  	databaseName := resolveDatabaseName(oldDbName)
   313  	// validate db name
   314  	if !isValidDatabaseName(databaseName) {
   315  		return fmt.Errorf("Invalid database name '%s' - must start with either a lowercase character or an underscore", databaseName)
   316  	}
   317  
   318  	statushooks.SetStatus(ctx, "Configuring database…")
   319  	err = installDatabaseWithPermissions(ctx, databaseName, client)
   320  	if err != nil {
   321  		log.Printf("[TRACE] installSteampipeDatabaseAndUser failed: %v", err)
   322  		return fmt.Errorf("Configuring database... FAILED!")
   323  	}
   324  
   325  	statushooks.SetStatus(ctx, "Configuring Steampipe…")
   326  	err = installForeignServer(ctx, client)
   327  	if err != nil {
   328  		log.Printf("[TRACE] installForeignServer failed: %v", err)
   329  		return fmt.Errorf("Configuring Steampipe... FAILED!")
   330  	}
   331  
   332  	return nil
   333  }
   334  
   335  func resolveDatabaseName(oldDbName *string) string {
   336  	// resolve the name of the database that is to be installed
   337  	// use the application constant as default
   338  	if oldDbName != nil {
   339  		return *oldDbName
   340  	}
   341  	databaseName := constants.DatabaseName
   342  	if envValue, exists := os.LookupEnv(constants.EnvInstallDatabase); exists && len(envValue) > 0 {
   343  		// use whatever is supplied, if available
   344  		databaseName = envValue
   345  	}
   346  	return databaseName
   347  }
   348  
   349  func startServiceForInstall(port int) (*psutils.Process, error) {
   350  	postgresCmd := exec.Command(
   351  		filepaths.GetPostgresBinaryExecutablePath(),
   352  		// by this time, we are sure that the port if free to listen to
   353  		"-p", fmt.Sprint(port),
   354  		"-c", "listen_addresses=127.0.0.1",
   355  		// NOTE: If quoted, the application name includes the quotes. Worried about
   356  		// having spaces in the APPNAME, but leaving it unquoted since currently
   357  		// the APPNAME is hardcoded to be steampipe.
   358  		"-c", fmt.Sprintf("application_name=%s", constants.AppName),
   359  		"-c", fmt.Sprintf("cluster_name=%s", constants.AppName),
   360  
   361  		// log directory
   362  		"-c", fmt.Sprintf("log_directory=%s", filepaths.EnsureLogDir()),
   363  
   364  		// Data Directory
   365  		"-D", filepaths.GetDataLocation())
   366  
   367  	setupLogCollection(postgresCmd)
   368  
   369  	err := postgresCmd.Start()
   370  	if err != nil {
   371  		return nil, err
   372  	}
   373  
   374  	return psutils.NewProcess(int32(postgresCmd.Process.Pid))
   375  }
   376  
   377  func isValidDatabaseName(databaseName string) bool {
   378  	return databaseName[0] == '_' || (databaseName[0] >= 'a' && databaseName[0] <= 'z')
   379  }
   380  
   381  func initDatabase() error {
   382  	utils.LogTime("db_local.install.initDatabase start")
   383  	defer utils.LogTime("db_local.install.initDatabase end")
   384  
   385  	// initdb sometimes fail due to invalid locale settings, to avoid this we update
   386  	// the locale settings to use 'C' only for the initdb process to complete, and
   387  	// then return to the existing locale settings of the user.
   388  	// set LC_ALL env variable to override current locale settings
   389  	err := os.Setenv("LC_ALL", "C")
   390  	if err != nil {
   391  		log.Printf("[TRACE] failed to update locale settings:\n %s", err.Error())
   392  		return err
   393  	}
   394  
   395  	initDBExecutable := filepaths.GetInitDbBinaryExecutablePath()
   396  	initDbProcess := exec.Command(
   397  		initDBExecutable,
   398  		// Steampipe runs Postgres as a local, embedded database so trust local
   399  		// users to login without a password.
   400  		fmt.Sprintf("--auth=%s", "trust"),
   401  		// Ensure the name of the database superuser is consistent across installs.
   402  		// By default it would be based on the user running the install of this
   403  		// embedded database.
   404  		fmt.Sprintf("--username=%s", constants.DatabaseSuperUser),
   405  		// Postgres data should placed under the Steampipe install directory.
   406  		fmt.Sprintf("--pgdata=%s", filepaths.GetDataLocation()),
   407  		// Ensure the encoding is consistent across installs. By default it would
   408  		// be based on the system locale.
   409  		fmt.Sprintf("--encoding=%s", "UTF-8"),
   410  	)
   411  
   412  	log.Printf("[TRACE] initdb start: %s", initDbProcess.String())
   413  
   414  	output, runError := initDbProcess.CombinedOutput()
   415  	if runError != nil {
   416  		log.Printf("[TRACE] initdb failed:\n %s", string(output))
   417  		return runError
   418  	}
   419  
   420  	// unset LC_ALL to return to original locale settings
   421  	err = os.Unsetenv("LC_ALL")
   422  	if err != nil {
   423  		log.Printf("[TRACE] failed to return back to original locale settings:\n %s", err.Error())
   424  		return err
   425  	}
   426  
   427  	// intentionally overwriting existing pg_hba.conf with a minimal config which only allows root
   428  	// so that we can setup the database and permissions
   429  	return os.WriteFile(filepaths.GetPgHbaConfLocation(), []byte(constants.MinimalPgHbaContent), 0600)
   430  }
   431  
   432  func installDatabaseWithPermissions(ctx context.Context, databaseName string, rawClient *pgx.Conn) error {
   433  	utils.LogTime("db_local.install.installDatabaseWithPermissions start")
   434  	defer utils.LogTime("db_local.install.installDatabaseWithPermissions end")
   435  
   436  	log.Println("[TRACE] installing database with name", databaseName)
   437  
   438  	statements := []string{
   439  
   440  		// Lockdown all existing, and future, databases from use.
   441  		`revoke all on database postgres from public`,
   442  		`revoke all on database template1 from public`,
   443  
   444  		// Only the root user (who owns the postgres database) should be able to use
   445  		// or change it.
   446  		`revoke all privileges on schema public from public`,
   447  
   448  		// Create the steampipe database, used to hold all steampipe tables, views and data.
   449  		fmt.Sprintf(`create database %s`, databaseName),
   450  
   451  		// Restrict permissions from general users to the steampipe database. We add them
   452  		// back progressively to allow appropriate read only access.
   453  		fmt.Sprintf("revoke all on database %s from public", databaseName),
   454  
   455  		// The root user gets full rights to the steampipe database, ensuring we can actually
   456  		// configure and manage it properly.
   457  		fmt.Sprintf("grant all on database %s to root", databaseName),
   458  
   459  		// The root user gets a password which will be used later on to connect
   460  		fmt.Sprintf(`alter user root with password '%s'`, generatePassword()),
   461  
   462  		//
   463  		// PERMISSIONS
   464  		//
   465  		// References:
   466  		// * https://dba.stackexchange.com/questions/117109/how-to-manage-default-privileges-for-users-on-a-database-vs-schema/117661#117661
   467  		//
   468  
   469  		// Create a role to represent all steampipe_users in the database.
   470  		// Grants and permissions can be managed on this role independent
   471  		// of the actual users in the system, giving us flexibility.
   472  		fmt.Sprintf(`create role %s`, constants.DatabaseUsersRole),
   473  
   474  		// Allow the steampipe user access to the steampipe database only
   475  		fmt.Sprintf("grant connect on database %s to %s", databaseName, constants.DatabaseUsersRole),
   476  
   477  		// Create the steampipe user. By default they do not have superuser, createdb
   478  		// or createrole permissions.
   479  		fmt.Sprintf("create user %s", constants.DatabaseUser),
   480  
   481  		// Allow the steampipe user to manage temporary tables
   482  		fmt.Sprintf("grant temporary on database %s to %s", databaseName, constants.DatabaseUsersRole),
   483  
   484  		// No need to set a password to the 'steampipe' user
   485  		// The password gets set on every service start
   486  
   487  		// Allow steampipe the privileges of steampipe_users.
   488  		fmt.Sprintf("grant %s to %s", constants.DatabaseUsersRole, constants.DatabaseUser),
   489  	}
   490  	for _, statement := range statements {
   491  		// not logging here, since the password may get logged
   492  		// we don't want that
   493  		if _, err := rawClient.Exec(ctx, statement); err != nil {
   494  			return err
   495  		}
   496  	}
   497  	return writePgHbaContent(databaseName, constants.DatabaseUser)
   498  }
   499  
   500  func writePgHbaContent(databaseName string, username string) error {
   501  	content := fmt.Sprintf(constants.PgHbaTemplate, databaseName, username)
   502  	return os.WriteFile(filepaths.GetPgHbaConfLocation(), []byte(content), 0600)
   503  }
   504  
   505  func installForeignServer(ctx context.Context, rawClient *pgx.Conn) error {
   506  	utils.LogTime("db_local.installForeignServer start")
   507  	defer utils.LogTime("db_local.installForeignServer end")
   508  
   509  	statements := []string{
   510  		// Install the FDW. The name must match the binary file.
   511  		`drop extension if exists "steampipe_postgres_fdw" cascade`,
   512  		`create extension if not exists "steampipe_postgres_fdw"`,
   513  		// Use steampipe for the server name, it's simplest
   514  		`create server "steampipe" foreign data wrapper "steampipe_postgres_fdw"`,
   515  	}
   516  
   517  	for _, statement := range statements {
   518  		// NOTE: This may print a password to the log file, but it doesn't matter
   519  		// since the password is stored in a config file anyway.
   520  		log.Println("[TRACE] Install Foreign Server: ", statement)
   521  		if _, err := rawClient.Exec(ctx, statement); err != nil {
   522  			return err
   523  		}
   524  	}
   525  
   526  	return nil
   527  }
   528  
   529  func updateDownloadedBinarySignature() error {
   530  	utils.LogTime("db_local.updateDownloadedBinarySignature start")
   531  	defer utils.LogTime("db_local.updateDownloadedBinarySignature end")
   532  
   533  	versionInfo, err := versionfile.LoadDatabaseVersionFile()
   534  	if err != nil {
   535  		return err
   536  	}
   537  	installedSignature := fmt.Sprintf("%s|%s", versionInfo.EmbeddedDB.ImageDigest, versionInfo.FdwExtension.ImageDigest)
   538  	return os.WriteFile(filepaths.GetDBSignatureLocation(), []byte(installedSignature), 0755)
   539  }