1 package sources
2
3
4
5
6
7 import (
8 "cmp"
9 "context"
10 "crypto/tls"
11 "crypto/x509"
12 "errors"
13 "fmt"
14 "net/url"
15 "os"
16 "strings"
17 "sync"
18 "time"
19
20 jsoniter "github.com/json-iterator/go"
21
22 "github.com/cybertec-postgresql/pgwatch/v6/internal/db"
23 "github.com/cybertec-postgresql/pgwatch/v6/internal/log"
24 pgx "github.com/jackc/pgx/v5"
25 client "go.etcd.io/etcd/client/v3"
26 "go.uber.org/zap"
27 )
28
29
30
31
32
33
34
35
36
37 type Resolver struct {
38 mu sync.Mutex
39
40
41 lastFoundClusterMembers map[string][]PatroniClusterMember
42
43
44
45 lastFoundDatabases map[postgresDiscoveryKey]SourceConns
46 }
47
48
49 func NewResolver() *Resolver {
50 return &Resolver{
51 lastFoundClusterMembers: make(map[string][]PatroniClusterMember),
52 lastFoundDatabases: make(map[postgresDiscoveryKey]SourceConns),
53 }
54 }
55
56
57
58
59 var defaultResolver = NewResolver()
60
61
62
63
64 func (srcs Sources) ResolveDatabases(onError func(string)) (SourceConns, error) {
65 return defaultResolver.ResolveDatabases(srcs, onError)
66 }
67
68
69
70 func (s Source) ResolveDatabases() (SourceConns, error) {
71 return defaultResolver.ResolveDatabase(s)
72 }
73
74
75
76
77 func (r *Resolver) ResolveDatabases(srcs Sources, onError func(string)) (_ SourceConns, err error) {
78 type result struct {
79 dbs SourceConns
80 err error
81 }
82 results := make([]result, len(srcs))
83 var wg sync.WaitGroup
84 for i, s := range srcs {
85 wg.Go(func() {
86 dbs, e := r.ResolveDatabase(s)
87 results[i] = result{dbs, e}
88 })
89 }
90 wg.Wait()
91 resolvedDbs := make(SourceConns, 0, len(srcs))
92 for i, res := range results {
93 if res.err != nil {
94 if onError != nil {
95 onError(srcs[i].Name)
96 }
97 logger.WithField("source", srcs[i].Name).WithError(res.err).Error("could not resolve databases from source")
98 err = errors.Join(err, res.err)
99 }
100 resolvedDbs = append(resolvedDbs, res.dbs...)
101 }
102 return resolvedDbs, err
103 }
104
105
106
107 func (r *Resolver) ResolveDatabase(s Source) (SourceConns, error) {
108 switch s.Kind {
109 case SourcePatroniDiscovery:
110 return r.ResolveDatabasesFromPatroni(s)
111 case SourcePostgresDiscovery:
112 return r.ResolveDatabasesFromPostgres(s)
113 case SourcePrometheus:
114 return SourceConns{NewPromConn(s)}, nil
115 }
116 return SourceConns{NewDbConn(s)}, nil
117 }
118
119 type PatroniClusterMember struct {
120 Scope string
121 Name string
122 ConnURL string `yaml:"conn_url"`
123 Role string
124 }
125
126 func (pcm PatroniClusterMember) IsPrimary() bool {
127 return pcm.Role == "primary" || pcm.Role == "master"
128 }
129
130 var logger log.Logger = log.FallbackLogger
131
132
133
134
135 type postgresDiscoveryKey struct {
136 name string
137 connStr string
138 includePattern string
139 excludePattern string
140 }
141
142 func postgresDiscoveryKeyOf(s Source) postgresDiscoveryKey {
143 return postgresDiscoveryKey{
144 name: s.Name,
145 connStr: s.ConnStr,
146 includePattern: s.IncludePattern,
147 excludePattern: s.ExcludePattern,
148 }
149 }
150
151 func getConsulClusterMembers(Source) ([]PatroniClusterMember, error) {
152 return nil, errors.ErrUnsupported
153 }
154
155 func getZookeeperClusterMembers(Source, HostConfig) ([]PatroniClusterMember, error) {
156 return nil, errors.ErrUnsupported
157 }
158
159 func jsonTextToStringMap(jsonText string) (map[string]string, error) {
160 retmap := make(map[string]string)
161 if jsonText == "" {
162 return retmap, nil
163 }
164 var iMap map[string]any
165 if err := jsoniter.ConfigFastest.Unmarshal([]byte(jsonText), &iMap); err != nil {
166 return nil, err
167 }
168 for k, v := range iMap {
169 retmap[k] = fmt.Sprintf("%v", v)
170 }
171 return retmap, nil
172 }
173
174
175
176 func getTransport(conf HostConfig) (*tls.Config, error) {
177 if !conf.UsesTLS() {
178 return nil, nil
179 }
180 tlsClientConfig := new(tls.Config)
181
182 if conf.CAFile != "" {
183 caCert, err := os.ReadFile(conf.CAFile)
184 if err != nil {
185 return nil, fmt.Errorf("cannot load CA file: %w", err)
186 }
187 caCertPool := x509.NewCertPool()
188 if !caCertPool.AppendCertsFromPEM(caCert) {
189 return nil, fmt.Errorf("no PEM certificates found in CA file %s", conf.CAFile)
190 }
191 tlsClientConfig.RootCAs = caCertPool
192 }
193
194 if conf.CertFile != "" {
195 cert, err := tls.LoadX509KeyPair(conf.CertFile, conf.KeyFile)
196 if err != nil {
197 return nil, fmt.Errorf("cannot load client cert or key file: %w", err)
198 }
199 tlsClientConfig.Certificates = []tls.Certificate{cert}
200 }
201
202 return tlsClientConfig, nil
203 }
204
205 func (r *Resolver) getEtcdClusterMembers(s Source, hc HostConfig) ([]PatroniClusterMember, error) {
206 var ret = make([]PatroniClusterMember, 0)
207 var cfg client.Config
208
209 if len(hc.DcsEndpoints) == 0 {
210 return ret, errors.New("missing ETCD connect info, make sure host config has a 'dcs_endpoints' key")
211 }
212
213 tlsConfig, err := getTransport(hc)
214 if err != nil {
215 return nil, err
216 }
217 cfg = client.Config{
218 Endpoints: hc.DcsEndpoints,
219 TLS: tlsConfig,
220 DialKeepAliveTimeout: time.Second,
221 Username: hc.Username,
222 Password: hc.Password,
223 DialTimeout: 5 * time.Second,
224 Logger: zap.NewNop(),
225 }
226
227 c, err := client.New(cfg)
228 if err != nil {
229 return ret, err
230 }
231 defer c.Close()
232
233 ctx, cancel := context.WithTimeoutCause(context.Background(), 5*time.Second, errors.New("etcd client timeout"))
234 defer cancel()
235
236
237
238
239
240 resp, err := c.Get(ctx, hc.Path, client.WithPrefix())
241 if err != nil {
242 return ret, cmp.Or(context.Cause(ctx), err)
243 }
244
245 for _, node := range resp.Kvs {
246
247 parts := strings.Split(strings.TrimPrefix(string(node.Key), "/"), "/")
248 if len(parts) < 4 || parts[2] != "members" {
249 continue
250 }
251 nodeData, err := jsonTextToStringMap(string(node.Value))
252 if err != nil {
253 logger.Errorf("Could not parse ETCD node data for node \"%s\": %s", node.Key, err)
254 continue
255 }
256 role := nodeData["role"]
257 connURL := nodeData["conn_url"]
258 scope := parts[1]
259 name := parts[3]
260 ret = append(ret, PatroniClusterMember{Scope: scope, ConnURL: connURL, Role: role, Name: name})
261 }
262
263 r.mu.Lock()
264 r.lastFoundClusterMembers[s.Name] = ret
265 r.mu.Unlock()
266 return ret, nil
267 }
268
269 const (
270 dcsTypeEtcd = "etcd"
271 dcsTypeZookeeper = "zookeeper"
272 dcsTypeConsul = "consul"
273 )
274
275 type HostConfig struct {
276 DcsType string `yaml:"dcs_type"`
277 DcsEndpoints []string `yaml:"dcs_endpoints"`
278 Path string
279 Username string
280 Password string
281 CAFile string `yaml:"ca_file"`
282 CertFile string `yaml:"cert_file"`
283 KeyFile string `yaml:"key_file"`
284 }
285
286
287 func (hc HostConfig) UsesTLS() bool {
288 return hc.CAFile != "" || hc.CertFile != "" || hc.KeyFile != ""
289 }
290
291 func (hc HostConfig) IsScopeSpecified() bool {
292
293
294 return strings.Count(hc.Path, "/") >= 2
295 }
296
297 func NewHostConfig(URI string) (hc HostConfig, err error) {
298
299 before, after, ok := strings.Cut(URI, "://")
300 if !ok {
301 return hc, fmt.Errorf("invalid URI: missing scheme")
302 }
303 scheme := before
304 remainder := after
305
306
307 hostEnd := strings.IndexAny(remainder, "/?")
308 var hostPart, pathAndQuery string
309 if hostEnd == -1 {
310 hostPart = remainder
311 pathAndQuery = ""
312 } else {
313 hostPart = remainder[:hostEnd]
314 pathAndQuery = remainder[hostEnd:]
315 }
316
317
318 var userInfo string
319 if atIdx := strings.LastIndex(hostPart, "@"); atIdx != -1 {
320 userInfo = hostPart[:atIdx]
321 hostPart = hostPart[atIdx+1:]
322 }
323
324
325 hosts := strings.Split(hostPart, ",")
326
327
328 cleanURI := scheme + "://"
329 if userInfo != "" {
330 cleanURI += userInfo + "@"
331 }
332 cleanURI += hosts[0] + pathAndQuery
333
334 var url *url.URL
335 url, err = url.Parse(cleanURI)
336 if err != nil {
337 return
338 }
339
340 hc.Path = url.Path
341 hc.Username = url.User.Username()
342 hc.Password, _ = url.User.Password()
343 hc.CAFile = url.Query().Get("ca_file")
344 hc.CertFile = url.Query().Get("cert_file")
345 hc.KeyFile = url.Query().Get("key_file")
346 if (hc.CertFile == "") != (hc.KeyFile == "") {
347 return hc, errors.New("cert_file and key_file must be set together")
348 }
349
350 switch url.Scheme {
351 case dcsTypeEtcd:
352 hc.DcsType = dcsTypeEtcd
353
354 endpointScheme := "http://"
355 if hc.UsesTLS() {
356 endpointScheme = "https://"
357 }
358 for _, h := range hosts {
359 hc.DcsEndpoints = append(hc.DcsEndpoints, endpointScheme+h)
360 }
361 case dcsTypeZookeeper:
362 hc.DcsType = dcsTypeZookeeper
363 hc.DcsEndpoints = hosts
364 case dcsTypeConsul:
365 hc.DcsType = dcsTypeConsul
366 hc.DcsEndpoints = hosts
367 default:
368 return hc, fmt.Errorf("unsupported DCS type: %s", url.Scheme)
369 }
370
371 return hc, nil
372 }
373
374 func (r *Resolver) ResolveDatabasesFromPatroni(source Source) (SourceConns, error) {
375 var mds SourceConns
376 var clusterMembers []PatroniClusterMember
377 var err error
378 var ok bool
379
380 hostConfig, err := NewHostConfig(source.ConnStr)
381 if err != nil {
382 return nil, err
383 }
384
385 switch hostConfig.DcsType {
386 case dcsTypeEtcd:
387 clusterMembers, err = r.getEtcdClusterMembers(source, hostConfig)
388 case dcsTypeZookeeper:
389 clusterMembers, err = getZookeeperClusterMembers(source, hostConfig)
390 case dcsTypeConsul:
391 clusterMembers, err = getConsulClusterMembers(source)
392 default:
393 return nil, errors.New("unknown DCS")
394 }
395 logger := logger.WithField("source", source.Name)
396 if err != nil {
397 if errors.Is(err, errors.ErrUnsupported) {
398 return nil, err
399 }
400 logger.Debug("failed to get info from DCS, using previous member info if any")
401 r.mu.Lock()
402 clusterMembers, ok = r.lastFoundClusterMembers[source.Name]
403 r.mu.Unlock()
404 if ok {
405 err = nil
406 }
407 } else {
408 r.mu.Lock()
409 r.lastFoundClusterMembers[source.Name] = clusterMembers
410 r.mu.Unlock()
411 }
412 if len(clusterMembers) == 0 {
413 return mds, err
414 }
415
416 for _, patroniMember := range clusterMembers {
417 logger.Info("processing Patroni cluster member: ", patroniMember.Name)
418 if source.OnlyIfMaster && !patroniMember.IsPrimary() {
419 continue
420 }
421 src := *source.Clone()
422 src.ConnStr = patroniMember.ConnURL
423 if !hostConfig.IsScopeSpecified() {
424 src.Name += "_" + patroniMember.Scope
425 }
426 src.Name += "_" + patroniMember.Name
427 if dbs, err := r.ResolveDatabasesFromPostgres(src); err == nil {
428 mds = append(mds, dbs...)
429 } else {
430 logger.WithError(err).Error("failed to resolve databases for Patroni member: ", patroniMember.Name)
431 }
432 }
433 return mds, err
434 }
435
436
437
438
439
440
441
442
443
444
445
446
447 func (r *Resolver) ResolveDatabasesFromPostgres(s Source) (resolvedDbs SourceConns, err error) {
448 resolvedDbs, err = resolveDatabasesFromPostgres(s)
449 key := postgresDiscoveryKeyOf(s)
450 r.mu.Lock()
451 defer r.mu.Unlock()
452 if err == nil {
453 r.lastFoundDatabases[key] = resolvedDbs
454 return resolvedDbs, nil
455 }
456 cached, ok := r.lastFoundDatabases[key]
457 if ok && len(cached) > 0 {
458 logger.WithField("source", s.Name).WithError(err).Warning("postgres discovery failed; serving last-known-good database list from cache")
459 return cached, nil
460 }
461 return nil, err
462 }
463
464
465 func resolveDatabasesFromPostgres(s Source) (resolvedDbs SourceConns, err error) {
466 var (
467 c db.PgxPoolIface
468 dbname string
469 rows pgx.Rows
470 )
471 ctx, cancel := db.WithOpTimeout(context.Background(), "resolve "+s.Name, db.ResolverTimeout)
472 defer cancel()
473
474 c, err = NewConn(ctx, s.ConnStr)
475 if err != nil {
476 return nil, cmp.Or(context.Cause(ctx), err)
477 }
478 defer c.Close()
479
480 sql := `select /* pgwatch_generated */
481 datname
482 from pg_database
483 where not datistemplate
484 and datallowconn
485 and has_database_privilege (datname, 'CONNECT')
486 and case when length(trim($1)) > 0 then datname ~ $1 else true end
487 and case when length(trim($2)) > 0 then not datname ~ $2 else true end`
488
489 if rows, err = c.Query(ctx, sql, s.IncludePattern, s.ExcludePattern); err != nil {
490 return nil, cmp.Or(context.Cause(ctx), err)
491 }
492 for rows.Next() {
493 if err = rows.Scan(&dbname); err != nil {
494 return nil, err
495 }
496 rdb := NewDbConn(*s.Clone())
497 rdb.Name += "_" + dbname
498 rdb.SetDatabaseName(dbname)
499 resolvedDbs = append(resolvedDbs, rdb)
500 }
501
502 if err := rows.Err(); err != nil {
503 return nil, err
504 }
505 return
506 }
507