github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/client/discovery/cli_helper.go (about)

     1  // Copyright (c) 2021, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package discovery
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"path/filepath"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/choria-io/go-choria/config"
    18  	"github.com/choria-io/go-choria/filter"
    19  	"github.com/choria-io/go-choria/inter"
    20  	"github.com/choria-io/go-choria/protocol"
    21  	"github.com/choria-io/go-choria/providers/discovery/broadcast"
    22  	"github.com/choria-io/go-choria/providers/discovery/external"
    23  	"github.com/choria-io/go-choria/providers/discovery/flatfile"
    24  	"github.com/choria-io/go-choria/providers/discovery/inventory"
    25  	"github.com/choria-io/go-choria/providers/discovery/puppetdb"
    26  	log "github.com/sirupsen/logrus"
    27  )
    28  
    29  type StandardOptions struct {
    30  	Collective              string            `json:"collective"`
    31  	FactFilter              []string          `json:"facts"`
    32  	AgentFilter             []string          `json:"agents"`
    33  	ClassFilter             []string          `json:"classes"`
    34  	IdentityFilter          []string          `json:"identities"`
    35  	CombinedFilter          []string          `json:"combined"`
    36  	CompoundFilter          string            `json:"compound"`
    37  	DiscoveryMethod         string            `json:"discovery_method"`
    38  	DiscoveryTimeout        int               `json:"discovery_timeout"`
    39  	DynamicDiscoveryTimeout bool              `json:"dynamic_discovery_timeout"`
    40  	NodesFile               string            `json:"nodes_file"`
    41  	DiscoveryOptions        map[string]string `json:"discovery_options"`
    42  
    43  	unsetMethod bool
    44  }
    45  
    46  // NewStandardOptions creates a new CLI options helper
    47  func NewStandardOptions() *StandardOptions {
    48  	return &StandardOptions{
    49  		FactFilter:       []string{},
    50  		AgentFilter:      []string{},
    51  		ClassFilter:      []string{},
    52  		IdentityFilter:   []string{},
    53  		CombinedFilter:   []string{},
    54  		DiscoveryOptions: make(map[string]string),
    55  	}
    56  }
    57  
    58  // Merge merges opt with the settings here, when a basic setting is
    59  // set in opt it will overwrite the one here, when its a filter like
    60  // a list or map it will extend ours with its values.
    61  func (o *StandardOptions) Merge(opt *StandardOptions) {
    62  	if opt.Collective != "" {
    63  		o.Collective = opt.Collective
    64  	}
    65  	o.FactFilter = append(o.FactFilter, opt.FactFilter...)
    66  	o.AgentFilter = append(o.AgentFilter, opt.AgentFilter...)
    67  	o.ClassFilter = append(o.ClassFilter, opt.ClassFilter...)
    68  	o.IdentityFilter = append(o.IdentityFilter, opt.IdentityFilter...)
    69  	o.CombinedFilter = append(o.CombinedFilter, opt.CombinedFilter...)
    70  	if opt.CompoundFilter != "" {
    71  		o.CompoundFilter = opt.CompoundFilter
    72  	}
    73  	if opt.DiscoveryMethod != "" {
    74  		o.DiscoveryMethod = opt.DiscoveryMethod
    75  	}
    76  	if opt.DiscoveryTimeout > 0 {
    77  		o.DiscoveryTimeout = opt.DiscoveryTimeout
    78  	}
    79  	if opt.NodesFile != "" {
    80  		o.NodesFile = opt.NodesFile
    81  	}
    82  	for k, v := range opt.DiscoveryOptions {
    83  		o.DiscoveryOptions[k] = v
    84  	}
    85  }
    86  
    87  // AddSelectionFlags adds the --dm and --discovery-timeout options
    88  func (o *StandardOptions) AddSelectionFlags(app inter.FlagApp) {
    89  	app.Flag("dm", "Sets a discovery method (mc, choria, file, external, inventory)").EnumVar(&o.DiscoveryMethod, "broadcast", "choria", "mc", "file", "flatfile", "external", "inventory")
    90  	app.Flag("discovery-timeout", "Timeout for doing discovery").PlaceHolder("SECONDS").IntVar(&o.DiscoveryTimeout)
    91  	app.Flag("discovery-window", "Enables a sliding window based dynamic discovery timeout (experimental)").UnNegatableBoolVar(&o.DynamicDiscoveryTimeout)
    92  }
    93  
    94  // AddFilterFlags adds the various flags like -W, -S, -T etc
    95  func (o *StandardOptions) AddFilterFlags(app inter.FlagApp) {
    96  	app.Flag("wf", "Match hosts with a certain fact").Short('F').StringsVar(&o.FactFilter)
    97  	app.Flag("wc", "Match hosts with a certain configuration management class").Short('C').StringsVar(&o.ClassFilter)
    98  	app.Flag("wa", "Match hosts with a certain Choria agent").Short('A').StringsVar(&o.AgentFilter)
    99  	app.Flag("wi", "Match hosts with a certain Choria identity").Short('I').StringsVar(&o.IdentityFilter)
   100  	app.Flag("with", "Combined classes and facts filter").Short('W').PlaceHolder("FILTER").StringsVar(&o.CombinedFilter)
   101  	app.Flag("select", "Match hosts using a expr compound filter").Short('S').PlaceHolder("EXPR").StringVar(&o.CompoundFilter)
   102  	app.Flag("target", "Target a specific sub collective").Short('T').StringVar(&o.Collective)
   103  	app.Flag("do", "Options for the chosen discovery method").PlaceHolder("K=V").StringMapVar(&o.DiscoveryOptions)
   104  }
   105  
   106  // AddFlatFileFlags adds the flags to select nodes using --nodes in text, json and yaml formats
   107  func (o *StandardOptions) AddFlatFileFlags(app inter.FlagApp) {
   108  	app.Flag("nodes", "List of nodes to interact with in JSON, YAML or TEXT formats").ExistingFileVar(&o.NodesFile)
   109  }
   110  
   111  func (o *StandardOptions) Discover(ctx context.Context, fw inter.Framework, agent string, supportStdin bool, progress bool, logger *log.Entry) ([]string, time.Duration, error) {
   112  	var (
   113  		fformat    flatfile.SourceFormat
   114  		sourceFile io.Reader
   115  		nodes      []string
   116  		to         = time.Second * time.Duration(o.DiscoveryTimeout)
   117  	)
   118  
   119  	filter, err := o.NewFilter(agent)
   120  	if err != nil {
   121  		return nil, 0, err
   122  	}
   123  
   124  	switch {
   125  	case o.NodesFile != "":
   126  		o.DiscoveryMethod = "flatfile"
   127  
   128  		switch filepath.Ext(o.NodesFile) {
   129  		case ".json":
   130  			logger.Debugf("Using %q as JSON format file", o.NodesFile)
   131  			fformat = flatfile.JSONFormat
   132  		case ".yaml", ".yml":
   133  			logger.Debugf("Using %q as YAML format file", o.NodesFile)
   134  			fformat = flatfile.YAMLFormat
   135  		default:
   136  			logger.Debugf("Using %q as TEXT format file", o.NodesFile)
   137  			fformat = flatfile.TextFormat
   138  		}
   139  
   140  		sourceFile, err = os.Open(o.NodesFile)
   141  		if err != nil {
   142  			return nil, 0, err
   143  		}
   144  
   145  	case len(filter.Compound) > 0 && o.DiscoveryMethod != "broadcast" && o.DiscoveryMethod != "inventory" && o.DiscoveryMethod != "mc":
   146  		o.DiscoveryMethod = "broadcast"
   147  		logger.Debugf("Forcing discovery mode to broadcast to support compound filters")
   148  
   149  	case supportStdin && o.isPiped() && (o.DiscoveryMethod == "" || o.unsetMethod):
   150  		stdin, err := io.ReadAll(os.Stdin)
   151  		stdin = bytes.TrimSpace(stdin)
   152  		sourceFile = bytes.NewReader(stdin)
   153  
   154  		if err != nil {
   155  			logger.Debugf("Could not read STDIN to detect flatfile override")
   156  			break
   157  		}
   158  
   159  		if len(stdin) == 0 {
   160  			logger.Debugf("No data on STDIN found, not forcing flatfile override")
   161  			break
   162  		}
   163  
   164  		if !(bytes.HasPrefix(stdin, []byte("{")) && bytes.HasSuffix(stdin, []byte("}"))) {
   165  			logger.Debugf("Found non JSON data on STDIN, not forcing flatfile override")
   166  			break
   167  		}
   168  
   169  		o.DiscoveryMethod = "flatfile"
   170  		fformat = flatfile.ChoriaResponsesFormat
   171  		logger.Debugf("Forcing discovery mode to flatfile with Choria responses on STDIN")
   172  	}
   173  
   174  	if o.DiscoveryMethod == "flatfile" && (fformat == 0 || sourceFile == nil) && len(o.DiscoveryOptions) == 0 {
   175  		return nil, 0, fmt.Errorf("could not determine file to use as discovery source")
   176  	}
   177  
   178  	if progress {
   179  		fmt.Printf("Discovering nodes using the %s method .... ", o.DiscoveryMethod)
   180  	}
   181  
   182  	start := time.Now()
   183  	switch o.DiscoveryMethod {
   184  	case "mc", "broadcast":
   185  		opts := []broadcast.DiscoverOption{broadcast.Filter(filter), broadcast.Collective(o.Collective), broadcast.Timeout(to)}
   186  		if o.DynamicDiscoveryTimeout {
   187  			opts = append(opts, broadcast.SlidingWindow())
   188  		}
   189  
   190  		nodes, err = broadcast.New(fw).Discover(ctx, opts...)
   191  	case "choria", "puppetdb":
   192  		nodes, err = puppetdb.New(fw).Discover(ctx, puppetdb.Filter(filter), puppetdb.Collective(o.Collective), puppetdb.Timeout(to))
   193  	case "external":
   194  		nodes, err = external.New(fw).Discover(ctx, external.Filter(filter), external.Timeout(to), external.Collective(o.Collective), external.DiscoveryOptions(o.DiscoveryOptions))
   195  	case "flatfile", "file":
   196  		nodes, err = flatfile.New(fw).Discover(ctx, flatfile.Reader(sourceFile), flatfile.Format(fformat), flatfile.DiscoveryOptions(o.DiscoveryOptions))
   197  	case "inventory":
   198  		nodes, err = inventory.New(fw).Discover(ctx, inventory.Filter(filter), inventory.Collective(o.Collective), inventory.DiscoveryOptions(o.DiscoveryOptions))
   199  	default:
   200  		return nil, 0, fmt.Errorf("unsupported discovery method %q", o.DiscoveryMethod)
   201  	}
   202  
   203  	if progress {
   204  		fmt.Printf("%d\n", len(nodes))
   205  	}
   206  
   207  	return nodes, time.Since(start), err
   208  }
   209  
   210  func (o *StandardOptions) isPiped() bool {
   211  	fi, err := os.Stdin.Stat()
   212  	if err != nil {
   213  		return false
   214  	}
   215  
   216  	return (fi.Mode() & os.ModeCharDevice) == 0
   217  }
   218  
   219  // SetDefaultsFromChoria sets the defaults based on cfg
   220  func (o *StandardOptions) SetDefaultsFromChoria(fw inter.Framework) {
   221  	o.SetDefaultsFromConfig(fw.Configuration())
   222  }
   223  
   224  // SetDefaultsFromConfig sets the defaults based on cfg
   225  func (o *StandardOptions) SetDefaultsFromConfig(cfg *config.Config) {
   226  	if o.DiscoveryMethod == "" {
   227  		o.DiscoveryMethod = cfg.DefaultDiscoveryMethod
   228  		o.unsetMethod = true
   229  	}
   230  
   231  	if o.Collective == "" {
   232  		o.Collective = cfg.MainCollective
   233  	}
   234  
   235  	if o.DiscoveryTimeout == 0 {
   236  		o.DiscoveryTimeout = cfg.DiscoveryTimeout
   237  	}
   238  
   239  	if len(o.DiscoveryOptions) == 0 {
   240  		for _, val := range cfg.DefaultDiscoveryOptions {
   241  			parts := strings.Split(val, "=")
   242  			if len(parts) == 2 {
   243  				o.DiscoveryOptions[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
   244  			}
   245  		}
   246  	}
   247  }
   248  
   249  // NewFilter creates a new filter based on the options supplied, additionally agent will be added to the list
   250  func (o *StandardOptions) NewFilter(agent string) (*protocol.Filter, error) {
   251  	return filter.NewFilter(
   252  		filter.FactFilter(o.FactFilter...),
   253  		filter.AgentFilter(o.AgentFilter...),
   254  		filter.ClassFilter(o.ClassFilter...),
   255  		filter.IdentityFilter(o.IdentityFilter...),
   256  		filter.CombinedFilter(o.CombinedFilter...),
   257  		filter.CompoundFilter(o.CompoundFilter),
   258  		filter.AgentFilter(agent),
   259  	)
   260  }