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  }