github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/filter/facts/facts.go (about)

     1  // Copyright (c) 2019-2022, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package facts
     6  
     7  import (
     8  	"encoding/json"
     9  	"fmt"
    10  	"os"
    11  	"regexp"
    12  	"strings"
    13  
    14  	"github.com/tidwall/gjson"
    15  
    16  	"github.com/ghodss/yaml"
    17  
    18  	"github.com/choria-io/go-choria/internal/util"
    19  )
    20  
    21  var validOperators = regexp.MustCompile(`<=|>=|=>|=<|<|>|!=|=~|={1,2}`)
    22  
    23  // Logger provides logging facilities
    24  type Logger interface {
    25  	Warnf(format string, args ...any)
    26  	Debugf(format string, args ...any)
    27  	Errorf(format string, args ...any)
    28  }
    29  
    30  // MatchFacts match fact filters in a OR manner, only facts matching all filters will be true
    31  func MatchFacts(filters [][3]string, facts json.RawMessage, log Logger) bool {
    32  	matched := false
    33  	var err error
    34  
    35  	for _, filter := range filters {
    36  		matched, err = HasFactJSON(filter[0], filter[1], filter[2], facts, log)
    37  		if err != nil {
    38  			log.Warnf("Failed to match fact '%#v': %s", filter, err)
    39  			return false
    40  		}
    41  
    42  		if !matched {
    43  			log.Debugf("Failed to match fact filter '%#v'", filter)
    44  			break
    45  		}
    46  	}
    47  
    48  	return matched
    49  }
    50  
    51  // MatchFile match fact filters in a OR manner, only nodes that have all the matching facts will be true here
    52  func MatchFile(filters [][3]string, file string, log Logger) bool {
    53  	facts, err := JSON(file, log)
    54  	if err != nil {
    55  		log.Warnf("Failed to match fact: '%#v': %s", filters, err)
    56  		return false
    57  	}
    58  
    59  	return MatchFacts(filters, facts, log)
    60  }
    61  
    62  // JSON parses the data, including doing any conversions needed, and returns JSON text
    63  func JSON(file string, log Logger) (json.RawMessage, error) {
    64  	out := make(map[string]any)
    65  
    66  	for _, f := range strings.Split(file, string(os.PathListSeparator)) {
    67  		if f == "" {
    68  			continue
    69  		}
    70  
    71  		if !util.FileExist(f) {
    72  			log.Warnf("Fact file %s does not exist", f)
    73  			continue
    74  		}
    75  
    76  		j, err := os.ReadFile(f)
    77  		if err != nil {
    78  			log.Errorf("Could not read fact file %s: %s", f, err)
    79  			continue
    80  		}
    81  
    82  		if strings.HasSuffix(f, "yaml") {
    83  			j, err = yaml.YAMLToJSON(j)
    84  			if err != nil {
    85  				log.Errorf("Could not parse facts file %s as YAML: %s", file, err)
    86  				continue
    87  			}
    88  		}
    89  
    90  		facts := make(map[string]any)
    91  		err = json.Unmarshal(j, &facts)
    92  		if err != nil {
    93  			log.Errorf("Could not parse facts file: %s", err)
    94  			continue
    95  		}
    96  
    97  		// does a very dumb shallow merge that mimics ruby Hash#merge
    98  		// to maintain mcollective compatibility
    99  		for k, v := range facts {
   100  			out[k] = v
   101  		}
   102  	}
   103  
   104  	if len(out) == 0 {
   105  		return json.RawMessage("{}"), fmt.Errorf("no facts were found in %s", file)
   106  	}
   107  
   108  	j, err := json.Marshal(&out)
   109  	if err != nil {
   110  		return json.RawMessage("{}"), fmt.Errorf("could not JSON marshal merged facts: %s", err)
   111  	}
   112  
   113  	return json.RawMessage(j), nil
   114  }
   115  
   116  // GetFact looks up a single fact from the facts file, errors reading
   117  // the file is reported but an absent fact is handled as empty result
   118  // and no error
   119  func GetFact(fact string, file string, log Logger) ([]byte, gjson.Result, error) {
   120  	j, err := JSON(file, log)
   121  	if err != nil {
   122  		return nil, gjson.Result{}, err
   123  	}
   124  
   125  	found, err := GetFactJSON(fact, j)
   126  	return j, found, err
   127  }
   128  
   129  // GetFactJSON looks up a single fact from the JSON data, absent fact is handled as empty
   130  // result and no error
   131  func GetFactJSON(fact string, facts json.RawMessage) (gjson.Result, error) {
   132  	result := gjson.GetBytes(facts, fact)
   133  
   134  	return result, nil
   135  }
   136  
   137  func HasFactJSON(fact string, operator string, value string, facts json.RawMessage, log Logger) (bool, error) {
   138  	result, err := GetFactJSON(fact, facts)
   139  	if err != nil {
   140  		return false, err
   141  	}
   142  
   143  	if !result.Exists() {
   144  		return false, nil
   145  	}
   146  
   147  	switch operator {
   148  	case "==":
   149  		return eqMatch(result, value)
   150  	case "=~":
   151  		return reMatch(result, value)
   152  	case "<=":
   153  		return leMatch(result, value)
   154  	case ">=":
   155  		return geMatch(result, value)
   156  	case "<":
   157  		return ltMatch(result, value)
   158  	case ">":
   159  		return gtMatch(result, value)
   160  	case "!=":
   161  		return neMatch(result, value)
   162  	default:
   163  		return false, fmt.Errorf("unknown fact matching operator %s while looking for fact %s", operator, fact)
   164  	}
   165  }
   166  
   167  // HasFact evaluates the expression against facts in the file
   168  func HasFact(fact string, operator string, value string, file string, log Logger) (bool, error) {
   169  	j, err := JSON(file, log)
   170  	if err != nil {
   171  		return false, err
   172  	}
   173  
   174  	return HasFactJSON(fact, operator, value, j, log)
   175  }
   176  
   177  // ParseFactFilterString parses a fact filter string as typically typed on the CLI
   178  func ParseFactFilterString(f string) ([3]string, error) {
   179  	operatorIndexes := validOperators.FindAllStringIndex(f, -1)
   180  	var mainOpIndex []int
   181  
   182  	if opCount := len(operatorIndexes); opCount > 1 {
   183  		// This is a special case where the left operand contains a valid operator.
   184  		// We skip over everything and use the right most operator.
   185  		mainOpIndex = operatorIndexes[len(operatorIndexes)-1]
   186  	} else if opCount == 1 {
   187  		mainOpIndex = operatorIndexes[0]
   188  	} else {
   189  		return [3]string{}, fmt.Errorf("could not parse fact %s it does not appear to be in a valid format", f)
   190  	}
   191  
   192  	op := f[mainOpIndex[0]:mainOpIndex[1]]
   193  	leftOp := strings.TrimSpace(f[:mainOpIndex[0]])
   194  	rightOp := strings.TrimSpace(f[mainOpIndex[1]:])
   195  
   196  	// validate that the left and right operands are both valid
   197  	if len(leftOp) == 0 || len(rightOp) == 0 {
   198  		return [3]string{}, fmt.Errorf("could not parse fact %s it does not appear to be in a valid format", f)
   199  	}
   200  
   201  	lStartString := string(leftOp[0])
   202  	rEndString := string(rightOp[len(rightOp)-1])
   203  	if validOperators.MatchString(lStartString) || validOperators.Match([]byte(rEndString)) {
   204  		return [3]string{}, fmt.Errorf("could not parse fact %s it does not appear to be in a valid format", f)
   205  	}
   206  
   207  	// transform op and value for processing
   208  	switch op {
   209  	case "=":
   210  		op = "=="
   211  	case "=<":
   212  		op = "<="
   213  	case "=>":
   214  		op = ">="
   215  	}
   216  
   217  	// finally check for old style regex fact matches
   218  	if rightOp[0] == '/' && rightOp[len(rightOp)-1] == '/' {
   219  		op = "=~"
   220  	}
   221  
   222  	return [3]string{leftOp, op, rightOp}, nil
   223  }