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 }