github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/steampipeconfig/load_config.go (about)

     1  package steampipeconfig
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"log"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/gertd/go-pluralize"
    14  	"github.com/hashicorp/hcl/v2"
    15  	filehelpers "github.com/turbot/go-kit/files"
    16  	"github.com/turbot/go-kit/helpers"
    17  	"github.com/turbot/pipe-fittings/hclhelpers"
    18  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    19  	"github.com/turbot/steampipe/pkg/constants"
    20  	"github.com/turbot/steampipe/pkg/db/db_common"
    21  	"github.com/turbot/steampipe/pkg/error_helpers"
    22  	"github.com/turbot/steampipe/pkg/filepaths"
    23  	"github.com/turbot/steampipe/pkg/ociinstaller/versionfile"
    24  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    25  	"github.com/turbot/steampipe/pkg/steampipeconfig/options"
    26  	"github.com/turbot/steampipe/pkg/steampipeconfig/parse"
    27  	"github.com/turbot/steampipe/pkg/utils"
    28  )
    29  
    30  var GlobalConfig *SteampipeConfig
    31  var defaultConfigFileName = "default.spc"
    32  var defaultConfigSampleFileName = "default.spc.sample"
    33  
    34  // LoadSteampipeConfig loads the HCL connection config and workspace options
    35  func LoadSteampipeConfig(ctx context.Context, modLocation string, commandName string) (*SteampipeConfig, error_helpers.ErrorAndWarnings) {
    36  	utils.LogTime("steampipeconfig.LoadSteampipeConfig start")
    37  	defer utils.LogTime("steampipeconfig.LoadSteampipeConfig end")
    38  
    39  	log.Printf("[INFO] ensureDefaultConfigFile")
    40  
    41  	if err := ensureDefaultConfigFile(filepaths.EnsureConfigDir()); err != nil {
    42  		return nil, error_helpers.NewErrorsAndWarning(
    43  			sperr.WrapWithMessage(
    44  				err,
    45  				"could not create default config",
    46  			),
    47  		)
    48  	}
    49  	return loadSteampipeConfig(ctx, modLocation, commandName)
    50  }
    51  
    52  // LoadConnectionConfig loads the connection config but not the workspace options
    53  // this is called by the fdw
    54  func LoadConnectionConfig(ctx context.Context) (*SteampipeConfig, error_helpers.ErrorAndWarnings) {
    55  	return LoadSteampipeConfig(ctx, "", "")
    56  }
    57  
    58  func ensureDefaultConfigFile(configFolder string) error {
    59  	// get the filepaths
    60  	defaultConfigFile := filepath.Join(configFolder, defaultConfigFileName)
    61  	defaultConfigSampleFile := filepath.Join(configFolder, defaultConfigSampleFileName)
    62  
    63  	// check if sample and default files exist
    64  	sampleExists := filehelpers.FileExists(defaultConfigSampleFile)
    65  	defaultExists := filehelpers.FileExists(defaultConfigFile)
    66  
    67  	var sampleContent []byte
    68  	var sampleModTime, defaultModTime time.Time
    69  
    70  	// if the sample file exists, load content and read mod time
    71  	if sampleExists {
    72  		sampleStat, err := os.Stat(defaultConfigSampleFile)
    73  		if err != nil {
    74  			return err
    75  		}
    76  		sampleContent, err = os.ReadFile(defaultConfigSampleFile)
    77  		if err != nil {
    78  			return err
    79  		}
    80  		sampleModTime = sampleStat.ModTime()
    81  	}
    82  
    83  	// if the default file exists read mod time
    84  	if defaultExists {
    85  		// get the file infos
    86  		defaultStat, err := os.Stat(defaultConfigFile)
    87  		if err != nil {
    88  			return err
    89  		}
    90  		// get the file mod times
    91  		defaultModTime = defaultStat.ModTime()
    92  	}
    93  
    94  	// check if the files are modified
    95  
    96  	// has the user modified the default file?
    97  	userModifiedDefault := defaultModTime.IsZero() ||
    98  		defaultModTime.After(sampleModTime) && defaultModTime.Sub(sampleModTime) > 100*time.Millisecond
    99  
   100  	// has the DefaultConnectionConfigContent been updated since the sample file was last writtne
   101  	sampleModified := sampleModTime.IsZero() ||
   102  		!bytes.Equal([]byte(constants.DefaultConnectionConfigContent), sampleContent)
   103  
   104  	// case: if sample is modified - always write new sample file content
   105  	if sampleModified {
   106  		err := os.WriteFile(defaultConfigSampleFile, []byte(constants.DefaultConnectionConfigContent), 0755)
   107  		if err != nil {
   108  			return err
   109  		}
   110  	}
   111  
   112  	// case: if sample is modified but default is not modified - write the new default file content
   113  	if sampleModified && !userModifiedDefault {
   114  		err := os.WriteFile(defaultConfigFile, []byte(constants.DefaultConnectionConfigContent), 0755)
   115  		if err != nil {
   116  			return err
   117  		}
   118  	}
   119  	return nil
   120  }
   121  
   122  func loadSteampipeConfig(ctx context.Context, modLocation string, commandName string) (steampipeConfig *SteampipeConfig, errorsAndWarnings error_helpers.ErrorAndWarnings) {
   123  	utils.LogTime("steampipeconfig.loadSteampipeConfig start")
   124  	defer utils.LogTime("steampipeconfig.loadSteampipeConfig end")
   125  
   126  	errorsAndWarnings = error_helpers.NewErrorsAndWarning(nil)
   127  	defer func() {
   128  		if r := recover(); r != nil {
   129  			errorsAndWarnings = error_helpers.NewErrorsAndWarning(helpers.ToError(r))
   130  		}
   131  	}()
   132  
   133  	steampipeConfig = NewSteampipeConfig(commandName)
   134  
   135  	// load plugin versions
   136  	v, err := versionfile.LoadPluginVersionFile(ctx)
   137  	if err != nil {
   138  		return nil, error_helpers.NewErrorsAndWarning(err)
   139  	}
   140  
   141  	// add any "local" plugins (i.e. plugins installed under the 'local' folder) into the version file
   142  	ew := v.AddLocalPlugins(ctx)
   143  	if ew.GetError() != nil {
   144  		return nil, ew
   145  	}
   146  	steampipeConfig.PluginVersions = v.Plugins
   147  
   148  	// load config from the installation folder -  load all spc files from config directory
   149  	include := filehelpers.InclusionsFromExtensions(constants.ConnectionConfigExtensions)
   150  	loadOptions := &loadConfigOptions{include: include}
   151  	ew = loadConfig(ctx, filepaths.EnsureConfigDir(), steampipeConfig, loadOptions)
   152  	if ew.GetError() != nil {
   153  		return nil, ew
   154  	}
   155  	// merge the warning from this call
   156  	errorsAndWarnings.AddWarning(ew.Warnings...)
   157  
   158  	// now load config from the workspace folder, if provided
   159  	// this has precedence and so will overwrite any config which has already been set
   160  	// check workspace folder exists
   161  	if modLocation != "" {
   162  		if _, err := os.Stat(modLocation); os.IsNotExist(err) {
   163  			return nil, error_helpers.NewErrorsAndWarning(fmt.Errorf("mod location '%s' does not exist", modLocation))
   164  		}
   165  
   166  		// only include workspace.spc from workspace directory
   167  		include = filehelpers.InclusionsFromFiles([]string{filepaths.WorkspaceConfigFileName})
   168  		// update load options to ONLY allow terminal options
   169  		loadOptions = &loadConfigOptions{include: include, allowedOptions: []string{options.TerminalBlock}}
   170  		ew := loadConfig(ctx, modLocation, steampipeConfig, loadOptions)
   171  		if ew.GetError() != nil {
   172  			return nil, ew.WrapErrorWithMessage("failed to load workspace config")
   173  		}
   174  
   175  		// merge the warning from this call
   176  		errorsAndWarnings.AddWarning(ew.Warnings...)
   177  	}
   178  
   179  	// now set default options on all connections without options set
   180  	// this is needed as the connection config is also loaded by the FDW which has no access to viper
   181  	steampipeConfig.setDefaultConnectionOptions()
   182  
   183  	// now validate the config
   184  	warnings, errors := steampipeConfig.Validate()
   185  	logValidationResult(warnings, errors)
   186  
   187  	return steampipeConfig, errorsAndWarnings
   188  }
   189  
   190  func logValidationResult(warnings []string, errors []string) {
   191  	if len(warnings) > 0 {
   192  		error_helpers.ShowWarning(buildValidationLogString(warnings, "warning"))
   193  		log.Printf("[TRACE] %s", buildValidationLogString(warnings, "warning"))
   194  	}
   195  	if len(errors) > 0 {
   196  		error_helpers.ShowWarning(buildValidationLogString(errors, "error"))
   197  		log.Printf("[TRACE] %s", buildValidationLogString(errors, "error"))
   198  	}
   199  }
   200  
   201  func buildValidationLogString(items []string, validationType string) string {
   202  	count := len(items)
   203  	if count == 0 {
   204  		return ""
   205  	}
   206  	var str strings.Builder
   207  	str.WriteString(fmt.Sprintf("connection config has has %d validation %s:\n",
   208  		count,
   209  		pluralize.NewClient().Pluralize(validationType, count, false),
   210  	))
   211  	for _, w := range items {
   212  		str.WriteString(fmt.Sprintf("\t %s\n", w))
   213  	}
   214  	return str.String()
   215  }
   216  
   217  // load config from the given folder and update steampipeConfig
   218  // NOTE: this mutates steampipe config
   219  type loadConfigOptions struct {
   220  	include        []string
   221  	allowedOptions []string
   222  }
   223  
   224  func loadConfig(ctx context.Context, configFolder string, steampipeConfig *SteampipeConfig, opts *loadConfigOptions) error_helpers.ErrorAndWarnings {
   225  	log.Printf("[INFO] loadConfig is loading connection config")
   226  	// get all the config files in the directory
   227  	configPaths, err := filehelpers.ListFilesWithContext(ctx, configFolder, &filehelpers.ListOptions{
   228  		Flags:   filehelpers.FilesFlat,
   229  		Include: opts.include,
   230  	})
   231  
   232  	if err != nil {
   233  		log.Printf("[WARN] loadConfig: failed to get config file paths: %v\n", err)
   234  		return error_helpers.NewErrorsAndWarning(err)
   235  	}
   236  	if len(configPaths) == 0 {
   237  		return error_helpers.ErrorAndWarnings{}
   238  	}
   239  
   240  	fileData, diags := parse.LoadFileData(configPaths...)
   241  	if diags.HasErrors() {
   242  		log.Printf("[WARN] loadConfig: failed to load all config files: %v\n", err)
   243  		return error_helpers.DiagsToErrorsAndWarnings("Failed to load all config files", diags)
   244  	}
   245  
   246  	body, diags := parse.ParseHclFiles(fileData)
   247  	if diags.HasErrors() {
   248  		return error_helpers.DiagsToErrorsAndWarnings("Failed to load all config files", diags)
   249  	}
   250  
   251  	// do a partial decode
   252  	content, moreDiags := body.Content(parse.ConfigBlockSchema)
   253  	if moreDiags.HasErrors() {
   254  		diags = append(diags, moreDiags...)
   255  		return error_helpers.DiagsToErrorsAndWarnings("Failed to load config", diags)
   256  	}
   257  
   258  	// store block types which we have found in this folder - each is only allowed once
   259  	// NOTE this is different to merging options with options already populated in the passed-in steampipe config
   260  	// this is valid because the same block may be defined in the config folder and the workspace
   261  	optionBlockMap := map[string]bool{}
   262  
   263  	for _, block := range content.Blocks {
   264  		switch block.Type {
   265  
   266  		case modconfig.BlockTypePlugin:
   267  			plugin, moreDiags := parse.DecodePlugin(block)
   268  			diags = append(diags, moreDiags...)
   269  			if moreDiags.HasErrors() {
   270  				continue
   271  			}
   272  			// add plugin to steampipeConfig
   273  			// NOTE: this errors if there is a plugin block with a duplicate label
   274  			if err := steampipeConfig.addPlugin(plugin); err != nil {
   275  				return error_helpers.NewErrorsAndWarning(err)
   276  			}
   277  
   278  		case modconfig.BlockTypeConnection:
   279  			connection, moreDiags := parse.DecodeConnection(block)
   280  			diags = append(diags, moreDiags...)
   281  			if moreDiags.HasErrors() {
   282  				continue
   283  			}
   284  			if existingConnection, alreadyThere := steampipeConfig.Connections[connection.Name]; alreadyThere {
   285  				err := getDuplicateConnectionError(existingConnection, connection)
   286  				return error_helpers.NewErrorsAndWarning(err)
   287  			}
   288  			if ok, errorMessage := db_common.IsSchemaNameValid(connection.Name); !ok {
   289  				return error_helpers.NewErrorsAndWarning(sperr.New("invalid connection name: '%s' in '%s'. %s ", connection.Name, block.TypeRange.Filename, errorMessage))
   290  			}
   291  			steampipeConfig.Connections[connection.Name] = connection
   292  
   293  		case modconfig.BlockTypeOptions:
   294  			// check this options type is permitted based on the options passed in
   295  			if err := optionsBlockPermitted(block, optionBlockMap, opts); err != nil {
   296  				return error_helpers.NewErrorsAndWarning(err)
   297  			}
   298  			opts, moreDiags := parse.DecodeOptions(block)
   299  			if moreDiags.HasErrors() {
   300  				diags = append(diags, moreDiags...)
   301  				continue
   302  			}
   303  			// set options on steampipe config
   304  			// if options are already set, this will merge the new options over the top of the existing options
   305  			// i.e. new options have precedence
   306  			e := steampipeConfig.SetOptions(opts)
   307  			if e.GetError() != nil {
   308  				// we should never get an error here, since SetOptions
   309  				// only sets warnings
   310  				// putting this here only for good-practice
   311  				return e
   312  			}
   313  			if len(e.Warnings) > 0 {
   314  				for _, warning := range e.Warnings {
   315  					diags = append(diags, &hcl.Diagnostic{
   316  						Severity: hcl.DiagWarning,
   317  						Summary:  warning,
   318  						Subject:  hclhelpers.BlockRangePointer(block),
   319  					})
   320  				}
   321  			}
   322  		}
   323  	}
   324  
   325  	if diags.HasErrors() {
   326  		return error_helpers.DiagsToErrorsAndWarnings("Failed to load config", diags)
   327  	}
   328  
   329  	res := error_helpers.DiagsToErrorsAndWarnings("", diags)
   330  
   331  	log.Printf("[INFO] loadConfig calling initializePlugins")
   332  
   333  	// resolve the plugins for each connection and create default plugin config
   334  	// for all plugins mentioned in connection config which have no explicit config
   335  	steampipeConfig.initializePlugins()
   336  
   337  	return res
   338  }
   339  
   340  func getDuplicateConnectionError(existingConnection, newConnection *modconfig.Connection) error {
   341  	return sperr.New("duplicate connection name: '%s'\n\t(%s:%d)\n\t(%s:%d)",
   342  		existingConnection.Name, existingConnection.DeclRange.Filename, existingConnection.DeclRange.Start.Line,
   343  		newConnection.DeclRange.Filename, newConnection.DeclRange.Start.Line)
   344  }
   345  
   346  func optionsBlockPermitted(block *hcl.Block, blockMap map[string]bool, opts *loadConfigOptions) error {
   347  	// keep track of duplicate block types
   348  	blockType := block.Labels[0]
   349  	if _, ok := blockMap[blockType]; ok {
   350  		return fmt.Errorf("multiple instances of '%s' options block", blockType)
   351  	}
   352  	blockMap[blockType] = true
   353  	permitted := len(opts.allowedOptions) == 0 ||
   354  		helpers.StringSliceContains(opts.allowedOptions, blockType)
   355  
   356  	if !permitted {
   357  		return fmt.Errorf("'%s' options block is not permitted", blockType)
   358  	}
   359  	return nil
   360  }