1 package sources
2
3 import (
4 "context"
5 "crypto/tls"
6 "crypto/x509"
7 "errors"
8 "fmt"
9 "io"
10 "maps"
11 "math"
12 "net/http"
13 "net/url"
14 "os"
15 "regexp"
16 "slices"
17 "strconv"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "time"
22
23 "github.com/cybertec-postgresql/pgwatch/v6/internal/db"
24 "github.com/cybertec-postgresql/pgwatch/v6/internal/metrics"
25 "github.com/jackc/pgx/v5"
26 "github.com/jackc/pgx/v5/pgxpool"
27 )
28
29
30 var (
31 NewConn = db.New
32 NewConnWithConfig = db.NewWithConfig
33 )
34
35 const (
36 EnvUnknown = "UNKNOWN"
37 EnvAzureSingle = "AZURE_SINGLE"
38 EnvAzureFlexible = "AZURE_FLEXIBLE"
39 EnvGoogle = "GOOGLE"
40 )
41
42
43 type SourceConn interface {
44 Connect(ctx context.Context, opts CmdOpts) error
45 Ping(ctx context.Context) error
46 IsPostgresSource() bool
47 GetSource() Source
48 GetMetricInterval(name string) time.Duration
49 SetMetricIntervals(main, standby metrics.MetricIntervals)
50 Close()
51 }
52
53
54 var _ SourceConn = (*DbConn)(nil)
55 var _ SourceConn = (*PromConn)(nil)
56
57 type RuntimeInfo struct {
58 IsInRecovery bool
59 VersionStr string
60 Version int
61 RealDbname string
62 SystemIdentifier string
63 IsSuperuser bool
64 Extensions map[string]int
65 ExecEnv string
66 ApproxDbSize int64
67 ChangeState map[string]map[string]string
68 }
69
70
71
72
73 type (
74 DbConn struct {
75 Source
76 Conn db.PgxPoolIface
77 ConnConfig *pgxpool.Config
78 RuntimeInfo
79 lastCheckedNs atomic.Int64
80 sync.RWMutex
81 }
82
83 SourceConns []SourceConn
84 )
85
86 func NewDbConn(s Source) *DbConn {
87 return &DbConn{
88 Source: s,
89 RuntimeInfo: RuntimeInfo{
90 Extensions: make(map[string]int),
91 ChangeState: make(map[string]map[string]string),
92 },
93 }
94 }
95
96
97 func NewSourceConn(s Source) SourceConn {
98 switch s.Kind {
99 case SourcePrometheus:
100 return NewPromConn(s)
101 default:
102 return NewDbConn(s)
103 }
104 }
105
106
107 func (md *DbConn) GetSource() Source {
108 return md.Source
109 }
110
111
112
113
114
115 func (md *DbConn) ActiveMetrics() metrics.MetricIntervals {
116 md.RLock()
117 defer md.RUnlock()
118 if md.IsInRecovery && len(md.MetricsStandby) > 0 {
119 return maps.Clone(md.MetricsStandby)
120 }
121 return maps.Clone(md.Metrics)
122 }
123
124
125 func (md *DbConn) SetMetricIntervals(main, standby metrics.MetricIntervals) {
126 md.Lock()
127 defer md.Unlock()
128 if main != nil {
129 md.Metrics = main
130 }
131 if standby != nil {
132 md.MetricsStandby = standby
133 }
134 }
135
136
137 func (md *DbConn) Close() {
138 if md.Conn != nil {
139 md.Conn.Close()
140 }
141 }
142
143
144 func (md *DbConn) Ping(ctx context.Context) (err error) {
145
146
147
148
149
150
151
152 timeout := 10 * time.Second
153 if md.ConnConfig != nil {
154 ct := time.Duration(0)
155 if md.ConnConfig.ConnConfig != nil {
156 ct = md.ConnConfig.ConnConfig.ConnectTimeout
157 }
158 timeout = ct + db.PingTimeoutMargin
159 }
160 pingCtx, cancel := db.WithOpTimeout(ctx, "ping", timeout)
161 defer cancel()
162
163 if md.Kind == SourcePgBouncer {
164
165 _, err = md.Conn.Exec(pingCtx, "SHOW VERSION")
166 return
167 }
168 return md.Conn.Ping(pingCtx)
169 }
170
171
172
173 func (md *DbConn) Connect(ctx context.Context, opts CmdOpts) (err error) {
174 if md.Conn == nil {
175 if err = md.ParseConfig(); err != nil {
176 return err
177 }
178 if md.Kind == SourcePgBouncer {
179 md.ConnConfig.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
180 }
181 if opts.MaxParallelConnectionsPerDb > 0 {
182 md.ConnConfig.MaxConns = int32(opts.MaxParallelConnectionsPerDb)
183 }
184 md.Conn, err = NewConnWithConfig(ctx, md.ConnConfig)
185 if err != nil {
186 return err
187 }
188 }
189 return md.Ping(ctx)
190 }
191
192
193 func (md *DbConn) ParseConfig() (err error) {
194 if md.ConnConfig == nil {
195 md.ConnConfig, err = pgxpool.ParseConfig(md.ConnStr)
196 return
197 }
198 return
199 }
200
201
202
203 func (md *DbConn) GetClusterIdentifier() string {
204 if err := md.ParseConfig(); err != nil {
205 return ""
206 }
207 md.RLock()
208 defer md.RUnlock()
209 return fmt.Sprintf("%s:%s:%d", md.SystemIdentifier, md.ConnConfig.ConnConfig.Host, md.ConnConfig.ConnConfig.Port)
210 }
211
212
213 func (md *DbConn) GetDatabaseName() string {
214 if err := md.ParseConfig(); err != nil {
215 return ""
216 }
217 return md.ConnConfig.ConnConfig.Database
218 }
219
220
221 func (md *DbConn) GetMetricInterval(name string) time.Duration {
222 md.RLock()
223 defer md.RUnlock()
224 if md.IsInRecovery && len(md.MetricsStandby) > 0 {
225 return time.Duration(md.MetricsStandby[name]) * time.Second
226 }
227 return time.Duration(md.Metrics[name]) * time.Second
228 }
229
230
231 func (md *DbConn) IsClientOnSameHost() bool {
232 ok, err := db.IsClientOnSameHost(md.Conn)
233 return ok && err == nil
234 }
235
236
237 func (md *DbConn) SetDatabaseName(name string) {
238 if err := md.ParseConfig(); err != nil {
239 return
240 }
241 md.ConnStr = ""
242 md.ConnConfig.ConnConfig.Database = name
243 }
244
245 func (md *DbConn) IsPostgresSource() bool {
246 switch md.Kind {
247 case SourcePostgres, SourcePatroniDiscovery, SourcePostgresDiscovery:
248 return true
249 default:
250 return false
251 }
252 }
253
254
255
256
257
258 var regVer = regexp.MustCompile(`(\d+).?(\d*).?(\d*)`)
259
260 func VersionToInt(version string) (v int) {
261 if matches := regVer.FindStringSubmatch(version); len(matches) > 1 {
262 for i, match := range matches[1:] {
263 v += func() (m int) { m, _ = strconv.Atoi(match); return }() * int(math.Pow10(4-i*2))
264 }
265 }
266 return
267 }
268
269 func (md *DbConn) FetchRuntimeInfo(ctx context.Context, forceRefetch bool) (err error) {
270
271
272 if !forceRefetch && time.Duration(time.Now().UnixNano()-md.lastCheckedNs.Load()) < 5*time.Minute {
273 return
274 }
275 if ctx.Err() != nil {
276 return ctx.Err()
277 }
278
279 md.Lock()
280 defer md.Unlock()
281
282 switch md.Kind {
283 case SourcePgBouncer, SourcePgPool:
284 err = md.FetchVersion(ctx, md.Kind)
285 default:
286 err = errors.Join(
287 md.FetchControlInfo(ctx),
288 md.DiscoverPlatform(ctx),
289 md.FetchApproxSize(ctx),
290 md.FetchExtensions(ctx))
291 }
292 if err == nil {
293 md.lastCheckedNs.Store(time.Now().UnixNano())
294 }
295 return
296 }
297
298
299 func (md *DbConn) FetchControlInfo(ctx context.Context) error {
300 sql := `select /* pgwatch_generated */
301 div(current_setting('server_version_num')::int, 10000) as ver,
302 version(),
303 pg_is_in_recovery(),
304 current_database()::TEXT,
305 system_identifier,
306 current_setting('is_superuser')::bool
307 FROM
308 pg_control_system()`
309 controlCtx, controlCancel := db.WithOpTimeout(ctx, "runtime_info control", db.RuntimeInfoTimeout)
310 defer controlCancel()
311 return md.Conn.QueryRow(controlCtx, sql).
312 Scan(&md.Version, &md.VersionStr,
313 &md.IsInRecovery, &md.RealDbname,
314 &md.SystemIdentifier, &md.IsSuperuser)
315 }
316
317
318 func (md *DbConn) FetchExtensions(ctx context.Context) error {
319 sqlExtensions := `select /* pgwatch_generated */ extname::text, (regexp_matches(extversion, $$\d+\.?\d+?$$))[1]::text as extversion from pg_extension order by 1;`
320 extCtx, extCancel := db.WithOpTimeout(ctx, "runtime_info extensions", db.RuntimeInfoTimeout)
321 defer extCancel()
322 res, err := md.Conn.Query(extCtx, sqlExtensions)
323 if err != nil {
324 return err
325 }
326 var ext, ver string
327 _, err = pgx.ForEachRow(res, []any{&ext, &ver}, func() error {
328 extver := VersionToInt(ver)
329 if extver == 0 {
330 return fmt.Errorf("unexpected extension %s version input: %s", ext, ver)
331 }
332 md.Extensions[ext] = extver
333 return nil
334 })
335 return err
336 }
337
338 func (md *DbConn) FetchVersion(ctx context.Context, kind Kind) (err error) {
339 sqls := map[Kind]string{SourcePgBouncer: "SHOW VERSION", SourcePgPool: "SHOW POOL_VERSION"}
340 versionCtx, versionCancel := db.WithOpTimeout(ctx, "runtime_info version", db.RuntimeInfoTimeout)
341 defer versionCancel()
342 if err = md.Conn.QueryRow(versionCtx, sqls[kind], pgx.QueryExecModeSimpleProtocol).Scan(&md.VersionStr); err != nil {
343 return
344 }
345 md.Version = VersionToInt(md.VersionStr)
346 return
347 }
348
349
350
351 func (md *DbConn) DiscoverPlatform(ctx context.Context) error {
352 if md.ExecEnv != "" {
353 return nil
354 }
355 platformCtx, platformCancel := db.WithOpTimeout(ctx, "runtime_info platform", db.RuntimeInfoTimeout)
356 defer platformCancel()
357 sql := `select /* pgwatch_generated */
358 case
359 when exists (select * from pg_settings where name = 'pg_qs.host_database' and setting = 'azure_sys') and version() ~* 'compiled by Visual C' then 'AZURE_SINGLE'
360 when exists (select * from pg_settings where name = 'pg_qs.host_database' and setting = 'azure_sys') and version() ~* 'compiled by gcc' then 'AZURE_FLEXIBLE'
361 when exists (select * from pg_settings where name = 'cloudsql.supported_extensions') then 'GOOGLE'
362 else
363 'UNKNOWN'
364 end as exec_env`
365 return md.Conn.QueryRow(platformCtx, sql).Scan(&md.ExecEnv)
366 }
367
368
369 func (md *DbConn) FetchApproxSize(ctx context.Context) error {
370 sizeCtx, sizeCancel := db.WithOpTimeout(ctx, "runtime_info size", db.RuntimeInfoTimeout)
371 defer sizeCancel()
372 sqlApproxDBSize := `select /* pgwatch_generated */ current_setting('block_size')::int8 * sum(relpages) from pg_class c where c.relpersistence != 't'`
373 return md.Conn.QueryRow(sizeCtx, sqlApproxDBSize).Scan(&md.ApproxDbSize)
374 }
375
376
377 func (md *DbConn) FunctionExists(ctx context.Context, functionName string) (exists bool) {
378 sql := `select /* pgwatch_generated */ true
379 from
380 pg_proc join pg_namespace n on pronamespace = n.oid
381 where
382 proname = $1 and n.nspname = 'public'`
383 _ = md.Conn.QueryRow(ctx, sql, functionName).Scan(&exists)
384 return
385 }
386
387
388 func (md *DbConn) TryCreateMissingExtensions(ctx context.Context, extensions []string) (string, error) {
389
390 md.RLock()
391 knownExts := maps.Clone(md.Extensions)
392 md.RUnlock()
393
394 sqlAvailableExts := `select name::text from pg_available_extensions order by 1`
395 createdExts := make([]string, 0)
396
397 availableCtx, availableCancel := db.WithOpTimeout(ctx, "available extensions", db.RuntimeInfoTimeout)
398 data, err := md.Conn.Query(availableCtx, sqlAvailableExts)
399 if err != nil {
400 availableCancel()
401 return "", err
402 }
403 availableExts, err := pgx.CollectRows(data, pgx.RowTo[string])
404 availableCancel()
405 if err != nil {
406 return "", err
407 }
408
409 for _, extToCreate := range extensions {
410 if _, ok := knownExts[extToCreate]; ok {
411 continue
412 }
413 if _, ok := slices.BinarySearch(availableExts, extToCreate); !ok {
414 err = errors.Join(err, fmt.Errorf("requested extension %s is not available on instance", extToCreate))
415 continue
416 }
417 if _, e := md.Conn.Exec(ctx, fmt.Sprintf(`create extension if not exists "%s"`, extToCreate)); e != nil {
418 err = errors.Join(err, fmt.Errorf("failed to create extension %s: %w", extToCreate, e))
419 } else {
420 createdExts = append(createdExts, extToCreate)
421 }
422 }
423 return strings.Join(createdExts, ","), err
424 }
425
426
427 func (md *DbConn) TryCreateMetricsHelpers(ctx context.Context, getSQLFn func(string) string) (err error) {
428
429 md.RLock()
430 metricsMap := maps.Clone(md.Metrics)
431 maps.Insert(metricsMap, maps.All(md.MetricsStandby))
432 md.RUnlock()
433
434 var sql string
435 for metricName := range metricsMap {
436 if sql = getSQLFn(metricName); sql == "" {
437 continue
438 }
439 if _, e := md.Conn.Exec(ctx, sql); e != nil {
440 err = errors.Join(err, fmt.Errorf("failed to create helper for metric %s: %w", metricName, e))
441 }
442 }
443 return
444 }
445
446 func (mds SourceConns) GetMonitoredDatabase(DBUniqueName string) SourceConn {
447 for _, md := range mds {
448 if md.GetSource().Name == DBUniqueName {
449 return md
450 }
451 }
452 return nil
453 }
454
455
456
457 func RedactURL(rawURL string) string {
458 u, err := url.Parse(rawURL)
459 if err != nil {
460 return rawURL
461 }
462 if u.User == nil {
463 return rawURL
464 }
465 if _, hasPass := u.User.Password(); !hasPass {
466 return rawURL
467 }
468 u.User = url.UserPassword(u.User.Username(), "xxxxx")
469 return u.String()
470 }
471
472
473
474 type promConnConfig struct {
475 URL string
476 Userinfo *url.Userinfo
477 TLSConfig *tls.Config
478 }
479
480
481 type PromConn struct {
482 Source
483 connConfig *promConnConfig
484 HTTPClient *http.Client
485 sync.RWMutex
486 }
487
488 func NewPromConn(s Source) *PromConn {
489 return &PromConn{
490 Source: s,
491 }
492 }
493
494
495
496 func (pc *PromConn) ParseConfig() error {
497 if pc.connConfig != nil {
498 return nil
499 }
500 u, err := url.Parse(pc.ConnStr)
501 if err != nil {
502 return fmt.Errorf("parsing prometheus source URL: %w", err)
503 }
504 userinfo := u.User
505 u.User = nil
506
507 q := u.Query()
508 tlsRootCert := q.Get("tlsrootcert")
509 tlsSkipVerify := q.Get("tlsskipverify") == "true"
510 q.Del("tlsrootcert")
511 q.Del("tlsskipverify")
512 u.RawQuery = q.Encode()
513
514 tlsConfig := &tls.Config{InsecureSkipVerify: tlsSkipVerify}
515 if tlsRootCert != "" {
516 caCert, readErr := os.ReadFile(tlsRootCert)
517 if readErr != nil {
518 return fmt.Errorf("reading tlsrootcert %q: %w", tlsRootCert, readErr)
519 }
520 pool := x509.NewCertPool()
521 pool.AppendCertsFromPEM(caCert)
522 tlsConfig.RootCAs = pool
523 }
524
525 pc.connConfig = &promConnConfig{
526 URL: u.String(),
527 Userinfo: userinfo,
528 TLSConfig: tlsConfig,
529 }
530 return nil
531 }
532
533 func (pc *PromConn) Connect(ctx context.Context, _ CmdOpts) error {
534 pc.Lock()
535 if pc.HTTPClient == nil {
536 if err := pc.ParseConfig(); err != nil {
537 pc.Unlock()
538 return err
539 }
540 pc.HTTPClient = &http.Client{
541 Transport: &http.Transport{TLSClientConfig: pc.connConfig.TLSConfig},
542 Timeout: 30 * time.Second,
543 CheckRedirect: func(*http.Request, []*http.Request) error {
544 return http.ErrUseLastResponse
545 },
546 }
547 }
548 pc.Unlock()
549 return pc.Ping(ctx)
550 }
551
552
553
554
555
556 func (pc *PromConn) Scrape(ctx context.Context) (*http.Response, error) {
557 pc.RLock()
558 client := pc.HTTPClient
559 cfg := pc.connConfig
560 pc.RUnlock()
561
562 if client == nil || cfg == nil {
563 return nil, errors.New("prometheus source not connected: call Connect first")
564 }
565
566 req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
567 if err != nil {
568 return nil, err
569 }
570 req.Header.Set("Accept", "text/plain")
571 if cfg.Userinfo != nil {
572 pass, _ := cfg.Userinfo.Password()
573 req.SetBasicAuth(cfg.Userinfo.Username(), pass)
574 }
575 return client.Do(req)
576 }
577
578 func (pc *PromConn) Ping(ctx context.Context) error {
579 pc.RLock()
580 client := pc.HTTPClient
581 cfg := pc.connConfig
582 pc.RUnlock()
583
584 if client == nil || cfg == nil {
585 return errors.New("prometheus source not connected: call Connect first")
586 }
587
588 req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
589 if err != nil {
590 return err
591 }
592 if cfg.Userinfo != nil {
593 pass, _ := cfg.Userinfo.Password()
594 req.SetBasicAuth(cfg.Userinfo.Username(), pass)
595 }
596
597 resp, err := client.Do(req)
598 if err != nil {
599 return err
600 }
601 _, _ = io.Copy(io.Discard, resp.Body)
602 _ = resp.Body.Close()
603
604 if resp.StatusCode >= 300 {
605 return fmt.Errorf("prometheus ping: unexpected status %s", resp.Status)
606 }
607 return nil
608 }
609
610 func (pc *PromConn) IsPostgresSource() bool { return false }
611 func (pc *PromConn) GetSource() Source { return pc.Source }
612 func (pc *PromConn) FetchRuntimeInfo(_ context.Context, _ bool) error { return nil }
613 func (pc *PromConn) Close() {}
614
615 func (pc *PromConn) GetMetricInterval(name string) time.Duration {
616 pc.RLock()
617 defer pc.RUnlock()
618 return time.Duration(pc.Metrics[name]) * time.Second
619 }
620
621 func (pc *PromConn) SetMetricIntervals(main, _ metrics.MetricIntervals) {
622 pc.Lock()
623 defer pc.Unlock()
624 if main != nil {
625 pc.Metrics = main
626 }
627 }
628