github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/providers/discovery/inventory/inventory.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 inventory
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"fmt"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/choria-io/go-choria/inter"
    16  	"github.com/expr-lang/expr/vm"
    17  	"github.com/ghodss/yaml"
    18  	"github.com/sirupsen/logrus"
    19  
    20  	"github.com/choria-io/go-choria/filter/compound"
    21  	"github.com/choria-io/go-choria/internal/util"
    22  	"github.com/choria-io/go-choria/protocol"
    23  )
    24  
    25  type Inventory struct {
    26  	fw  inter.Framework
    27  	log *logrus.Entry
    28  }
    29  
    30  // New creates a new puppetdb discovery client
    31  func New(fw inter.Framework) *Inventory {
    32  	b := &Inventory{
    33  		fw:  fw,
    34  		log: fw.Logger("inventory_discovery"),
    35  	}
    36  
    37  	return b
    38  }
    39  
    40  // Discover performs a broadcast discovery using the supplied filter
    41  func (i *Inventory) Discover(ctx context.Context, opts ...DiscoverOption) (n []string, err error) {
    42  	dopts := &dOpts{
    43  		collective: i.fw.Configuration().MainCollective,
    44  		source:     i.fw.Configuration().Choria.InventoryDiscoverySource,
    45  		filter:     protocol.NewFilter(),
    46  		do:         make(map[string]string),
    47  	}
    48  
    49  	for _, opt := range opts {
    50  		opt(dopts)
    51  	}
    52  
    53  	file, ok := dopts.do["file"]
    54  	if ok {
    55  		dopts.source = file
    56  	}
    57  
    58  	_, ok = dopts.do["novalidate"]
    59  	if ok {
    60  		dopts.noValidate = true
    61  	}
    62  
    63  	if dopts.source == "" {
    64  		return nil, fmt.Errorf("no discovery source file specified")
    65  	}
    66  
    67  	dopts.source, err = util.ExpandPath(dopts.source)
    68  	if err != nil {
    69  		return nil, err
    70  	}
    71  
    72  	if !util.FileExist(dopts.source) {
    73  		return nil, fmt.Errorf("discovery source %q does not exist", dopts.source)
    74  	}
    75  
    76  	return i.discover(ctx, dopts)
    77  }
    78  
    79  func (i *Inventory) discover(ctx context.Context, dopts *dOpts) ([]string, error) {
    80  	data, err := ReadInventory(dopts.source, dopts.noValidate)
    81  	if err != nil {
    82  		return nil, err
    83  	}
    84  
    85  	grouped, err := i.isValidGroupLookup(dopts.filter)
    86  	if err != nil {
    87  		return nil, err
    88  	}
    89  
    90  	if grouped {
    91  		matched := []string{}
    92  		for _, id := range dopts.filter.IdentityFilters() {
    93  			id := strings.TrimPrefix(id, "group:")
    94  			grp, ok := data.LookupGroup(id)
    95  			if ok {
    96  				gf, err := grp.Filter.ToProtocolFilter()
    97  				if err != nil {
    98  					return nil, err
    99  				}
   100  				selected, err := i.selectMatchingNodes(ctx, data, "", gf)
   101  				if err != nil {
   102  					return nil, err
   103  				}
   104  
   105  				matched = append(matched, selected...)
   106  			} else {
   107  				return nil, fmt.Errorf("unknown group '%s'", id)
   108  			}
   109  		}
   110  
   111  		return util.UniqueStrings(matched, true), nil
   112  	}
   113  
   114  	return i.selectMatchingNodes(ctx, data, dopts.collective, dopts.filter)
   115  }
   116  
   117  func (i *Inventory) isValidGroupLookup(f *protocol.Filter) (grouped bool, err error) {
   118  	grp := 0
   119  	node := 0
   120  
   121  	idf := len(f.IdentityFilters())
   122  	if idf > 0 {
   123  		for _, f := range f.IdentityFilters() {
   124  			if strings.HasPrefix(f, "group:") {
   125  				grp++
   126  			} else {
   127  				node++
   128  			}
   129  		}
   130  	}
   131  
   132  	if grp == 0 {
   133  		return false, nil
   134  	}
   135  
   136  	if node != 0 {
   137  		return true, fmt.Errorf("group matches cannot be combined with other filters")
   138  	}
   139  
   140  	// we allow one agent filter because it's pretty much always there but any additional filters would not be allowed
   141  	if len(f.FactFilters()) > 0 || len(f.ClassFilters()) > 0 || len(f.AgentFilters()) > 1 || len(f.CompoundFilters()) > 0 {
   142  		return true, fmt.Errorf("group matches cannot be combined with other filters")
   143  	}
   144  
   145  	return true, nil
   146  }
   147  
   148  func (i *Inventory) selectMatchingNodes(ctx context.Context, d *DataFile, collective string, f *protocol.Filter) ([]string, error) {
   149  	var (
   150  		matched []string
   151  		query   string
   152  		prog    *vm.Program
   153  		err     error
   154  	)
   155  
   156  	if len(f.CompoundFilters()) > 0 {
   157  		query = f.CompoundFilters()[0][0]["expr"]
   158  		prog, err = compound.CompileExprQuery(query, nil)
   159  		if err != nil {
   160  			return nil, err
   161  		}
   162  	}
   163  
   164  	for _, node := range d.Nodes {
   165  		if ctx.Err() != nil {
   166  			return nil, ctx.Err()
   167  		}
   168  
   169  		if collective != "" && !util.StringInList(node.Collectives, collective) {
   170  			continue
   171  		}
   172  
   173  		if f.Empty() {
   174  			matched = append(matched, node.Name)
   175  			continue
   176  		}
   177  
   178  		passed := 0
   179  
   180  		if len(f.IdentityFilters()) > 0 {
   181  			if f.MatchIdentity(node.Name) {
   182  				passed++
   183  			} else {
   184  				continue
   185  			}
   186  		}
   187  
   188  		if len(f.AgentFilters()) > 0 {
   189  			if f.MatchAgents(node.Agents) {
   190  				passed++
   191  			} else {
   192  				continue
   193  			}
   194  		}
   195  
   196  		if len(f.ClassFilters()) > 0 {
   197  			if f.MatchClasses(node.Classes, i.log) {
   198  				passed++
   199  			} else {
   200  				continue
   201  			}
   202  		}
   203  
   204  		if len(f.FactFilters()) > 0 {
   205  			if f.MatchFacts(node.Facts, i.log) {
   206  				passed++
   207  			} else {
   208  				continue
   209  			}
   210  		}
   211  
   212  		if len(f.CompoundFilters()) > 0 {
   213  			b, _ := compound.MatchExprProgram(prog, node.Facts, node.Classes, node.Agents, nil, i.log)
   214  			if b {
   215  				passed++
   216  			} else {
   217  				continue
   218  			}
   219  		}
   220  
   221  		if passed > 0 {
   222  			matched = append(matched, node.Name)
   223  		}
   224  	}
   225  
   226  	return matched, nil
   227  }
   228  
   229  // ReadInventory reads and validates an inventory file
   230  func ReadInventory(path string, noValidate bool) (*DataFile, error) {
   231  	var err error
   232  
   233  	if !util.FileExist(path) {
   234  		return nil, fmt.Errorf("discovery source %s does not exist", path)
   235  	}
   236  
   237  	ext := filepath.Ext(path)
   238  	f, err := os.ReadFile(path)
   239  	if err != nil {
   240  		return nil, err
   241  	}
   242  
   243  	data := &DataFile{}
   244  
   245  	if ext == ".yaml" || ext == ".yml" {
   246  		f, err = yaml.YAMLToJSON(f)
   247  		if err != nil {
   248  			return nil, err
   249  		}
   250  	}
   251  
   252  	if !noValidate {
   253  		warnings, err := ValidateInventory(f)
   254  		if err != nil {
   255  			return nil, err
   256  		}
   257  		if len(warnings) > 0 {
   258  			return nil, fmt.Errorf("invalid inventory file, validate using 'choria tool inventory'")
   259  		}
   260  	}
   261  
   262  	err = json.Unmarshal(f, data)
   263  	if err != nil {
   264  		return nil, err
   265  	}
   266  
   267  	if data.Schema != DataSchema {
   268  		return nil, fmt.Errorf("invalid schema %q expected %q", data.Schema, DataSchema)
   269  	}
   270  
   271  	return data, nil
   272  }