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  }