go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/cli/shell/shell.go (about) 1 // Copyright (c) Mondoo, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 package shell 5 6 import ( 7 "fmt" 8 "io" 9 "os" 10 "os/signal" 11 "path" 12 "regexp" 13 "runtime" 14 "sort" 15 "strings" 16 "sync" 17 18 prompt "github.com/c-bata/go-prompt" 19 "github.com/mitchellh/go-homedir" 20 "github.com/rs/zerolog/log" 21 "go.mondoo.com/cnquery" 22 "go.mondoo.com/cnquery/cli/theme" 23 "go.mondoo.com/cnquery/llx" 24 "go.mondoo.com/cnquery/mql" 25 "go.mondoo.com/cnquery/mqlc" 26 "go.mondoo.com/cnquery/mqlc/parser" 27 "go.mondoo.com/cnquery/providers" 28 "go.mondoo.com/cnquery/providers-sdk/v1/resources" 29 "go.mondoo.com/cnquery/providers-sdk/v1/upstream" 30 "go.mondoo.com/cnquery/types" 31 "go.mondoo.com/cnquery/utils/sortx" 32 "go.mondoo.com/cnquery/utils/stringx" 33 ) 34 35 type ShellOption func(c *Shell) 36 37 func WithOnCloseListener(onCloseHandler func()) ShellOption { 38 return func(t *Shell) { 39 t.onCloseHandler = onCloseHandler 40 } 41 } 42 43 func WithUpstreamConfig(c *upstream.UpstreamConfig) ShellOption { 44 return func(t *Shell) { 45 if x, ok := t.Runtime.(*providers.Runtime); ok { 46 x.UpstreamConfig = c 47 } 48 } 49 } 50 51 func WithFeatures(features cnquery.Features) ShellOption { 52 return func(t *Shell) { 53 t.features = features 54 } 55 } 56 57 func WithOutput(writer io.Writer) ShellOption { 58 return func(t *Shell) { 59 t.out = writer 60 } 61 } 62 63 func WithTheme(theme *theme.Theme) ShellOption { 64 return func(t *Shell) { 65 t.Theme = theme 66 } 67 } 68 69 // Shell is the interactive explorer 70 type Shell struct { 71 Runtime llx.Runtime 72 Theme *theme.Theme 73 History []string 74 HistoryPath string 75 MaxLines int 76 77 completer *Completer 78 alreadyPrinted *sync.Map 79 out io.Writer 80 features cnquery.Features 81 onCloseHandler func() 82 query string 83 isMultiline bool 84 multilineIndent int 85 } 86 87 // New creates a new Shell 88 func New(runtime llx.Runtime, opts ...ShellOption) (*Shell, error) { 89 res := Shell{ 90 alreadyPrinted: &sync.Map{}, 91 out: os.Stdout, 92 features: cnquery.DefaultFeatures, 93 MaxLines: 1024, 94 Runtime: runtime, 95 } 96 97 for i := range opts { 98 opts[i](&res) 99 } 100 101 if res.Theme == nil { 102 res.Theme = theme.DefaultTheme 103 } 104 105 res.completer = NewCompleter(runtime.Schema(), res.features, func() string { 106 return res.query 107 }) 108 109 return &res, nil 110 } 111 112 func (s *Shell) printWelcome() { 113 if s.Theme.Welcome == "" { 114 return 115 } 116 117 fmt.Fprintln(s.out, s.Theme.Welcome) 118 } 119 120 func (s *Shell) print(msg string) { 121 if msg == "" { 122 return 123 } 124 125 if _, ok := s.alreadyPrinted.Load(msg); !ok { 126 s.alreadyPrinted.Store(msg, struct{}{}) 127 fmt.Fprintln(s.out, msg) 128 } 129 } 130 131 // reset the cache that deduplicates messages on the shell 132 func (s *Shell) resetPrintCache() { 133 s.alreadyPrinted = &sync.Map{} 134 } 135 136 // RunInteractive starts a REPL loop 137 func (s *Shell) RunInteractive(cmd string) { 138 s.backupTerminalSettings() 139 s.printWelcome() 140 141 s.History = []string{} 142 homeDir, _ := homedir.Dir() 143 s.HistoryPath = path.Join(homeDir, ".mondoo_history") 144 if rawHistory, err := os.ReadFile(s.HistoryPath); err == nil { 145 s.History = strings.Split(string(rawHistory), "\n") 146 } 147 148 if cmd != "" { 149 s.ExecCmd(cmd) 150 s.History = append(s.History, cmd) 151 } 152 153 completer := s.completer.CompletePrompt 154 // NOTE: this is an issue with windows cmd and powershell prompt, since this is not reliable we deactivate the 155 // autocompletion, see https://github.com/c-bata/go-prompt/issues/209 156 if runtime.GOOS == "windows" { 157 completer = func(doc prompt.Document) []prompt.Suggest { 158 return nil 159 } 160 } 161 162 p := prompt.New( 163 s.ExecCmd, 164 completer, 165 prompt.OptionPrefix(s.Theme.Prefix), 166 prompt.OptionPrefixTextColor(s.Theme.PromptColors.PrefixTextColor), 167 prompt.OptionLivePrefix(s.changeLivePrefix), 168 prompt.OptionPreviewSuggestionTextColor(s.Theme.PromptColors.PreviewSuggestionTextColor), 169 prompt.OptionPreviewSuggestionBGColor(s.Theme.PromptColors.PreviewSuggestionBGColor), 170 prompt.OptionSelectedSuggestionTextColor(s.Theme.PromptColors.SelectedSuggestionTextColor), 171 prompt.OptionSelectedSuggestionBGColor(s.Theme.PromptColors.SelectedSuggestionBGColor), 172 prompt.OptionSuggestionTextColor(s.Theme.PromptColors.SuggestionTextColor), 173 prompt.OptionSuggestionBGColor(s.Theme.PromptColors.SuggestionBGColor), 174 prompt.OptionDescriptionTextColor(s.Theme.PromptColors.DescriptionTextColor), 175 prompt.OptionDescriptionBGColor(s.Theme.PromptColors.DescriptionBGColor), 176 prompt.OptionSelectedDescriptionTextColor(s.Theme.PromptColors.SelectedDescriptionTextColor), 177 prompt.OptionSelectedDescriptionBGColor(s.Theme.PromptColors.SelectedDescriptionBGColor), 178 prompt.OptionScrollbarBGColor(s.Theme.PromptColors.ScrollbarBGColor), 179 prompt.OptionScrollbarThumbColor(s.Theme.PromptColors.ScrollbarThumbColor), 180 prompt.OptionAddKeyBind( 181 prompt.KeyBind{ 182 Key: prompt.ControlC, 183 Fn: func(buf *prompt.Buffer) { 184 s.print("") 185 }, 186 }, 187 prompt.KeyBind{ 188 Key: prompt.ControlD, 189 Fn: func(buf *prompt.Buffer) { 190 s.handleExit() 191 }, 192 }, 193 prompt.KeyBind{ 194 Key: prompt.ControlZ, 195 Fn: func(buf *prompt.Buffer) { 196 s.suspend() 197 }, 198 }, 199 ), 200 prompt.OptionHistory(s.History), 201 prompt.OptionCompletionWordSeparator(completerSeparator), 202 ) 203 204 p.Run() 205 206 s.handleExit() 207 } 208 209 var helpResource = regexp.MustCompile(`help\s(.*)`) 210 211 func (s *Shell) ExecCmd(cmd string) { 212 switch { 213 case s.isMultiline: 214 s.execQuery(cmd) 215 case cmd == "": 216 return 217 case cmd == "exit": 218 s.handleExit() 219 return 220 case cmd == "clear": 221 // clear screen 222 s.out.Write([]byte{0x1b, '[', '2', 'J'}) 223 // move cursor to home 224 s.out.Write([]byte{0x1b, '[', 'H'}) 225 return 226 case cmd == "help": 227 s.listAvailableResources() 228 return 229 case cmd == "nyanya": 230 size := prompt.NewStandardInputParser().GetWinSize() 231 nyago(int(size.Col), int(size.Row)) 232 return 233 case helpResource.MatchString(cmd): 234 s.listFilteredResources(cmd) 235 return 236 default: 237 s.execQuery(cmd) 238 } 239 } 240 241 func (s *Shell) execQuery(cmd string) { 242 s.query += " " + cmd 243 244 // Note: we could optimize the call structure here, since compile 245 // will end up being called twice. However, since we are talking about 246 // the shell and we only deal with one query at a time, with the 247 // compiler being rather fast, the additional time is negligible 248 // and may not be worth coding around. 249 code, err := mqlc.Compile(s.query, nil, mqlc.NewConfig(s.Runtime.Schema(), s.features)) 250 if err != nil { 251 if e, ok := err.(*parser.ErrIncomplete); ok { 252 s.isMultiline = true 253 s.multilineIndent = e.Indent 254 return 255 } 256 } 257 258 // at this point we know this is not a multi-line call anymore 259 260 cleanCommand := s.query 261 if code != nil { 262 cleanCommand = code.Source 263 } 264 265 if len(s.History) == 0 || s.History[len(s.History)-1] != cleanCommand { 266 s.History = append(s.History, cleanCommand) 267 } 268 269 code, res, err := s.RunOnce(s.query) 270 // we can safely ignore err != nil, since RunOnce handles most of the printing we need 271 if err == nil { 272 s.PrintResults(code, res) 273 } 274 275 s.isMultiline = false 276 s.query = "" 277 } 278 279 func (s *Shell) changeLivePrefix() (string, bool) { 280 if s.isMultiline { 281 indent := strings.Repeat(" ", s.multilineIndent*2) 282 return " .. > " + indent, true 283 } 284 return "", false 285 } 286 287 // handleExit is called when the user wants to exit the shell, it restores the terminal 288 // when the interactive prompt has been used and writes the history to disk. Once that 289 // is completed it calls Close() to call the optional close handler for the provider 290 func (s *Shell) handleExit() { 291 rawHistory := strings.Join(s.History, "\n") 292 err := os.WriteFile(s.HistoryPath, []byte(rawHistory), 0o640) 293 if err != nil { 294 log.Error().Err(err).Msg("failed to save history") 295 } 296 297 s.restoreTerminalSettings() 298 299 // run onClose handler if set 300 s.Close() 301 302 os.Exit(0) 303 } 304 305 // Close is called when the shell is closed and calls the onCloseHandler 306 func (s *Shell) Close() { 307 s.Runtime.Close() 308 // run onClose handler if set 309 if s.onCloseHandler != nil { 310 s.onCloseHandler() 311 } 312 } 313 314 // RunOnce executes the query and returns results 315 func (s *Shell) RunOnce(cmd string) (*llx.CodeBundle, map[string]*llx.RawResult, error) { 316 s.resetPrintCache() 317 318 code, err := mqlc.Compile(cmd, nil, mqlc.NewConfig(s.Runtime.Schema(), s.features)) 319 if err != nil { 320 fmt.Fprintln(s.out, s.Theme.Error("failed to compile: "+err.Error())) 321 322 if code != nil && code.Suggestions != nil { 323 fmt.Fprintln(s.out, formatSuggestions(code.Suggestions, s.Theme)) 324 } 325 return nil, nil, err 326 } 327 328 res, err := s.RunOnceBundle(code) 329 return code, res, err 330 } 331 332 // RunOnceBundle executes the given code bundle and returns results 333 func (s *Shell) RunOnceBundle(code *llx.CodeBundle) (map[string]*llx.RawResult, error) { 334 return mql.ExecuteCode(s.Runtime, code, nil, s.features) 335 } 336 337 func (s *Shell) PrintResults(code *llx.CodeBundle, results map[string]*llx.RawResult) { 338 printedResult := s.Theme.PolicyPrinter.Results(code, results) 339 340 if s.MaxLines > 0 { 341 printedResult = stringx.MaxLines(s.MaxLines, printedResult) 342 } 343 344 fmt.Fprint(s.out, "\r") 345 fmt.Fprintln(s.out, printedResult) 346 } 347 348 func indent(indent int) string { 349 indentTxt := "" 350 for i := 0; i < indent; i++ { 351 indentTxt += " " 352 } 353 return indentTxt 354 } 355 356 // listAvailableResources lists resource names and their title 357 func (s *Shell) listAvailableResources() { 358 resources := s.Runtime.Schema().AllResources() 359 keys := sortx.Keys(resources) 360 s.renderResources(resources, keys) 361 } 362 363 // listFilteredResources displays the schema of one or many resources that start with the provided prefix 364 func (s *Shell) listFilteredResources(cmd string) { 365 m := helpResource.FindStringSubmatch(cmd) 366 if len(m) == 0 { 367 return 368 } 369 370 search := m[1] 371 resources := s.Runtime.Schema().AllResources() 372 373 // if we find the requested resource, just return it 374 if _, ok := resources[search]; ok { 375 s.renderResources(resources, []string{search}) 376 return 377 } 378 379 // otherwise we will look for anything that matches 380 keys := []string{} 381 for k := range resources { 382 if strings.HasPrefix(k, search) { 383 keys = append(keys, k) 384 } 385 } 386 sort.Strings(keys) 387 s.renderResources(resources, keys) 388 } 389 390 // renderResources renders a set of resources from a given schema 391 func (s *Shell) renderResources(resources map[string]*resources.ResourceInfo, keys []string) { 392 // list resources and field 393 type rowEntry struct { 394 key string 395 keylength int 396 value string 397 } 398 399 rows := []rowEntry{} 400 maxk := 0 401 const separator = ":" 402 403 for i := range keys { 404 k := keys[i] 405 resource := resources[k] 406 407 keyLength := len(resource.Name) + len(separator) 408 rows = append(rows, rowEntry{ 409 s.Theme.PolicyPrinter.Secondary(resource.Name) + separator, 410 keyLength, 411 resource.Title, 412 }) 413 if maxk < keyLength { 414 maxk = keyLength 415 } 416 417 fields := sortx.Keys(resource.Fields) 418 for i := range fields { 419 field := resource.Fields[fields[i]] 420 if field.IsPrivate { 421 continue 422 } 423 424 fieldName := " " + field.Name 425 fieldType := types.Type(field.Type).Label() 426 displayType := "" 427 fieldComment := field.Title 428 if fieldComment == "" && types.Type(field.Type).IsResource() { 429 r, ok := resources[fieldType] 430 if ok { 431 fieldComment = r.Title 432 } 433 } 434 if len(fieldType) > 0 { 435 fieldType = " " + fieldType 436 displayType = s.Theme.PolicyPrinter.Disabled(fieldType) 437 } 438 439 keyLength = len(fieldName) + len(fieldType) + len(separator) 440 rows = append(rows, rowEntry{ 441 s.Theme.PolicyPrinter.Secondary(fieldName) + displayType + separator, 442 keyLength, 443 fieldComment, 444 }) 445 if maxk < keyLength { 446 maxk = keyLength 447 } 448 } 449 } 450 451 for i := range rows { 452 entry := rows[i] 453 fmt.Fprintln(s.out, entry.key+indent(maxk-entry.keylength+1)+entry.value) 454 } 455 } 456 457 // capture the interrupt signal (SIGINT) once and notify a given channel 458 func captureSIGINTonce(sig chan<- struct{}) { 459 c := make(chan os.Signal, 1) 460 signal.Notify(c, os.Interrupt) 461 462 go func() { 463 <-c 464 signal.Stop(c) 465 sig <- struct{}{} 466 }() 467 } 468 469 func formatSuggestions(suggestions []*llx.Documentation, theme *theme.Theme) string { 470 var res strings.Builder 471 res.WriteString(theme.Secondary("\nsuggestions: \n")) 472 for i := range suggestions { 473 s := suggestions[i] 474 res.WriteString(theme.List(s.Field+": "+s.Title) + "\n") 475 } 476 return res.String() 477 }