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 md.ConnConfig.ShouldPing = func(context.Context, pgxpool.ShouldPingParams) bool { return false }
182 }
183 if opts.MaxParallelConnectionsPerDb > 0 {
184 md.ConnConfig.MaxConns = int32(opts.MaxParallelConnectionsPerDb)
185 }
186 md.Conn, err = NewConnWithConfig(ctx, md.ConnConfig)
187 if err != nil {
188 return err
189 }
190 }
191 return md.Ping(ctx)
192 }
193
194
195 func (md *DbConn) ParseConfig() (err error) {
196 if md.ConnConfig == nil {
197 md.ConnConfig, err = pgxpool.ParseConfig(md.ConnStr)
198 return
199 }
200 return
201 }
202
203
204
205 func (md *DbConn) GetClusterIdentifier() string {
206 if err := md.ParseConfig(); err != nil {
207 return ""
208 }
209 md.RLock()
210 defer md.RUnlock()
211 return fmt.Sprintf("%s:%s:%d", md.SystemIdentifier, md.ConnConfig.ConnConfig.Host, md.ConnConfig.ConnConfig.Port)
212 }
213
214
215 func (md *DbConn) GetDatabaseName() string {
216 if err := md.ParseConfig(); err != nil {
217 return ""
218 }
219 return md.ConnConfig.ConnConfig.Database
220 }
221
222
223 func (md *DbConn) GetMetricInterval(name string) time.Duration {
224 md.RLock()
225 defer md.RUnlock()
226 if md.IsInRecovery && len(md.MetricsStandby) > 0 {
227 return time.Duration(md.MetricsStandby[name]) * time.Second
228 }
229 return time.Duration(md.Metrics[name]) * time.Second
230 }
231
232
233 func (md *DbConn) IsClientOnSameHost() bool {
234 ok, err := db.IsClientOnSameHost(md.Conn)
235 return ok && err == nil
236 }
237
238
239 func (md *DbConn) SetDatabaseName(name string) {
240 if err := md.ParseConfig(); err != nil {
241 return
242 }
243 md.ConnStr = ""
244 md.ConnConfig.ConnConfig.Database = name
245 }
246
247 func (md *DbConn) IsPostgresSource() bool {
248 switch md.Kind {
249 case SourcePostgres, SourcePatroniDiscovery, SourcePostgresDiscovery:
250 return true
251 default:
252 return false
253 }
254 }
255
256
257
258
259
260 var regVer = regexp.MustCompile(`(\d+).?(\d*).?(\d*)`)
261
262 func VersionToInt(version string) (v int) {
263 if matches := regVer.FindStringSubmatch(version); len(matches) > 1 {
264 for i, match := range matches[1:] {
265 v += func() (m int) { m, _ = strconv.Atoi(match); return }() * int(math.Pow10(4-i*2))
266 }
267 }
268 return
269 }
270
271 func (md *DbConn) FetchRuntimeInfo(ctx context.Context, forceRefetch bool) (err error) {
272
273
274 if !forceRefetch && time.Duration(time.Now().UnixNano()-md.lastCheckedNs.Load()) < 5*time.Minute {
275 return
276 }
277 if ctx.Err() != nil {
278 return ctx.Err()
279 }
280
281 md.Lock()
282 defer md.Unlock()
283
284 switch md.Kind {
285 case SourcePgBouncer, SourcePgPool:
286 err = md.FetchVersion(ctx, md.Kind)
287 default:
288 err = errors.Join(
289 md.FetchControlInfo(ctx),
290 md.DiscoverPlatform(ctx),
291 md.FetchApproxSize(ctx),
292 md.FetchExtensions(ctx))
293 }
294 if err == nil {
295 md.lastCheckedNs.Store(time.Now().UnixNano())
296 }
297 return
298 }
299
300
301 func (md *DbConn) FetchControlInfo(ctx context.Context) error {
302 sql := `select /* pgwatch_generated */
303 div(current_setting('server_version_num')::int, 10000) as ver,
304 version(),
305 pg_is_in_recovery(),
306 current_database()::TEXT,
307 system_identifier,
308 current_setting('is_superuser')::bool
309 FROM
310 pg_control_system()`
311 controlCtx, controlCancel := db.WithOpTimeout(ctx, "runtime_info control", db.RuntimeInfoTimeout)
312 defer controlCancel()
313 return md.Conn.QueryRow(controlCtx, sql).
314 Scan(&md.Version, &md.VersionStr,
315 &md.IsInRecovery, &md.RealDbname,
316 &md.SystemIdentifier, &md.IsSuperuser)
317 }
318
319
320 func (md *DbConn) FetchExtensions(ctx context.Context) error {
321 sqlExtensions := `select /* pgwatch_generated */ extname::text, (regexp_matches(extversion, $$\d+\.?\d+?$$))[1]::text as extversion from pg_extension order by 1;`
322 extCtx, extCancel := db.WithOpTimeout(ctx, "runtime_info extensions", db.RuntimeInfoTimeout)
323 defer extCancel()
324 res, err := md.Conn.Query(extCtx, sqlExtensions)
325 if err != nil {
326 return err
327 }
328 var ext, ver string
329 _, err = pgx.ForEachRow(res, []any{&ext, &ver}, func() error {
330 extver := VersionToInt(ver)
331 if extver == 0 {
332 return fmt.Errorf("unexpected extension %s version input: %s", ext, ver)
333 }
334 md.Extensions[ext] = extver
335 return nil
336 })
337 return err
338 }
339
340 func (md *DbConn) FetchVersion(ctx context.Context, kind Kind) (err error) {
341 sqls := map[Kind]string{SourcePgBouncer: "SHOW VERSION", SourcePgPool: "SHOW POOL_VERSION"}
342 versionCtx, versionCancel := db.WithOpTimeout(ctx, "runtime_info version", db.RuntimeInfoTimeout)
343 defer versionCancel()
344 if err = md.Conn.QueryRow(versionCtx, sqls[kind], pgx.QueryExecModeSimpleProtocol).Scan(&md.VersionStr); err != nil {
345 return
346 }
347 md.Version = VersionToInt(md.VersionStr)
348 return
349 }
350
351
352
353 func (md *DbConn) DiscoverPlatform(ctx context.Context) error {
354 if md.ExecEnv != "" {
355 return nil
356 }
357 platformCtx, platformCancel := db.WithOpTimeout(ctx, "runtime_info platform", db.RuntimeInfoTimeout)
358 defer platformCancel()
359 sql := `select /* pgwatch_generated */
360 case
361 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'
362 when exists (select * from pg_settings where name = 'pg_qs.host_database' and setting = 'azure_sys') and version() ~* 'compiled by gcc' then 'AZURE_FLEXIBLE'
363 when exists (select * from pg_settings where name = 'cloudsql.supported_extensions') then 'GOOGLE'
364 else
365 'UNKNOWN'
366 end as exec_env`
367 return md.Conn.QueryRow(platformCtx, sql).Scan(&md.ExecEnv)
368 }
369
370
371 func (md *DbConn) FetchApproxSize(ctx context.Context) error {
372 sizeCtx, sizeCancel := db.WithOpTimeout(ctx, "runtime_info size", db.RuntimeInfoTimeout)
373 defer sizeCancel()
374 sqlApproxDBSize := `select /* pgwatch_generated */ current_setting('block_size')::int8 * sum(relpages) from pg_class c where c.relpersistence != 't'`
375 return md.Conn.QueryRow(sizeCtx, sqlApproxDBSize).Scan(&md.ApproxDbSize)
376 }
377
378
379 func (md *DbConn) FunctionExists(ctx context.Context, functionName string) (exists bool) {
380 sql := `select /* pgwatch_generated */ true
381 from
382 pg_proc join pg_namespace n on pronamespace = n.oid
383 where
384 proname = $1 and n.nspname = 'public'`
385 _ = md.Conn.QueryRow(ctx, sql, functionName).Scan(&exists)
386 return
387 }
388
389
390 func (md *DbConn) TryCreateMissingExtensions(ctx context.Context, extensions []string) (string, error) {
391
392 md.RLock()
393 knownExts := maps.Clone(md.Extensions)
394 md.RUnlock()
395
396 sqlAvailableExts := `select name::text from pg_available_extensions order by 1`
397 createdExts := make([]string, 0)
398
399 availableCtx, availableCancel := db.WithOpTimeout(ctx, "available extensions", db.RuntimeInfoTimeout)
400 data, err := md.Conn.Query(availableCtx, sqlAvailableExts)
401 if err != nil {
402 availableCancel()
403 return "", err
404 }
405 availableExts, err := pgx.CollectRows(data, pgx.RowTo[string])
406 availableCancel()
407 if err != nil {
408 return "", err
409 }
410
411 for _, extToCreate := range extensions {
412 if _, ok := knownExts[extToCreate]; ok {
413 continue
414 }
415 if _, ok := slices.BinarySearch(availableExts, extToCreate); !ok {
416 err = errors.Join(err, fmt.Errorf("requested extension %s is not available on instance", extToCreate))
417 continue
418 }
419 if _, e := md.Conn.Exec(ctx, fmt.Sprintf(`create extension if not exists "%s"`, extToCreate)); e != nil {
420 err = errors.Join(err, fmt.Errorf("failed to create extension %s: %w", extToCreate, e))
421 } else {
422 createdExts = append(createdExts, extToCreate)
423 }
424 }
425 return strings.Join(createdExts, ","), err
426 }
427
428
429 func (md *DbConn) TryCreateMetricsHelpers(ctx context.Context, getSQLFn func(string) string) (err error) {
430
431 md.RLock()
432 metricsMap := maps.Clone(md.Metrics)
433 maps.Insert(metricsMap, maps.All(md.MetricsStandby))
434 md.RUnlock()
435
436 var sql string
437 for metricName := range metricsMap {
438 if sql = getSQLFn(metricName); sql == "" {
439 continue
440 }
441 if _, e := md.Conn.Exec(ctx, sql); e != nil {
442 err = errors.Join(err, fmt.Errorf("failed to create helper for metric %s: %w", metricName, e))
443 }
444 }
445 return
446 }
447
448 func (mds SourceConns) GetMonitoredDatabase(DBUniqueName string) SourceConn {
449 for _, md := range mds {
450 if md.GetSource().Name == DBUniqueName {
451 return md
452 }
453 }
454 return nil
455 }
456
457
458
459 func RedactURL(rawURL string) string {
460 u, err := url.Parse(rawURL)
461 if err != nil {
462 return rawURL
463 }
464 if u.User == nil {
465 return rawURL
466 }
467 if _, hasPass := u.User.Password(); !hasPass {
468 return rawURL
469 }
470 u.User = url.UserPassword(u.User.Username(), "xxxxx")
471 return u.String()
472 }
473
474
475
476 type promConnConfig struct {
477 URL string
478 Userinfo *url.Userinfo
479 TLSConfig *tls.Config
480 }
481
482
483 type PromConn struct {
484 Source
485 connConfig *promConnConfig
486 HTTPClient *http.Client
487 sync.RWMutex
488 }
489
490 func NewPromConn(s Source) *PromConn {
491 return &PromConn{
492 Source: s,
493 }
494 }
495
496
497
498 func (pc *PromConn) ParseConfig() error {
499 if pc.connConfig != nil {
500 return nil
501 }
502 u, err := url.Parse(pc.ConnStr)
503 if err != nil {
504 return fmt.Errorf("parsing prometheus source URL: %w", err)
505 }
506 userinfo := u.User
507 u.User = nil
508
509 q := u.Query()
510 tlsRootCert := q.Get("tlsrootcert")
511 tlsSkipVerify := q.Get("tlsskipverify") == "true"
512 q.Del("tlsrootcert")
513 q.Del("tlsskipverify")
514 u.RawQuery = q.Encode()
515
516 tlsConfig := &tls.Config{InsecureSkipVerify: tlsSkipVerify}
517 if tlsRootCert != "" {
518 caCert, readErr := os.ReadFile(tlsRootCert)
519 if readErr != nil {
520 return fmt.Errorf("reading tlsrootcert %q: %w", tlsRootCert, readErr)
521 }
522 pool := x509.NewCertPool()
523 pool.AppendCertsFromPEM(caCert)
524 tlsConfig.RootCAs = pool
525 }
526
527 pc.connConfig = &promConnConfig{
528 URL: u.String(),
529 Userinfo: userinfo,
530 TLSConfig: tlsConfig,
531 }
532 return nil
533 }
534
535 func (pc *PromConn) Connect(ctx context.Context, _ CmdOpts) error {
536 pc.Lock()
537 if pc.HTTPClient == nil {
538 if err := pc.ParseConfig(); err != nil {
539 pc.Unlock()
540 return err
541 }
542 pc.HTTPClient = &http.Client{
543 Transport: &http.Transport{TLSClientConfig: pc.connConfig.TLSConfig},
544 Timeout: 30 * time.Second,
545 CheckRedirect: func(*http.Request, []*http.Request) error {
546 return http.ErrUseLastResponse
547 },
548 }
549 }
550 pc.Unlock()
551 return pc.Ping(ctx)
552 }
553
554
555
556
557
558 func (pc *PromConn) Scrape(ctx context.Context) (*http.Response, error) {
559 pc.RLock()
560 client := pc.HTTPClient
561 cfg := pc.connConfig
562 pc.RUnlock()
563
564 if client == nil || cfg == nil {
565 return nil, errors.New("prometheus source not connected: call Connect first")
566 }
567
568 req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
569 if err != nil {
570 return nil, err
571 }
572 req.Header.Set("Accept", "text/plain")
573 if cfg.Userinfo != nil {
574 pass, _ := cfg.Userinfo.Password()
575 req.SetBasicAuth(cfg.Userinfo.Username(), pass)
576 }
577 return client.Do(req)
578 }
579
580 func (pc *PromConn) Ping(ctx context.Context) error {
581 pc.RLock()
582 client := pc.HTTPClient
583 cfg := pc.connConfig
584 pc.RUnlock()
585
586 if client == nil || cfg == nil {
587 return errors.New("prometheus source not connected: call Connect first")
588 }
589
590 req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
591 if err != nil {
592 return err
593 }
594 if cfg.Userinfo != nil {
595 pass, _ := cfg.Userinfo.Password()
596 req.SetBasicAuth(cfg.Userinfo.Username(), pass)
597 }
598
599 resp, err := client.Do(req)
600 if err != nil {
601 return err
602 }
603 _, _ = io.Copy(io.Discard, resp.Body)
604 _ = resp.Body.Close()
605
606 if resp.StatusCode >= 300 {
607 return fmt.Errorf("prometheus ping: unexpected status %s", resp.Status)
608 }
609 return nil
610 }
611
612 func (pc *PromConn) IsPostgresSource() bool { return false }
613 func (pc *PromConn) GetSource() Source { return pc.Source }
614 func (pc *PromConn) FetchRuntimeInfo(_ context.Context, _ bool) error { return nil }
615 func (pc *PromConn) Close() {}
616
617 func (pc *PromConn) GetMetricInterval(name string) time.Duration {
618 pc.RLock()
619 defer pc.RUnlock()
620 return time.Duration(pc.Metrics[name]) * time.Second
621 }
622
623 func (pc *PromConn) SetMetricIntervals(main, _ metrics.MetricIntervals) {
624 pc.Lock()
625 defer pc.Unlock()
626 if main != nil {
627 pc.Metrics = main
628 }
629 }
630