github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/workspace/workspace.go (about) 1 package workspace 2 3 import ( 4 "bufio" 5 "context" 6 "errors" 7 "fmt" 8 "golang.org/x/exp/maps" 9 "log" 10 "os" 11 "path/filepath" 12 "strings" 13 "sync" 14 15 "github.com/fatih/color" 16 "github.com/fsnotify/fsnotify" 17 "github.com/spf13/cobra" 18 "github.com/spf13/viper" 19 filehelpers "github.com/turbot/go-kit/files" 20 "github.com/turbot/go-kit/filewatcher" 21 "github.com/turbot/steampipe-plugin-sdk/v5/sperr" 22 "github.com/turbot/steampipe/pkg/cmdconfig" 23 "github.com/turbot/steampipe/pkg/constants" 24 "github.com/turbot/steampipe/pkg/dashboard/dashboardevents" 25 "github.com/turbot/steampipe/pkg/db/db_common" 26 "github.com/turbot/steampipe/pkg/error_helpers" 27 "github.com/turbot/steampipe/pkg/filepaths" 28 "github.com/turbot/steampipe/pkg/modinstaller" 29 "github.com/turbot/steampipe/pkg/statushooks" 30 "github.com/turbot/steampipe/pkg/steampipeconfig" 31 "github.com/turbot/steampipe/pkg/steampipeconfig/modconfig" 32 "github.com/turbot/steampipe/pkg/steampipeconfig/parse" 33 "github.com/turbot/steampipe/pkg/steampipeconfig/versionmap" 34 "github.com/turbot/steampipe/pkg/task" 35 "github.com/turbot/steampipe/pkg/utils" 36 ) 37 38 type Workspace struct { 39 Path string 40 ModInstallationPath string 41 Mod *modconfig.Mod 42 43 Mods map[string]*modconfig.Mod 44 // the input variables used in the parse 45 VariableValues map[string]string 46 CloudMetadata *steampipeconfig.CloudMetadata 47 48 // source snapshot paths 49 // if this is set, no other mod resources are loaded and 50 // the ResourceMaps returned by GetModResources will contain only the snapshots 51 SourceSnapshots []string 52 53 watcher *filewatcher.FileWatcher 54 loadLock sync.Mutex 55 exclusions []string 56 modFilePath string 57 // should we load/watch files recursively 58 listFlag filehelpers.ListFlag 59 fileWatcherErrorHandler func(context.Context, error) 60 watcherError error 61 // event handlers 62 dashboardEventHandlers []dashboardevents.DashboardEventHandler 63 // callback function called when there is a file watcher event 64 onFileWatcherEventMessages func() 65 loadPseudoResources bool 66 // channel used to send dashboard events to the handleDashboardEvent goroutine 67 dashboardEventChan chan dashboardevents.DashboardEvent 68 } 69 70 // LoadWorkspaceVars creates a Workspace and loads the variables 71 func LoadWorkspaceVars(ctx context.Context) (*Workspace, *modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) { 72 log.Printf("[INFO] LoadWorkspaceVars: creating workspace, loading variable and resolving variable values") 73 workspacePath := viper.GetString(constants.ArgModLocation) 74 75 utils.LogTime("workspace.Load start") 76 defer utils.LogTime("workspace.Load end") 77 78 workspace, err := createShellWorkspace(workspacePath) 79 if err != nil { 80 log.Printf("[INFO] createShellWorkspace failed %s", err.Error()) 81 return nil, nil, error_helpers.NewErrorsAndWarning(err) 82 } 83 84 // check if your workspace path is home dir and if modfile exists - if yes then warn and ask user to continue or not 85 if err := HomeDirectoryModfileCheck(ctx, workspacePath); err != nil { 86 log.Printf("[INFO] HomeDirectoryModfileCheck failed %s", err.Error()) 87 return nil, nil, error_helpers.NewErrorsAndWarning(err) 88 } 89 inputVariables, errorsAndWarnings := workspace.PopulateVariables(ctx) 90 if errorsAndWarnings.Error != nil { 91 log.Printf("[WARN] PopulateVariables failed %s", errorsAndWarnings.Error.Error()) 92 return nil, nil, errorsAndWarnings 93 } 94 95 log.Printf("[INFO] LoadWorkspaceVars succededed - got values for vars: %s", strings.Join(maps.Keys(workspace.VariableValues), ", ")) 96 97 return workspace, inputVariables, errorsAndWarnings 98 } 99 100 // LoadVariables creates a Workspace and uses it to load all variables, ignoring any value resolution errors 101 // this is use for the variable list command 102 func LoadVariables(ctx context.Context, workspacePath string) ([]*modconfig.Variable, error_helpers.ErrorAndWarnings) { 103 utils.LogTime("workspace.LoadVariables start") 104 defer utils.LogTime("workspace.LoadVariables end") 105 106 // create shell workspace 107 workspace, err := createShellWorkspace(workspacePath) 108 if err != nil { 109 return nil, error_helpers.NewErrorsAndWarning(err) 110 } 111 112 // resolve variables values, WITHOUT validating missing vars 113 validateMissing := false 114 variableMap, errorAndWarnings := workspace.getInputVariables(ctx, validateMissing) 115 if errorAndWarnings.Error != nil { 116 return nil, errorAndWarnings 117 } 118 119 // convert into a sorted array 120 return variableMap.ToArray(), errorAndWarnings 121 } 122 123 func createShellWorkspace(workspacePath string) (*Workspace, error) { 124 // create shell workspace 125 workspace := &Workspace{ 126 Path: workspacePath, 127 VariableValues: make(map[string]string), 128 } 129 130 // check whether the workspace contains a modfile 131 // this will determine whether we load files recursively, and create pseudo resources for sql files 132 workspace.setModfileExists() 133 134 // load the .steampipe ignore file 135 if err := workspace.loadExclusions(); err != nil { 136 return nil, err 137 } 138 139 return workspace, nil 140 } 141 142 // LoadResourceNames builds lists of all workspace resource names 143 func LoadResourceNames(ctx context.Context, workspacePath string) (*modconfig.WorkspaceResources, error) { 144 utils.LogTime("workspace.LoadResourceNames start") 145 defer utils.LogTime("workspace.LoadResourceNames end") 146 147 // create shell workspace 148 workspace := &Workspace{ 149 Path: workspacePath, 150 } 151 152 // determine whether to load files recursively or just from the top level folder 153 workspace.setModfileExists() 154 155 // load the .steampipe ignore file 156 if err := workspace.loadExclusions(); err != nil { 157 return nil, err 158 } 159 160 return workspace.loadWorkspaceResourceName(ctx) 161 } 162 163 func (w *Workspace) SetupWatcher(ctx context.Context, client db_common.Client, errorHandler func(context.Context, error)) error { 164 watcherOptions := &filewatcher.WatcherOptions{ 165 Directories: []string{w.Path}, 166 Include: filehelpers.InclusionsFromExtensions(steampipeconfig.GetModFileExtensions()), 167 Exclude: w.exclusions, 168 ListFlag: w.listFlag, 169 EventMask: fsnotify.Create | fsnotify.Remove | fsnotify.Rename | fsnotify.Write, 170 // we should look into passing the callback function into the underlying watcher 171 // we need to analyze the kind of errors that come out from the watcher and 172 // decide how to handle them 173 // OnError: errCallback, 174 OnChange: func(events []fsnotify.Event) { 175 w.handleFileWatcherEvent(ctx, client, events) 176 }, 177 } 178 watcher, err := filewatcher.NewWatcher(watcherOptions) 179 if err != nil { 180 return err 181 } 182 w.watcher = watcher 183 // start the watcher 184 watcher.Start() 185 186 // set the file watcher error handler, which will get called when there are parsing errors 187 // after a file watcher event 188 w.fileWatcherErrorHandler = errorHandler 189 if w.fileWatcherErrorHandler == nil { 190 w.fileWatcherErrorHandler = func(ctx context.Context, err error) { 191 fmt.Println() 192 error_helpers.ShowErrorWithMessage(ctx, err, "failed to reload mod from file watcher") 193 } 194 } 195 196 return nil 197 } 198 199 func (w *Workspace) SetOnFileWatcherEventMessages(f func()) { 200 w.onFileWatcherEventMessages = f 201 } 202 203 // access functions 204 // NOTE: all access functions lock 'loadLock' - this is to avoid conflicts with the file watcher 205 206 func (w *Workspace) Close() { 207 if w.watcher != nil { 208 w.watcher.Close() 209 } 210 if ch := w.dashboardEventChan; ch != nil { 211 // NOTE: set nil first 212 w.dashboardEventChan = nil 213 log.Printf("[TRACE] closing dashboardEventChan") 214 close(ch) 215 } 216 } 217 218 func (w *Workspace) ModfileExists() bool { 219 return len(w.modFilePath) > 0 220 } 221 222 // check whether the workspace contains a modfile 223 // this will determine whether we load files recursively, and create pseudo resources for sql files 224 func (w *Workspace) setModfileExists() { 225 modFile, err := FindModFilePath(w.Path) 226 modFileExists := err != ErrorNoModDefinition 227 228 if modFileExists { 229 log.Printf("[TRACE] modfile exists in workspace folder - creating pseudo-resources and loading files recursively ") 230 // only load/watch recursively if a mod sp file exists in the workspace folder 231 w.listFlag = filehelpers.FilesRecursive 232 w.loadPseudoResources = true 233 w.modFilePath = modFile 234 235 // also set it in the viper config, so that it is available to whoever is using it 236 viper.Set(constants.ArgModLocation, filepath.Dir(modFile)) 237 w.Path = filepath.Dir(modFile) 238 } else { 239 log.Printf("[TRACE] no modfile exists in workspace folder - NOT creating pseudoresources and only loading resource files from top level folder") 240 w.listFlag = filehelpers.Files 241 w.loadPseudoResources = false 242 } 243 } 244 245 // FindModFilePath looks in the current folder for mod.sp 246 // if not found it looks in the parent folder - right up to the root 247 func FindModFilePath(folder string) (string, error) { 248 folder, err := filepath.Abs(folder) 249 if err != nil { 250 return "", err 251 } 252 for _, modFilePath := range filepaths.ModFilePaths(folder) { 253 _, err = os.Stat(modFilePath) 254 if err == nil { 255 // found the modfile 256 return modFilePath, nil 257 } 258 } 259 260 // if the file wasn't found, search in the parent directory 261 parent := filepath.Dir(folder) 262 if folder == parent { 263 // this typically means that we are already in the root directory 264 return "", ErrorNoModDefinition 265 } 266 return FindModFilePath(filepath.Dir(folder)) 267 } 268 269 func HomeDirectoryModfileCheck(ctx context.Context, workspacePath string) error { 270 // bypass all the checks if ConfigKeyBypassHomeDirModfileWarning is set - it means home dir modfile check 271 // has already happened before 272 if viper.GetBool(constants.ConfigKeyBypassHomeDirModfileWarning) { 273 return nil 274 } 275 // get the cmd and home dir 276 cmd := viper.Get(constants.ConfigKeyActiveCommand).(*cobra.Command) 277 home, _ := os.UserHomeDir() 278 279 var modFileExists bool 280 for _, modFilePath := range filepaths.ModFilePaths(workspacePath) { 281 if _, err := os.Stat(modFilePath); err == nil { 282 modFileExists = true 283 } 284 } 285 286 // check if your workspace path is home dir and if modfile exists 287 if workspacePath == home && modFileExists { 288 // for interactive query - ask for confirmation to continue 289 if cmd.Name() == "query" && viper.GetBool(constants.ConfigKeyInteractive) { 290 confirm, err := utils.UserConfirmation(ctx, fmt.Sprintf("%s: You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nDo you still want to continue? (y/n)", color.YellowString("Warning"))) 291 if err != nil { 292 return err 293 } 294 if !confirm { 295 return sperr.New("failed to load workspace: execution cancelled") 296 } 297 return nil 298 } 299 300 // for batch query mode - if output is table, just warn 301 if task.IsBatchQueryCmd(cmd, viper.GetStringSlice(constants.ConfigKeyActiveCommandArgs)) && cmdconfig.Viper().GetString(constants.ArgOutput) == constants.OutputFormatTable { 302 error_helpers.ShowWarning("You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nHit Ctrl+C to stop.\n") 303 return nil 304 } 305 306 // for other cmds - if home dir has modfile, just warn 307 error_helpers.ShowWarning("You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nHit Ctrl+C to stop.\n") 308 } 309 310 return nil 311 } 312 313 func (w *Workspace) LoadWorkspaceMod(ctx context.Context, inputVariables *modconfig.ModVariableMap) error_helpers.ErrorAndWarnings { 314 var errorsAndWarnings = error_helpers.ErrorAndWarnings{} 315 316 // build run context which we use to load the workspace 317 parseCtx, err := w.getParseContext(ctx, inputVariables) 318 if err != nil { 319 errorsAndWarnings.Error = err 320 return errorsAndWarnings 321 } 322 323 // do not reload variables as we already have them 324 parseCtx.BlockTypeExclusions = []string{modconfig.BlockTypeVariable} 325 326 // load the workspace mod 327 m, otherErrorAndWarning := steampipeconfig.LoadMod(ctx, w.Path, parseCtx) 328 errorsAndWarnings.Merge(otherErrorAndWarning) 329 if errorsAndWarnings.Error != nil { 330 return errorsAndWarnings 331 } 332 333 // now set workspace properties 334 // populate the mod references map references 335 m.ResourceMaps.PopulateReferences() 336 // set the mod 337 w.Mod = m 338 // set the child mods 339 w.Mods = parseCtx.GetTopLevelDependencyMods() 340 // NOTE: add in the workspace mod to the dependency mods 341 w.Mods[w.Mod.Name()] = w.Mod 342 343 // verify all runtime dependencies can be resolved 344 errorsAndWarnings.Error = w.verifyResourceRuntimeDependencies() 345 return errorsAndWarnings 346 } 347 348 func (w *Workspace) PopulateVariables(ctx context.Context) (*modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) { 349 log.Printf("[TRACE] Workspace.PopulateVariables") 350 // resolve values of all input variables 351 // we WILL validate missing variables when loading 352 validateMissing := true 353 inputVariables, errorsAndWarnings := w.getInputVariables(ctx, validateMissing) 354 if errorsAndWarnings.Error != nil { 355 // so there was an error - was it missing variables error 356 var missingVariablesError steampipeconfig.MissingVariableError 357 ok := errors.As(errorsAndWarnings.GetError(), &missingVariablesError) 358 // if there was an error which is NOT a MissingVariableError, return it 359 if !ok { 360 return nil, errorsAndWarnings 361 } 362 // if there are missing transitive dependency variables, fail as we do not prompt for these 363 if len(missingVariablesError.MissingTransitiveVariables) > 0 { 364 return nil, errorsAndWarnings 365 } 366 // if interactive input is disabled, return the missing variables error 367 if !viper.GetBool(constants.ArgInput) { 368 return nil, error_helpers.NewErrorsAndWarning(missingVariablesError) 369 } 370 // so we have missing variables - prompt for them 371 // first hide spinner if it is there 372 statushooks.Done(ctx) 373 if err := promptForMissingVariables(ctx, missingVariablesError.MissingVariables, w.Path); err != nil { 374 log.Printf("[TRACE] Interactive variables prompting returned error %v", err) 375 return nil, error_helpers.NewErrorsAndWarning(err) 376 } 377 378 // now try to load vars again 379 inputVariables, errorsAndWarnings = w.getInputVariables(ctx, validateMissing) 380 if errorsAndWarnings.Error != nil { 381 return nil, errorsAndWarnings 382 } 383 384 } 385 // populate the parsed variable values 386 w.VariableValues, errorsAndWarnings.Error = inputVariables.GetPublicVariableValues() 387 388 return inputVariables, errorsAndWarnings 389 } 390 391 func (w *Workspace) getInputVariables(ctx context.Context, validateMissing bool) (*modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) { 392 log.Printf("[TRACE] Workspace.getInputVariables") 393 // build a run context just to use to load variable definitions 394 variablesParseCtx, err := w.getParseContext(ctx, nil) 395 if err != nil { 396 return nil, error_helpers.NewErrorsAndWarning(err) 397 } 398 399 // load variable definitions 400 variableMap, err := steampipeconfig.LoadVariableDefinitions(ctx, w.Path, variablesParseCtx) 401 if err != nil { 402 return nil, error_helpers.NewErrorsAndWarning(err) 403 } 404 405 log.Printf("[INFO] loaded variable definitions: %s", variableMap) 406 407 // get the values 408 return steampipeconfig.GetVariableValues(variablesParseCtx, variableMap, validateMissing) 409 } 410 411 // build options used to load workspace 412 // set flags to create pseudo resources and a default mod if needed 413 func (w *Workspace) getParseContext(ctx context.Context, variables *modconfig.ModVariableMap) (*parse.ModParseContext, error) { 414 parseFlag := parse.CreateDefaultMod 415 if w.loadPseudoResources { 416 parseFlag |= parse.CreatePseudoResources 417 } 418 workspaceLock, err := w.loadWorkspaceLock(ctx) 419 if err != nil { 420 return nil, err 421 } 422 parseCtx := parse.NewModParseContext( 423 workspaceLock, 424 w.Path, 425 parseFlag, 426 &filehelpers.ListOptions{ 427 // listFlag specifies whether to load files recursively 428 Flags: w.listFlag, 429 Exclude: w.exclusions, 430 // only load .sp files 431 Include: filehelpers.InclusionsFromExtensions(constants.ModDataExtensions), 432 }) 433 434 // add any evaluated variables to the context 435 if variables != nil { 436 parseCtx.AddInputVariableValues(variables) 437 } 438 439 return parseCtx, nil 440 } 441 442 // load the workspace lock, migrating it if necessary 443 func (w *Workspace) loadWorkspaceLock(ctx context.Context) (*versionmap.WorkspaceLock, error) { 444 workspaceLock, err := versionmap.LoadWorkspaceLock(ctx, w.Path) 445 if err != nil { 446 return nil, fmt.Errorf("failed to load installation cache from %s: %s", w.Path, err) 447 } 448 449 // if this is the old format, migrate by reinstalling dependencies 450 if workspaceLock.StructVersion() != versionmap.WorkspaceLockStructVersion { 451 opts := &modinstaller.InstallOpts{WorkspaceMod: w.Mod} 452 installData, err := modinstaller.InstallWorkspaceDependencies(ctx, opts) 453 if err != nil { 454 return nil, err 455 } 456 workspaceLock = installData.NewLock 457 } 458 return workspaceLock, nil 459 } 460 461 func (w *Workspace) loadExclusions() error { 462 // default to ignoring hidden files and folders 463 w.exclusions = []string{ 464 // ignore any hidden folder 465 fmt.Sprintf("%s/.*", w.Path), 466 // and sub files/folders of hidden folders 467 fmt.Sprintf("%s/.*/**", w.Path), 468 } 469 470 ignorePath := filepath.Join(w.Path, filepaths.WorkspaceIgnoreFile) 471 file, err := os.Open(ignorePath) 472 if err != nil { 473 // if file does not exist, just return 474 if os.IsNotExist(err) { 475 return nil 476 } 477 return err 478 } 479 defer file.Close() 480 481 scanner := bufio.NewScanner(file) 482 for scanner.Scan() { 483 line := scanner.Text() 484 if len(strings.TrimSpace(line)) != 0 && !strings.HasPrefix(line, "#") { 485 // add exclusion to the workspace path (to ensure relative patterns work) 486 absoluteExclusion := filepath.Join(w.Path, line) 487 w.exclusions = append(w.exclusions, absoluteExclusion) 488 } 489 } 490 491 if err = scanner.Err(); err != nil { 492 return err 493 } 494 495 return nil 496 } 497 498 func (w *Workspace) loadWorkspaceResourceName(ctx context.Context) (*modconfig.WorkspaceResources, error) { 499 // build options used to load workspace 500 parseCtx, err := w.getParseContext(ctx, nil) 501 if err != nil { 502 return nil, err 503 } 504 505 workspaceResourceNames, err := steampipeconfig.LoadModResourceNames(ctx, w.Mod, parseCtx) 506 if err != nil { 507 return nil, err 508 } 509 510 return workspaceResourceNames, nil 511 } 512 513 func (w *Workspace) verifyResourceRuntimeDependencies() error { 514 for _, d := range w.Mod.ResourceMaps.Dashboards { 515 if err := d.ValidateRuntimeDependencies(w); err != nil { 516 return err 517 } 518 } 519 return nil 520 }