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 }