github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/providers/discovery/puppetdb/puppetdb.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 puppetdb 6 7 import ( 8 "context" 9 "fmt" 10 "regexp" 11 "strconv" 12 "strings" 13 "sync" 14 "time" 15 16 "github.com/sirupsen/logrus" 17 "golang.org/x/text/cases" 18 "golang.org/x/text/language" 19 20 "github.com/choria-io/go-choria/config" 21 "github.com/choria-io/go-choria/protocol" 22 ) 23 24 type PuppetDB struct { 25 fw ChoriaFramework 26 timeout time.Duration 27 log *logrus.Entry 28 } 29 30 type ChoriaFramework interface { 31 Logger(string) *logrus.Entry 32 Configuration() *config.Config 33 PQLQueryCertNames(query string) ([]string, error) 34 } 35 36 var ( 37 stringIsRegex = regexp.MustCompile(`^/(.+)/$`) 38 stringIsAlpha = regexp.MustCompile(`[[:alpha:]]`) 39 stringIsPQL = regexp.MustCompile(`^pql:\s*(.+)$`) 40 ) 41 42 // New creates a new puppetdb discovery client 43 func New(fw ChoriaFramework) *PuppetDB { 44 b := &PuppetDB{ 45 fw: fw, 46 timeout: time.Second * time.Duration(fw.Configuration().DiscoveryTimeout), 47 log: fw.Logger("puppetdb_discovery"), 48 } 49 50 return b 51 } 52 53 // Discover performs a broadcast discovery using the supplied filter 54 func (p *PuppetDB) Discover(_ context.Context, opts ...DiscoverOption) (n []string, err error) { 55 dopts := &dOpts{ 56 collective: p.fw.Configuration().MainCollective, 57 discovered: []string{}, 58 filter: protocol.NewFilter(), 59 mu: &sync.Mutex{}, 60 timeout: p.timeout, 61 } 62 63 for _, opt := range opts { 64 opt(dopts) 65 } 66 67 if len(dopts.filter.Compound) > 0 { 68 return nil, fmt.Errorf("compound filters are not supported by PuppetDB") 69 } 70 71 if p.identityOptimize(dopts.filter) { 72 return dopts.filter.IdentityFilters(), nil 73 } 74 75 search, err := p.searchString(dopts.collective, dopts.filter) 76 if err != nil { 77 return nil, err 78 } 79 80 return p.fw.PQLQueryCertNames(search) 81 } 82 83 func (p *PuppetDB) identityOptimize(filter *protocol.Filter) bool { 84 if !(len(filter.CompoundFilters()) == 0 && len(filter.FactFilters()) == 0 && len(filter.ClassFilters()) == 0 && len(filter.IdentityFilters()) > 0) { 85 return false 86 } 87 88 for _, f := range filter.IdentityFilters() { 89 if stringIsRegex.MatchString(f) || stringIsPQL.MatchString(f) { 90 return false 91 } 92 } 93 94 return true 95 } 96 97 func (p *PuppetDB) searchString(collective string, filter *protocol.Filter) (string, error) { 98 var queries []string 99 100 queries = append(queries, p.discoverCollective(collective)) 101 queries = append(queries, p.discoverNodes(filter.Identity)) 102 queries = append(queries, p.discoverClasses(filter.Class)) 103 queries = append(queries, p.discoverAgents(filter.Agent)) 104 105 fq, err := p.discoverFacts(filter.Fact) 106 if err != nil { 107 return "", err 108 } 109 110 queries = append(queries, fq) 111 112 var pqlParts []string 113 for _, q := range queries { 114 if q != "" { 115 pqlParts = append(pqlParts, fmt.Sprintf("(%s)", q)) 116 } 117 } 118 119 pql := strings.Join(pqlParts, " and ") 120 return fmt.Sprintf(`nodes[certname, deactivated] { %s }`, pql), nil 121 } 122 123 func (p *PuppetDB) discoverAgents(agents []string) string { 124 if len(agents) == 0 { 125 return "" 126 } 127 128 var pql []string 129 130 for _, a := range agents { 131 switch { 132 case a == "scout" || a == "rpcutil" || a == "choria_util": 133 pql = append(pql, fmt.Sprintf("(%s or %s)", p.discoverClasses([]string{"choria::service"}), p.discoverClasses([]string{"mcollective::service"}))) 134 case stringIsRegex.MatchString(a): 135 matches := stringIsRegex.FindStringSubmatch(a) 136 pql = append(pql, fmt.Sprintf(`resources {type = "File" and tag ~ "mcollective_agent_.*?%s.*?_server"}`, p.stringRegex(matches[1]))) 137 default: 138 pql = append(pql, fmt.Sprintf(`resources {type = "File" and tag = "mcollective_agent_%s_server"}`, a)) 139 } 140 } 141 142 return strings.Join(pql, " and ") 143 } 144 145 func (p *PuppetDB) stringRegex(s string) string { 146 derived := s 147 if stringIsRegex.MatchString(s) { 148 parts := stringIsRegex.FindStringSubmatch(s) 149 derived = parts[1] 150 } 151 152 re := "" 153 for _, c := range []byte(derived) { 154 if stringIsAlpha.MatchString(string(c)) { 155 re += fmt.Sprintf("[%s%s]", strings.ToLower(string(c)), strings.ToUpper(string(c))) 156 } else { 157 re += string(c) 158 } 159 } 160 161 return re 162 } 163 164 func (p *PuppetDB) capitalizePuppetResource(r string) string { 165 parts := strings.Split(r, "::") 166 var res []string 167 168 for _, p := range parts { 169 res = append(res, cases.Title(language.AmericanEnglish).String(p)) 170 } 171 172 return strings.Join(res, "::") 173 } 174 175 func (p *PuppetDB) discoverClasses(classes []string) string { 176 if len(classes) == 0 { 177 return "" 178 } 179 180 var pql []string 181 182 for _, class := range classes { 183 if stringIsRegex.MatchString(class) { 184 parts := stringIsRegex.FindStringSubmatch(class) 185 pql = append(pql, fmt.Sprintf(`resources {type = "Class" and title ~ "%s"}`, p.stringRegex(parts[1]))) 186 } else { 187 pql = append(pql, fmt.Sprintf(`resources {type = "Class" and title = "%s"}`, p.capitalizePuppetResource(class))) 188 } 189 } 190 191 return strings.Join(pql, " and ") 192 } 193 194 func (p *PuppetDB) discoverNodes(nodes []string) string { 195 if len(nodes) == 0 { 196 return "" 197 } 198 199 var pql []string 200 201 for _, node := range nodes { 202 switch { 203 case stringIsPQL.MatchString(node): 204 parts := stringIsPQL.FindStringSubmatch(node) 205 pql = append(pql, fmt.Sprintf("certname in %s", parts[1])) 206 207 case stringIsRegex.MatchString(node): 208 parts := stringIsRegex.FindStringSubmatch(node) 209 pql = append(pql, fmt.Sprintf(`certname ~ "%s"`, p.stringRegex(parts[1]))) 210 211 default: 212 pql = append(pql, fmt.Sprintf(`certname = "%s"`, node)) 213 214 } 215 } 216 217 return strings.Join(pql, " or ") 218 } 219 220 func (p *PuppetDB) discoverCollective(f string) string { 221 if f == "" { 222 return "" 223 } 224 225 return fmt.Sprintf(`certname in inventory[certname] { facts.mcollective.server.collectives.match("\d+") = "%s" }`, f) 226 } 227 228 func (p *PuppetDB) isNumeric(s string) bool { 229 _, err := strconv.ParseFloat(s, 64) 230 return err == nil 231 } 232 233 func (p *PuppetDB) discoverFacts(facts []protocol.FactFilter) (string, error) { 234 if len(facts) == 0 { 235 return "", nil 236 } 237 238 var pql []string 239 240 for _, f := range facts { 241 switch f.Operator { 242 case "=~": 243 pql = append(pql, fmt.Sprintf(`inventory {facts.%s ~ "%s"}`, f.Fact, p.stringRegex(f.Value))) 244 245 case "==": 246 if f.Value == "true" || f.Value == "false" || p.isNumeric(f.Value) { 247 pql = append(pql, fmt.Sprintf(`inventory {facts.%s = %s or facts.%s = "%s"}`, f.Fact, f.Value, f.Fact, f.Value)) 248 } else { 249 pql = append(pql, fmt.Sprintf(`inventory {facts.%s = "%s"}`, f.Fact, f.Value)) 250 } 251 252 case "!=": 253 if f.Value == "true" || f.Value == "false" || p.isNumeric(f.Value) { 254 pql = append(pql, fmt.Sprintf(`inventory {!(facts.%s = %s or facts.%s = "%s")}`, f.Fact, f.Value, f.Fact, f.Value)) 255 } else { 256 pql = append(pql, fmt.Sprintf(`inventory {!(facts.%s = "%s")}`, f.Fact, f.Value)) 257 } 258 259 case ">=", ">", "<=", "<": 260 if !p.isNumeric(f.Value) { 261 return "", fmt.Errorf("'%s' operator supports only numeric values", f.Operator) 262 } 263 264 pql = append(pql, fmt.Sprintf("inventory {facts.%s %s %s}", f.Fact, f.Operator, f.Value)) 265 266 default: 267 return "", fmt.Errorf("do not know how to do fact comparisons using the '%s' operator with PuppetDB", f.Operator) 268 269 } 270 } 271 272 return strings.Join(pql, " and "), nil 273 }