├── internal
├── collector
│ ├── testdata
│ │ ├── dev
│ │ │ ├── dm-1
│ │ │ ├── sda
│ │ │ ├── sdb2
│ │ │ ├── test
│ │ │ └── mapper
│ │ │ │ └── ssd-root
│ │ ├── sys
│ │ │ ├── block
│ │ │ │ ├── sdz
│ │ │ │ │ ├── size
│ │ │ │ │ └── queue
│ │ │ │ │ │ ├── rotational
│ │ │ │ │ │ └── scheduler
│ │ │ │ ├── sda
│ │ │ │ │ ├── size
│ │ │ │ │ └── queue
│ │ │ │ │ │ ├── rotational
│ │ │ │ │ │ └── scheduler
│ │ │ │ ├── sdb
│ │ │ │ │ ├── size
│ │ │ │ │ ├── queue
│ │ │ │ │ │ ├── rotational
│ │ │ │ │ │ └── scheduler
│ │ │ │ │ └── device
│ │ │ │ │ │ └── model
│ │ │ │ └── sdy
│ │ │ │ │ └── queue
│ │ │ │ │ ├── rotational
│ │ │ │ │ └── scheduler
│ │ │ └── devices.system
│ │ │ │ ├── cpu
│ │ │ │ ├── cpu1
│ │ │ │ │ ├── online
│ │ │ │ │ └── cpufreq
│ │ │ │ │ │ └── scaling_governor
│ │ │ │ ├── cpu2
│ │ │ │ │ ├── online
│ │ │ │ │ └── cpufreq
│ │ │ │ │ │ └── scaling_governor
│ │ │ │ ├── cpu4
│ │ │ │ │ ├── online
│ │ │ │ │ └── cpufreq
│ │ │ │ │ │ └── scaling_governor
│ │ │ │ └── cpu0
│ │ │ │ │ └── cpufreq
│ │ │ │ │ └── scaling_governor
│ │ │ │ └── node
│ │ │ │ ├── node0
│ │ │ │ └── dummy
│ │ │ │ └── node1
│ │ │ │ └── dummy
│ │ ├── datadir
│ │ │ ├── pg_hba.conf.golden
│ │ │ ├── pg_ident.conf.golden
│ │ │ ├── postgresql.conf.golden
│ │ │ └── postgresql.log.golden
│ │ ├── proc
│ │ │ ├── uptime.golden
│ │ │ ├── loadavg.golden
│ │ │ ├── stat.invalid
│ │ │ ├── vmstat.invalid.1
│ │ │ ├── vmstat.invalid.2
│ │ │ ├── diskstats.golden
│ │ │ ├── mounts.golden
│ │ │ ├── mounts-short.golden
│ │ │ ├── netdev.golden
│ │ │ ├── meminfo.golden
│ │ │ └── vmstat.golden
│ │ └── etc
│ │ │ └── os-release.golden
│ ├── pgscv_services_test.go
│ ├── postgres_custom.go
│ ├── linux_sysinfo_test.go
│ ├── pgscv_services.go
│ ├── linux_load_average_test.go
│ ├── collector_test.go
│ ├── strings.go
│ ├── linux_netdev_test.go
│ ├── postgres_custom_test.go
│ ├── postgres_stat_ssl_test.go
│ ├── strings_test.go
│ ├── filesystem_common_test.go
│ ├── postgres_conflicts_test.go
│ ├── postgres_stat_slru_test.go
│ ├── postgres_locks_test.go
│ ├── filesystem_common.go
│ ├── postgres_archiver_test.go
│ ├── postgres_indexes_test.go
│ ├── config_test.go
│ ├── linux_network_test.go
│ ├── postgres_replication_slots_test.go
│ ├── linux_filesystem_test.go
│ ├── linux_load_average.go
│ ├── linux_cpu_test.go
│ ├── pgbouncer_stats_test.go
│ ├── postgres_functions_test.go
│ ├── linux_sysconfig_test.go
│ ├── linux_network.go
│ ├── postgres_bgwriter_test.go
│ ├── testing.go
│ ├── postgres_schema_test.go
│ └── pgbouncer_settings_test.go
├── pgscv
│ ├── testdata
│ │ ├── invalid.txt
│ │ ├── pgscv-pull-example.yaml
│ │ ├── pgscv-filters-example.yaml
│ │ ├── pgscv-auth-example.yaml
│ │ ├── pgscv-defaults-example.yaml
│ │ ├── pgscv-services-example.yaml
│ │ ├── pgscv-disable-collectors-example.yaml
│ │ ├── pgscv-collectors-settings-example.yaml
│ │ └── pgscv-full-merge-example.yaml
│ └── pgscv_test.go
├── service
│ ├── testdata
│ │ ├── pgbouncer.ini.d
│ │ │ ├── invalid-port.golden
│ │ │ ├── valid-trim.golden
│ │ │ ├── valid.golden
│ │ │ ├── valid-asterisk.golden
│ │ │ ├── valid-incomplete.golden
│ │ │ ├── valid-non-value-param.golden
│ │ │ └── valid-commented.golden
│ │ └── postmaster.pid.d
│ │ │ ├── invalid.golden
│ │ │ ├── invalid-ts.golden
│ │ │ ├── valid-unix.golden
│ │ │ ├── valid.golden
│ │ │ └── invalid-port.golden
│ ├── testing.go
│ └── config_test.go
├── discovery
│ ├── mapops
│ │ ├── full_join.go
│ │ └── full_join_test.go
│ ├── cloud
│ │ └── yandex
│ │ │ ├── filter.go
│ │ │ ├── key.go
│ │ │ ├── filter_test.go
│ │ │ └── sdk.go
│ └── service
│ │ └── yandex_engine.go
├── store
│ └── testing.go
├── http
│ ├── testing.go
│ ├── http_client_test.go
│ ├── testdata
│ │ ├── example.crt
│ │ └── example.key
│ └── http_client.go
├── filter
│ └── filter.go
└── log
│ └── log.go
├── deploy
├── demo-lab
│ ├── pgbench
│ │ ├── .env
│ │ └── start_pgbench_test.sh
│ ├── patroni
│ │ └── .env
│ ├── pgbouncer
│ │ ├── conf14
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf15
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf16
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf17
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf10
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf11
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf12
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf13
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ ├── conf9
│ │ │ ├── userlist.txt
│ │ │ └── pgbouncer.ini
│ │ └── generate_userlist.sh
│ ├── victoriametrics
│ │ ├── alertmanager.yml
│ │ ├── auth-cluster.yml
│ │ └── auth-cluster-all.yml
│ ├── docker-compose.yml
│ ├── grafana
│ │ └── provisioning
│ │ │ ├── dashboards
│ │ │ └── dashboard.yml
│ │ │ └── datasources
│ │ │ ├── single.yml
│ │ │ └── cluster.yml
│ ├── docker-compose.vm-cluster.yml
│ ├── postgres
│ │ ├── init_9.sql
│ │ ├── init_10.sql
│ │ └── init.sql
│ ├── vmagent
│ │ ├── vmagent.yaml
│ │ └── vmagent-cluster.yaml
│ ├── .env
│ ├── compose.pgscv.yml
│ ├── compose.victoriametrics.yml
│ ├── stop_and_cleanup_data.sh
│ ├── README.md
│ ├── compose.pgbouncer.yml
│ ├── stop_pgbench.sh
│ └── README.VM-CLUSTER.md
├── pgscv.default
├── helm-chart
│ ├── templates
│ │ ├── configmap.yaml
│ │ ├── pgscv.yaml
│ │ ├── deployment.yaml
│ │ └── _helpers.tpl
│ ├── .helmignore
│ ├── values.yaml
│ └── Chart.yaml
├── docker-compose.yaml
├── pgscv.service
└── deployment.yaml
├── .dockerignore
├── .github
├── CODEOWNERS
├── FUNDING.yml
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── default.yml
│ ├── codeql.yml
│ ├── release.yml
│ └── beta.yml
├── docker_entrypoint.sh
├── Dockerfile
├── discovery
├── log
│ └── log.go
├── factory
│ └── sd.go
├── common.go
└── yandex_test.go
├── testing
└── docker-test-runner
│ ├── Dockerfile
│ └── fixtures.sql
├── LICENSE
├── .goreleaser.yml
├── .gitignore
├── cmd
└── pgscv.go
└── go.mod
/internal/collector/testdata/dev/dm-1:
--------------------------------------------------------------------------------
1 | dummy
--------------------------------------------------------------------------------
/internal/collector/testdata/dev/sda:
--------------------------------------------------------------------------------
1 | dummy
--------------------------------------------------------------------------------
/internal/collector/testdata/dev/sdb2:
--------------------------------------------------------------------------------
1 | dummy
--------------------------------------------------------------------------------
/internal/collector/testdata/dev/test:
--------------------------------------------------------------------------------
1 | dummy
--------------------------------------------------------------------------------
/internal/pgscv/testdata/invalid.txt:
--------------------------------------------------------------------------------
1 | invalid
2 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbench/.env:
--------------------------------------------------------------------------------
1 | export PGPASSWORD=pgbench
--------------------------------------------------------------------------------
/deploy/demo-lab/patroni/.env:
--------------------------------------------------------------------------------
1 | export PGPASSWORD=postgres
--------------------------------------------------------------------------------
/internal/collector/testdata/dev/mapper/ssd-root:
--------------------------------------------------------------------------------
1 | ../dm-1
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdz/size:
--------------------------------------------------------------------------------
1 | invalid
2 |
--------------------------------------------------------------------------------
/deploy/pgscv.default:
--------------------------------------------------------------------------------
1 | ARGS='--config-file=/etc/pgscv.yaml'
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sda/size:
--------------------------------------------------------------------------------
1 | 234441648
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdb/size:
--------------------------------------------------------------------------------
1 | 3907029168
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sda/queue/rotational:
--------------------------------------------------------------------------------
1 | 0
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdb/queue/rotational:
--------------------------------------------------------------------------------
1 | 1
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdy/queue/rotational:
--------------------------------------------------------------------------------
1 | 2
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdz/queue/rotational:
--------------------------------------------------------------------------------
1 | 1
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu1/online:
--------------------------------------------------------------------------------
1 | 0
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu2/online:
--------------------------------------------------------------------------------
1 | 1
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/datadir/pg_hba.conf.golden:
--------------------------------------------------------------------------------
1 | # stubfile
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/datadir/pg_ident.conf.golden:
--------------------------------------------------------------------------------
1 | # stubfile
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/datadir/postgresql.conf.golden:
--------------------------------------------------------------------------------
1 | # stubfile
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/uptime.golden:
--------------------------------------------------------------------------------
1 | 187477.47 1397296.12
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdz/queue/scheduler:
--------------------------------------------------------------------------------
1 | invalid
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/node/node0/dummy:
--------------------------------------------------------------------------------
1 | dummy
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/node/node1/dummy:
--------------------------------------------------------------------------------
1 | dummy
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | deploy
2 | testing
3 | .github
4 | README.md
5 | README.ru.md
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu4/online:
--------------------------------------------------------------------------------
1 | invalid
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/loadavg.golden:
--------------------------------------------------------------------------------
1 | 1.15 1.36 1.24 1/2076 2253587
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sda/queue/scheduler:
--------------------------------------------------------------------------------
1 | [mq-deadline] none
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdb/queue/scheduler:
--------------------------------------------------------------------------------
1 | noop [deadline] cfq
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdy/queue/scheduler:
--------------------------------------------------------------------------------
1 | noop [deadline] cfq
2 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-pull-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/block/sdb/device/model:
--------------------------------------------------------------------------------
1 | TEST HARDDISK WITH LONG LONG LONG LONG NAME
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu0/cpufreq/scaling_governor:
--------------------------------------------------------------------------------
1 | powersave
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu1/cpufreq/scaling_governor:
--------------------------------------------------------------------------------
1 | powersave
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu2/cpufreq/scaling_governor:
--------------------------------------------------------------------------------
1 | performance
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/sys/devices.system/cpu/cpu4/cpufreq/scaling_governor:
--------------------------------------------------------------------------------
1 | performance
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | /.github @cherts
2 | /.github/CODEOWNERS @cherts
3 | /.github/workflows/** @cherts
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: cherts
2 | custom: ["https://paypal.me/mikhailgrigorev1981?locale.x=en_US"]
3 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf14/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "pgbench"
2 | "pgscv" "pgscv"
3 | "postgres" "postgres"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf15/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "pgbench"
2 | "pgscv" "pgscv"
3 | "postgres" "postgres"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf16/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "pgbench"
2 | "pgscv" "pgscv"
3 | "postgres" "postgres"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf17/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "pgbench"
2 | "pgscv" "pgscv"
3 | "postgres" "postgres"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/victoriametrics/alertmanager.yml:
--------------------------------------------------------------------------------
1 | route:
2 | receiver: blackhole
3 |
4 | receivers:
5 | - name: blackhole
6 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/stat.invalid:
--------------------------------------------------------------------------------
1 | invalid 3097668 1593 1419618 132242258 42535 0 384686 0 0 0
2 | cpu
3 | ctxt invalid
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/invalid-port.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr = 1.2.3.4
4 | listen_port = invalid
5 |
6 | ; comment
7 |
--------------------------------------------------------------------------------
/internal/service/testdata/postmaster.pid.d/invalid.golden:
--------------------------------------------------------------------------------
1 | 1373 /var/lib/postgresql/12/main 1590225462 5432 /var/run/postgresql * 5432001 32769 ready
2 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/vmstat.invalid.1:
--------------------------------------------------------------------------------
1 | nr_free_pages 509240
2 | invalid_with_text invalid
3 | nr_zone_inactive_anon 129292
4 | nr_zone_active_anon 3113580
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/vmstat.invalid.2:
--------------------------------------------------------------------------------
1 | nr_free_pages 509240
2 | invalid_with_wrong_number_of_field
3 | nr_zone_inactive_anon 129292
4 | nr_zone_active_anon 3113580
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid-trim.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr=1.2.3.4
4 | listen_port=16432
5 | unix_socket_dir=/testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr = 1.2.3.4
4 | listen_port = 16432
5 | unix_socket_dir = /testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid-asterisk.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr = *
4 | listen_port = 16432
5 | unix_socket_dir = /testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid-incomplete.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr =
4 | listen_port = 16432
5 | unix_socket_dir = /testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid-non-value-param.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | listen_addr = 1.2.3.4
4 | listen_port
5 | unix_socket_dir = /testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/pgbouncer.ini.d/valid-commented.golden:
--------------------------------------------------------------------------------
1 | ; comment
2 |
3 | ;listen_addr = 1.2.3.4
4 | ;listen_port = 16432
5 | ;unix_socket_dir = /testdir
6 |
7 | ; comment
8 |
--------------------------------------------------------------------------------
/internal/service/testdata/postmaster.pid.d/invalid-ts.golden:
--------------------------------------------------------------------------------
1 | 1373
2 | /var/lib/postgresql/12/main
3 | invalid
4 | 5432
5 | /var/run/postgresql
6 | *
7 | 5432001 32769
8 | ready
9 |
--------------------------------------------------------------------------------
/internal/service/testdata/postmaster.pid.d/valid-unix.golden:
--------------------------------------------------------------------------------
1 | 593
2 | /var/lib/postgresql/12/main
3 | 1590520126
4 | 5432
5 | /var/run/postgresql
6 |
7 | 5433001 395837447
8 | ready
9 |
--------------------------------------------------------------------------------
/internal/service/testdata/postmaster.pid.d/valid.golden:
--------------------------------------------------------------------------------
1 | 1373
2 | /var/lib/postgresql/12/main
3 | 1590225462
4 | 5432
5 | /var/run/postgresql
6 | *
7 | 5432001 32769
8 | ready
9 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-filters-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 | collectors:
3 | postgres/custom:
4 | filters:
5 | device:
6 | exclude: "^(test|example)$"
--------------------------------------------------------------------------------
/internal/service/testdata/postmaster.pid.d/invalid-port.golden:
--------------------------------------------------------------------------------
1 | 1373
2 | /var/lib/postgresql/12/main
3 | 1590225462
4 | invalid
5 | /var/run/postgresql
6 | *
7 | 5432001 32769
8 | ready
9 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf10/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "md5639270cd84190424fa8aac81550875bc"
2 | "pgscv" "md5cb3d06852b6b47c9df609b62a5e90591"
3 | "postgres" "md53175bce1d3201d16594cebf9d7eb3f9d"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf11/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "md5639270cd84190424fa8aac81550875bc"
2 | "pgscv" "md5cb3d06852b6b47c9df609b62a5e90591"
3 | "postgres" "md53175bce1d3201d16594cebf9d7eb3f9d"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf12/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "md5639270cd84190424fa8aac81550875bc"
2 | "pgscv" "md5cb3d06852b6b47c9df609b62a5e90591"
3 | "postgres" "md53175bce1d3201d16594cebf9d7eb3f9d"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf13/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "md5639270cd84190424fa8aac81550875bc"
2 | "pgscv" "md5cb3d06852b6b47c9df609b62a5e90591"
3 | "postgres" "md53175bce1d3201d16594cebf9d7eb3f9d"
4 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf9/userlist.txt:
--------------------------------------------------------------------------------
1 | "pgbench" "md5639270cd84190424fa8aac81550875bc"
2 | "pgscv" "md5cb3d06852b6b47c9df609b62a5e90591"
3 | "postgres" "md53175bce1d3201d16594cebf9d7eb3f9d"
4 |
--------------------------------------------------------------------------------
/docker_entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ARG1=$1
4 |
5 | case "${ARG1}" in
6 | "bash" | "sh")
7 | echo ${ARG1}
8 | exec "$@"
9 | ;;
10 | *)
11 | exec /bin/pgscv "$@"
12 | ;;
13 | esac
14 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-auth-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 | authentication:
3 | username: user
4 | password: supersecret
5 | keyfile: example.key
6 | certfile: example.cert
--------------------------------------------------------------------------------
/deploy/demo-lab/docker-compose.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - path: compose.postgres.yml
3 | - path: compose.patroni.yml
4 | - path: compose.pgbouncer.yml
5 | - path: compose.pgscv.yml
6 | - path: compose.victoriametrics.yml
7 |
--------------------------------------------------------------------------------
/deploy/demo-lab/grafana/provisioning/dashboards/dashboard.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: Prometheus
5 | orgId: 1
6 | folder: ''
7 | type: file
8 | options:
9 | path: /var/lib/grafana/dashboards
10 |
--------------------------------------------------------------------------------
/deploy/demo-lab/docker-compose.vm-cluster.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - path: compose.postgres.yml
3 | - path: compose.patroni.yml
4 | - path: compose.pgbouncer.yml
5 | - path: compose.victoriametrics.cluster.yml
6 | - path: compose.pgscv.yml
7 |
--------------------------------------------------------------------------------
/deploy/demo-lab/victoriametrics/auth-cluster.yml:
--------------------------------------------------------------------------------
1 | # balance load among vmselects
2 | # see https://docs.victoriametrics.com/vmauth/#load-balancing
3 | unauthorized_user:
4 | url_prefix:
5 | - http://vmselect-1:8481
6 | - http://vmselect-2:8481
7 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/diskstats.golden:
--------------------------------------------------------------------------------
1 | 8 0 sda 118374 28537 5814772 33586 170999 194921 19277944 181605 0 187400 108536 16519 0 5817512 63312
2 | 8 16 sdb 11850 3383 1004986 64473 13797 2051 192184 43282 0 36604 89536 0 0 0 0
3 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/mounts.golden:
--------------------------------------------------------------------------------
1 | /dev/mapper/ssd-root / ext4 rw,relatime,discard,errors=remount-ro 0 0
2 | /dev/sda1 /boot ext3 rw,relatime 0 0
3 | /dev/mapper/ssd-data /data ext4 rw,relatime,discard 0 0
4 | /dev/sdc1 /archive xfs rw,relatime 0 0
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-defaults-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 | defaults:
3 | postgres_username: "testuser"
4 | postgres_password: "testpassword"
5 | pgbouncer_username: "testuser2"
6 | pgbouncer_password: "testapassword2"
7 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/mounts-short.golden:
--------------------------------------------------------------------------------
1 | /dev/mapper/ssd-root / ext4 rw,relatime,discard,errors=remount-ro 0 0
2 | /dev/sda1 /boot ext3 rw,relatime 0 0
3 | /dev/mapper/ssd-data /data ext4 rw,relatime,discard 0 0
4 | /dev/sdc1 /archive xfs rw,relatime 0 0
5 |
--------------------------------------------------------------------------------
/deploy/helm-chart/templates/configmap.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: ConfigMap
3 | metadata:
4 | name: {{ include "pgscv.fullname" . }}-configmap
5 | labels:
6 | {{- include "pgscv.labels" . | nindent 4 }}
7 | data:
8 | pgscv.yaml: {{ .Values.configmap.pgscvYaml | toYaml | indent 1 }}
--------------------------------------------------------------------------------
/deploy/demo-lab/grafana/provisioning/datasources/single.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | datasources:
4 | - name: victoriametrics
5 | type: prometheus
6 | access: proxy
7 | orgId: 1
8 | uid: DS_VM_01
9 | url: http://victoriametrics:8428
10 | basicAuth: false
11 | isDefault: true
12 | editable: true
13 |
--------------------------------------------------------------------------------
/deploy/demo-lab/grafana/provisioning/datasources/cluster.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | datasources:
4 | - name: victoriametrics
5 | type: prometheus
6 | access: proxy
7 | orgId: 1
8 | uid: DS_VM_01
9 | url: http://vmauth:8427/select/0/prometheus
10 | basicAuth: false
11 | isDefault: true
12 | editable: true
13 |
--------------------------------------------------------------------------------
/internal/collector/pgscv_services_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import "testing"
4 |
5 | func TestPgscvServicesCollector_Update(t *testing.T) {
6 | var input = pipelineInput{
7 | required: []string{
8 | "pgscv_services_registered_total",
9 | },
10 | collector: NewPgscvServicesCollector,
11 | }
12 |
13 | pipeline(t, input)
14 | }
15 |
--------------------------------------------------------------------------------
/deploy/demo-lab/victoriametrics/auth-cluster-all.yml:
--------------------------------------------------------------------------------
1 | unauthorized_user:
2 | url_map:
3 | - src_paths:
4 | - "/insert/.*"
5 | url_prefix:
6 | - "http://vminsert-1:8480/"
7 | - "http://vminsert-2:8480/"
8 | - src_paths:
9 | - "/select/.*"
10 | url_prefix:
11 | - "http://vmselect-1:8481/"
12 | - "http://vmselect-2:8481/"
13 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-services-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 | services:
3 | "postgres:5432":
4 | service_type: "postgres"
5 | conninfo: "host=127.0.0.1 port=5432 dbname=pgscv_fixtures user=pgscv"
6 | "pgbouncer:6432":
7 | service_type: "pgbouncer"
8 | conninfo: "host=127.0.0.1 port=6432 dbname=pgbouncer user=pgscv password=pgscv"
9 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 |
4 | # Maintain dependencies for Go modules
5 | - package-ecosystem: gomod
6 | directory: "/"
7 | schedule:
8 | interval: daily
9 | time: "04:00"
10 | open-pull-requests-limit: 10
11 |
12 | # Maintain dependencies for GitHub Actions
13 | - package-ecosystem: "github-actions"
14 | directory: "/"
15 | schedule:
16 | interval: "daily"
--------------------------------------------------------------------------------
/deploy/helm-chart/templates/pgscv.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: {{ include "pgscv.fullname" . }}-pgscv
5 | labels:
6 | link-app: pgscv
7 | {{- include "pgscv.labels" . | nindent 4 }}
8 | spec:
9 | type: {{ .Values.pgscv.type }}
10 | selector:
11 | link-app: pgscv
12 | {{- include "pgscv.selectorLabels" . | nindent 4 }}
13 | ports:
14 | {{- .Values.pgscv.ports | toYaml | nindent 2 -}}
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-disable-collectors-example.yaml:
--------------------------------------------------------------------------------
1 | disable_collectors:
2 | - system
3 | - another-disabled-collector
4 | listen_address: "127.0.0.1:12345"
5 | services:
6 | "postgres":
7 | service_type: "postgres"
8 | conninfo: "host=127.0.0.1 port=5432 dbname=pgscv_fixtures user=pgscv"
9 | "pgbouncer:6432":
10 | service_type: "pgbouncer"
11 | conninfo: "host=127.0.0.1 port=6432 dbname=pgbouncer user=pgscv password=pgscv"
12 |
--------------------------------------------------------------------------------
/deploy/helm-chart/.helmignore:
--------------------------------------------------------------------------------
1 | # Patterns to ignore when building packages.
2 | # This supports shell glob matching, relative path matching, and
3 | # negation (prefixed with !). Only one pattern per line.
4 | .DS_Store
5 | # Common VCS dirs
6 | .git/
7 | .gitignore
8 | .bzr/
9 | .bzrignore
10 | .hg/
11 | .hgignore
12 | .svn/
13 | # Common backup files
14 | *.swp
15 | *.bak
16 | *.tmp
17 | *.orig
18 | *~
19 | # Various IDEs
20 | .project
21 | .idea/
22 | *.tmproj
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/internal/collector/testdata/etc/os-release.golden:
--------------------------------------------------------------------------------
1 | NAME="Ubuntu"
2 | VERSION="20.04.2 LTS (Focal Fossa)"
3 | ID=ubuntu
4 | ID_LIKE=debian
5 |
6 | PRETTY_NAME="Ubuntu 20.04.2 LTS"
7 | VERSION_ID="20.04"
8 | HOME_URL="https://www.ubuntu.com/"
9 | SUPPORT_URL="https://help.ubuntu.com/"
10 | BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
11 | PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
12 | VERSION_CODENAME=focal
13 | UBUNTU_CODENAME=focal
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # stage 1
2 | # __release_tag__ golang 1.25 was released 2025-08-12
3 | FROM golang:1.25 AS build
4 | LABEL stage=intermediate
5 | WORKDIR /app
6 | COPY . .
7 | RUN make build
8 |
9 | # stage 2: scratch
10 | # __release_tag__ alpine 3.22.0 was released 2025-05-30
11 | FROM alpine:3.22.0 AS dist
12 | COPY --from=build /app/bin/pgscv /bin/pgscv
13 | #COPY docker_entrypoint.sh /bin/
14 | EXPOSE 9890
15 | #EXPOSE 6060
16 | ENTRYPOINT ["/bin/pgscv"]
17 | #ENTRYPOINT ["/bin/docker_entrypoint.sh"]
18 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/netdev.golden:
--------------------------------------------------------------------------------
1 | Inter-| Receive | Transmit
2 | face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
3 | enp2s0: 34899781 60249 10 20 30 40 50 447 8935189 63211 60 70 80 90 100 110
4 | lo: 31433180 57694 15 25 48 75 71 18 31433180 57694 12 48 82 38 66 17
5 | wlxc8be19e6279d: 68384665 50991 0 85 0 17 0 44 4138903 29619 1 0 74 0 4 0
6 |
--------------------------------------------------------------------------------
/deploy/demo-lab/postgres/init_9.sql:
--------------------------------------------------------------------------------
1 | CREATE USER pgscv WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgscv';
2 | GRANT SELECT ON pg_stat_database TO pgscv;
3 | ALTER USER pgscv WITH SUPERUSER;
4 | CREATE USER pgbench WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgbench';
5 | SELECT 'CREATE DATABASE pgbench WITH OWNER = pgbench' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'pgbench')\gexec
6 | GRANT ALL PRIVILEGES ON DATABASE pgbench TO pgbench;
7 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
8 | CREATE USER repluser WITH NOCREATEDB NOCREATEROLE LOGIN REPLICATION PASSWORD 'repluser';
9 | \c pgbench
10 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
11 |
--------------------------------------------------------------------------------
/deploy/demo-lab/postgres/init_10.sql:
--------------------------------------------------------------------------------
1 | CREATE USER pgscv WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgscv';
2 | GRANT pg_monitor TO pgscv;
3 | GRANT EXECUTE on FUNCTION pg_current_logfile() TO pgscv;
4 | CREATE USER pgbench WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgbench';
5 | SELECT 'CREATE DATABASE pgbench WITH OWNER = pgbench' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'pgbench')\gexec
6 | GRANT ALL PRIVILEGES ON DATABASE pgbench TO pgbench;
7 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
8 | CREATE USER repluser WITH NOCREATEDB NOCREATEROLE LOGIN REPLICATION PASSWORD 'repluser';
9 | \c pgbench
10 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
11 |
--------------------------------------------------------------------------------
/deploy/demo-lab/postgres/init.sql:
--------------------------------------------------------------------------------
1 | CREATE USER pgscv WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgscv';
2 | GRANT pg_read_server_files, pg_monitor TO pgscv;
3 | GRANT EXECUTE on FUNCTION pg_current_logfile() TO pgscv;
4 | CREATE USER pgbench WITH NOCREATEDB NOCREATEROLE LOGIN PASSWORD 'pgbench';
5 | SELECT 'CREATE DATABASE pgbench WITH OWNER = pgbench' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'pgbench')\gexec
6 | GRANT ALL PRIVILEGES ON DATABASE pgbench TO pgbench;
7 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
8 | CREATE USER repluser WITH NOCREATEDB NOCREATEROLE LOGIN REPLICATION PASSWORD 'repluser';
9 | \c pgbench
10 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
11 |
--------------------------------------------------------------------------------
/deploy/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | pgscv:
3 | image: cherts/pgscv:latest
4 | container_name: pgscv
5 | restart: always
6 | privileged: true
7 | #cpus: 1
8 | #mem_limit: 1g
9 | ports:
10 | - 9890:9890
11 | environment:
12 | PGSCV_DISABLE_COLLECTORS: "system"
13 | PGSCV_LISTEN_ADDRESS: "0.0.0.0:9890"
14 | POSTGRES_DSN: "postgresql://pgscv:secretpassword@example.org:5432/postgres"
15 | #PGBOUNCER_DSN: "postgresql://pgscv:secretpassword@example.org:6432/pgbouncer"
16 | #PATRONI_URL: "http://localhost:8008"
17 | # command:
18 | # - --config-file=/app/conf/pgscv.yaml
19 | # volumes:
20 | # - /etc/pgscv:/app/conf
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/deploy/demo-lab/vmagent/vmagent.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | scrape_interval: 30s
3 |
4 | scrape_configs:
5 | - job_name: 'pgscv'
6 | # static_configs:
7 | # - targets:
8 | # - pgscv:9890
9 | http_sd_configs:
10 | - url: http://pgscv:9890/targets
11 | # metric_relabel_configs:
12 | # truncate query label of postgres_statements_query_info to 72 characters
13 | # - action: replace
14 | # source_labels: [ query ]
15 | # regex: (.{1,72}).*
16 | # target_label: query
17 | # replacement: $1
18 | - job_name: 'vmagent'
19 | static_configs:
20 | - targets: ['vmagent:8429']
21 | - job_name: 'victoriametrics'
22 | static_configs:
23 | - targets: ['victoriametrics:8428']
24 |
--------------------------------------------------------------------------------
/deploy/pgscv.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=pgSCV - PostgreSQL ecosystem metrics collector
3 | Documentation=https://github.com/cherts/pgscv/wiki
4 | Requires=network-online.target
5 | After=network-online.target
6 |
7 | [Service]
8 | Type=simple
9 | User=postgres
10 | Group=postgres
11 | EnvironmentFile=-/etc/default/pgscv
12 | # Start the agent process
13 | ExecStart=/usr/sbin/pgscv $ARGS
14 | # Kill all processes in the cgroup
15 | KillMode=control-group
16 | # Wait reasonable amount of time for agent up/down
17 | TimeoutSec=5
18 | # Restart agent if it crashes
19 | Restart=on-failure
20 | RestartSec=10
21 | # if agent leaks during long period of time, let him to be the first person for eviction
22 | OOMScoreAdjust=1000
23 |
24 | [Install]
25 | WantedBy=multi-user.target
26 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf13/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres13 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = md5
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 |
22 | # Connection sanity checks, timeouts
23 | server_reset_query = "DISCARD ALL"
24 |
25 | # TLS settings
26 |
27 | # Dangerous timeouts
28 | ################## end file ##################
29 |
--------------------------------------------------------------------------------
/internal/discovery/mapops/full_join.go:
--------------------------------------------------------------------------------
1 | // Package mapops implement join ops
2 | package mapops
3 |
4 | // FullJoin implement full join of maps by keys. Keys should be comparable.
5 | func FullJoin[K comparable, V1 any, V2 any](left map[K]V1, right map[K]V2) []struct{ Left, Right *K } {
6 | result := make([]struct{ Left, Right *K }, 0, len(left)+len(right))
7 | for l := range left {
8 | if _, ok := right[l]; !ok {
9 | result = append(result, struct{ Left, Right *K }{Left: &l, Right: nil})
10 | } else {
11 | result = append(result, struct{ Left, Right *K }{Left: &l, Right: &l})
12 | }
13 | }
14 | for r := range right {
15 | if _, ok := left[r]; !ok {
16 | result = append(result, struct{ Left, Right *K }{Left: nil, Right: &r})
17 | }
18 | }
19 | return result
20 | }
21 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf14/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres14 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = scram-sha-256
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 |
22 | # Connection sanity checks, timeouts
23 | server_reset_query = "DISCARD ALL"
24 |
25 | # TLS settings
26 |
27 | # Dangerous timeouts
28 | ################## end file ##################
29 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf15/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres15 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = scram-sha-256
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 |
22 | # Connection sanity checks, timeouts
23 | server_reset_query = "DISCARD ALL"
24 |
25 | # TLS settings
26 |
27 | # Dangerous timeouts
28 | ################## end file ##################
29 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf16/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres16 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = scram-sha-256
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 |
22 | # Connection sanity checks, timeouts
23 | server_reset_query = "DISCARD ALL"
24 |
25 | # TLS settings
26 |
27 | # Dangerous timeouts
28 | ################## end file ##################
29 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf17/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres17 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = scram-sha-256
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 |
22 | # Connection sanity checks, timeouts
23 | server_reset_query = "DISCARD ALL"
24 |
25 | # TLS settings
26 |
27 | # Dangerous timeouts
28 | ################## end file ##################
29 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf9/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres9 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = md5
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 | stats_users = postgres,pgscv
22 |
23 | # Connection sanity checks, timeouts
24 | server_reset_query = "DISCARD ALL"
25 |
26 | # TLS settings
27 |
28 | # Dangerous timeouts
29 | ################## end file ##################
30 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf10/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres10 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = md5
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 | stats_users = postgres,pgscv
22 |
23 | # Connection sanity checks, timeouts
24 | server_reset_query = "DISCARD ALL"
25 |
26 | # TLS settings
27 |
28 | # Dangerous timeouts
29 | ################## end file ##################
30 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf11/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres11 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = md5
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 | stats_users = postgres,pgscv
22 |
23 | # Connection sanity checks, timeouts
24 | server_reset_query = "DISCARD ALL"
25 |
26 | # TLS settings
27 |
28 | # Dangerous timeouts
29 | ################## end file ##################
30 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/conf12/pgbouncer.ini:
--------------------------------------------------------------------------------
1 | ################## Auto generated ##################
2 | [databases]
3 | pgbench = host=postgres12 port=5432 auth_user=pgbench
4 |
5 | [pgbouncer]
6 | listen_addr = 0.0.0.0
7 | listen_port = 5432
8 | unix_socket_dir =
9 | user = postgres
10 | auth_file = /etc/pgbouncer/userlist.txt
11 | auth_type = md5
12 | pool_mode = transaction
13 | default_pool_size = 20
14 | min_pool_size = 5
15 | reserve_pool_size = 5
16 | max_client_conn = 100
17 | ignore_startup_parameters = extra_float_digits
18 |
19 | # Log settings
20 | admin_users = postgres,pgscv
21 | stats_users = postgres,pgscv
22 |
23 | # Connection sanity checks, timeouts
24 | server_reset_query = "DISCARD ALL"
25 |
26 | # TLS settings
27 |
28 | # Dangerous timeouts
29 | ################## end file ##################
30 |
--------------------------------------------------------------------------------
/internal/store/testing.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | // TestPostgresConnStr PostgreSQL connection string
10 | const TestPostgresConnStr = "host=127.0.0.1 port=5432 user=pgscv dbname=pgscv_fixtures sslmode=disable"
11 |
12 | // TestPgbouncerConnStr Pgbouncer connection string
13 | const TestPgbouncerConnStr = "host=127.0.0.1 port=6432 user=pgscv dbname=pgbouncer sslmode=disable password=pgscv"
14 |
15 | // NewTest create PostgreSQL test
16 | func NewTest(t *testing.T) *DB {
17 | db, err := New(TestPostgresConnStr, 0)
18 | assert.NoError(t, err)
19 | return db
20 | }
21 |
22 | // NewTestPgbouncer create Pgbouncer test
23 | func NewTestPgbouncer(t *testing.T) *DB {
24 | db, err := New(TestPgbouncerConnStr, 0)
25 | assert.NoError(t, err)
26 | return db
27 | }
28 |
--------------------------------------------------------------------------------
/internal/http/testing.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | // TestServer create http test server
13 | func TestServer(t *testing.T, code int, response string) *httptest.Server {
14 | return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
15 | if code == http.StatusOK {
16 | if response != "" {
17 | _, err := fmt.Fprint(rw, response)
18 | assert.NoError(t, err)
19 | } else {
20 | rw.WriteHeader(code)
21 | }
22 | } else {
23 | rw.WriteHeader(code)
24 | }
25 | }))
26 | }
27 |
28 | // TestFileServer create http test server
29 | func TestFileServer(_ *testing.T, dir string) *httptest.Server {
30 | return httptest.NewServer(http.FileServer(http.Dir(dir)))
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/default.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Default
3 |
4 | on:
5 | workflow_dispatch:
6 | push:
7 | paths:
8 | - "cmd/**"
9 | - "internal/**"
10 | - "discovery/**"
11 | - "Makefile"
12 | - "go.mod"
13 | - "go.sum"
14 | pull_request:
15 | paths:
16 | - "cmd/**"
17 | - "internal/**"
18 | - "discovery/**"
19 | - "Makefile"
20 | - "go.mod"
21 | - "go.sum"
22 |
23 | jobs:
24 | test:
25 | runs-on: ubuntu-22.04
26 | container: cherts/pgscv-test-runner:1.0.12
27 |
28 | steps:
29 | - name: Checkout code
30 | uses: actions/checkout@v6
31 | - name: Prepare test environment
32 | run: prepare-test-environment.sh
33 | - name: Check code modernization
34 | run: make modernize-check
35 | - name: Run tests
36 | run: make test
37 |
--------------------------------------------------------------------------------
/deploy/demo-lab/vmagent/vmagent-cluster.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | scrape_interval: 30s
3 |
4 | scrape_configs:
5 | - job_name: 'pgscv'
6 | http_sd_configs:
7 | - url: http://pgscv:9890/targets
8 | no_stale_markers: true
9 | - job_name: 'vmagent'
10 | static_configs:
11 | - targets: ['vmagent-1:8429', 'vmagent-2:8429']
12 | - job_name: 'vmauth'
13 | static_configs:
14 | - targets: ['vmauth:8427']
15 | - job_name: 'vmalert'
16 | static_configs:
17 | - targets: ['vmalert:8880']
18 | - job_name: 'vminsert'
19 | static_configs:
20 | - targets: ['vminsert-1:8480', 'vminsert-2:8480']
21 | - job_name: 'vmselect'
22 | static_configs:
23 | - targets: ['vmselect-1:8481', 'vmselect-2:8481']
24 | - job_name: 'vmstorage'
25 | static_configs:
26 | - targets: ['vmstorage-1:8482', 'vmstorage-2:8482']
27 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | - cron: '32 5 * * 1'
7 |
8 | jobs:
9 | analyze:
10 | name: Analyze
11 | runs-on: ubuntu-22.04
12 | permissions:
13 | actions: read
14 | contents: read
15 | security-events: write
16 | packages: read
17 | strategy:
18 | fail-fast: false
19 | matrix:
20 | language: [ 'go' ]
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v6
24 | - name: Initialize CodeQL
25 | uses: github/codeql-action/init@v4
26 | with:
27 | languages: ${{ matrix.language }}
28 | - name: Autobuild
29 | uses: github/codeql-action/autobuild@v4
30 | - name: Perform CodeQL Analysis
31 | uses: github/codeql-action/analyze@v4
32 | with:
33 | category: "/language:${{matrix.language}}"
34 |
--------------------------------------------------------------------------------
/internal/collector/postgres_custom.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/prometheus/client_golang/prometheus"
7 | )
8 |
9 | type postgresCustomCollector struct {
10 | custom []typedDescSet
11 | }
12 |
13 | // NewPostgresCustomCollector returns a new Collector that expose user-defined postgres metrics.
14 | func NewPostgresCustomCollector(constLabels labels, settings model.CollectorSettings) (Collector, error) {
15 | return &postgresCustomCollector{
16 | custom: newDeskSetsFromSubsystems("postgres", settings.Subsystems, constLabels),
17 | }, nil
18 | }
19 |
20 | // Update method collects statistics, parse it and produces metrics that are sent to Prometheus.
21 | func (c *postgresCustomCollector) Update(config Config, ch chan<- prometheus.Metric) error {
22 | return updateAllDescSets(config, c.custom, ch)
23 | }
24 |
--------------------------------------------------------------------------------
/deploy/helm-chart/values.yaml:
--------------------------------------------------------------------------------
1 | configmap:
2 | pgscvYaml: |-
3 | listen_address: 0.0.0.0:9890
4 | services:
5 | "postgres:5432":
6 | service_type: "postgres"
7 | conninfo: "postgres://postgres:password@127.0.0.1:5432/postgres"
8 | "pgbouncer:6432":
9 | service_type: "pgbouncer"
10 | conninfo: "postgres://pgbouncer:password@127.0.0.1:6432/pgbouncer"
11 | kubernetesClusterDomain: cluster.local
12 | pgscv:
13 | nodeSelector:
14 | kubernetes.io/os: linux
15 | pgscv:
16 | args:
17 | - --config-file=/app/conf/pgscv.yaml
18 | image:
19 | repository: cherts/pgscv
20 | tag: latest
21 | imagePullPolicy: Always
22 | resources:
23 | limits:
24 | cpu: "1"
25 | ephemeral-storage: 100Mi
26 | memory: 500Mi
27 | ports:
28 | - name: http
29 | port: 9890
30 | protocol: TCP
31 | targetPort: 9890
32 | replicas: 1
33 | type: ClusterIP
34 |
--------------------------------------------------------------------------------
/internal/collector/linux_sysinfo_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 | )
9 |
10 | func TestSysInfoCollector_Update(t *testing.T) {
11 | var input = pipelineInput{
12 | required: []string{
13 | "node_platform_info", "node_os_info",
14 | },
15 | optional: []string{},
16 | collector: NewSysInfoCollector,
17 | }
18 |
19 | pipeline(t, input)
20 | }
21 |
22 | func Test_getSysInfo(t *testing.T) {
23 | info, err := getSysInfo()
24 | assert.NoError(t, err)
25 | assert.NotNil(t, info)
26 | }
27 |
28 | func Test_parseOsRelease(t *testing.T) {
29 | file, err := os.Open(filepath.Clean("testdata/etc/os-release.golden"))
30 | assert.NoError(t, err)
31 | defer func() { _ = file.Close() }()
32 |
33 | name, version, err := parseOsRelease(file)
34 | assert.NoError(t, err)
35 | assert.NotEqual(t, "", name)
36 | assert.NotEqual(t, "", version)
37 | }
38 |
--------------------------------------------------------------------------------
/discovery/log/log.go:
--------------------------------------------------------------------------------
1 | // Package log using for logging SD event
2 | package log
3 |
4 | // Logger struct, defines user log functions
5 | var Logger struct {
6 | Debugf func(string, ...any)
7 | Infof func(string, ...any)
8 | Errorf func(string, ...any)
9 | Debug func(msg string)
10 | }
11 |
12 | // Debug prints message with DEBUG severity
13 | func Debug(msg string) {
14 | if Logger.Debugf == nil {
15 | return
16 | }
17 | Logger.Debug(msg)
18 | }
19 |
20 | // Debugf prints formatted message with DEBUG severity
21 | func Debugf(format string, v ...any) {
22 | if Logger.Debugf == nil {
23 | return
24 | }
25 | Logger.Debugf(format, v...)
26 | }
27 |
28 | // Errorf prints formatted message with ERROR severity
29 | func Errorf(str string, v ...any) {
30 | if Logger.Errorf == nil {
31 | return
32 | }
33 | Logger.Errorf(str, v...)
34 | }
35 |
36 | // Infof prints formatted message with INFO severity
37 | func Infof(str string, v ...any) {
38 | if Logger.Infof == nil {
39 | return
40 | }
41 | Logger.Infof(str, v...)
42 | }
43 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-collectors-settings-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8080"
2 | collectors:
3 | postgres/custom:
4 | echo: "example"
5 | subsystems:
6 | activity:
7 | query: "select datname as database,xact_commit,xact_rollback,blks_read as read,blks_write as write from pg_stat_database"
8 | metrics:
9 | - name: xact_commit_total
10 | usage: COUNTER
11 | labels:
12 | - database
13 | value: xact_commit
14 | description: "description"
15 | - name: "blocks_total"
16 | usage: COUNTER
17 | labels:
18 | - database
19 | labeled_values:
20 | access: [ "read", "write" ]
21 | description: "description"
22 | bgwriter:
23 | query: "select maxwritten_clean from pg_stat_bgwriter"
24 | metrics:
25 | - name: "maxwritten_clean_total"
26 | usage: COUNTER
27 | value: maxwritten_clean
28 | description: "description"
--------------------------------------------------------------------------------
/deploy/demo-lab/.env:
--------------------------------------------------------------------------------
1 | # pgSCV settings
2 | LOG_LEVEL=debug
3 | #PGSCV_CONFIG_FILE=/etc/pgscv.yaml
4 | #PGSCV_LISTEN_ADDRESS="127.0.0.1:9890"
5 | #PGSCV_AUTH_USERNAME=monitoring
6 | #PGSCV_AUTH_PASSWORD=supersecretpassword
7 | #PGSCV_AUTH_KEYFILE=/etc/ssl/private/ssl-cert-snakeoil.key
8 | #PGSCV_AUTH_CERTFILE=/etc/ssl/certs/ssl-cert-snakeoil.pem
9 | #PGSCV_NO_TRACK_MODE=false
10 | #PGSCV_COLLECT_TOP_QUERY=15
11 | #PGSCV_COLLECT_TOP_TABLE=15
12 | #PGSCV_COLLECT_TOP_INDEX=15
13 | #PGSCV_SKIP_CONN_ERROR_MODE=t
14 | #POSTGRES_DSN_db1=postgresql://pgscv:pgscv@host1:5432/pgbench1
15 | #POSTGRES_DSN_db2=postgresql://pgscv:pgscv@host1:5432/pgbench2
16 | #PGSCV_DATABASES="^pgbench.*$"
17 | #PGSCV_DISABLE_COLLECTORS="system,postgres/logs"
18 |
19 | # Patroni settings
20 | PATRONI_RESTAPI_USERNAME=admin
21 | PATRONI_RESTAPI_PASSWORD=admin
22 | PATRONI_SUPERUSER_USERNAME=postgres
23 | PATRONI_SUPERUSER_PASSWORD=postgres
24 | PATRONI_REPLICATION_USERNAME=replicator
25 | PATRONI_REPLICATION_PASSWORD=replicate
26 | PATRONI_admin_PASSWORD=admin
27 | PATRONI_admin_OPTIONS=createdb,createrole
28 |
--------------------------------------------------------------------------------
/internal/http/http_client_test.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "net/http"
6 | "testing"
7 | )
8 |
9 | func TestClient_EnableTLSInsecure(t *testing.T) {
10 | cl := NewClient(ClientConfig{})
11 |
12 | assert.Nil(t, cl.client.Transport.(*http.Transport).TLSClientConfig)
13 | cl.EnableTLSInsecure()
14 | assert.True(t, cl.client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify)
15 | }
16 |
17 | func TestClient_Get(t *testing.T) {
18 | ts := TestServer(t, StatusOK, "")
19 | defer ts.Close()
20 |
21 | cl := NewClient(ClientConfig{})
22 | resp, err := cl.Get(ts.URL)
23 | assert.NoError(t, err)
24 | assert.NotNil(t, resp)
25 |
26 | _, err = cl.Get("http://[[")
27 | assert.Error(t, err)
28 | }
29 |
30 | func TestClient_Do(t *testing.T) {
31 | ts := TestServer(t, StatusOK, "")
32 | defer ts.Close()
33 |
34 | req, err := http.NewRequest("GET", ts.URL, nil)
35 | assert.NoError(t, err)
36 |
37 | cl := NewClient(ClientConfig{})
38 |
39 | resp, err := cl.Do(req)
40 | assert.NoError(t, err)
41 | assert.NotNil(t, resp)
42 | }
43 |
--------------------------------------------------------------------------------
/internal/service/testing.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "github.com/cherts/pgscv/internal/model"
4 |
5 | // TestSystemService returns system service for testing purposes
6 | func TestSystemService() Service {
7 | return Service{
8 | ServiceID: "system",
9 | ConnSettings: ConnSetting{
10 | ServiceType: model.ServiceTypeSystem,
11 | },
12 | }
13 | }
14 |
15 | // TestPostgresService returns postgres service for testing purposes
16 | func TestPostgresService() Service {
17 | return Service{
18 | ServiceID: "postgres:5432",
19 | ConnSettings: ConnSetting{
20 | ServiceType: model.ServiceTypePostgresql,
21 | Conninfo: "host=127.0.0.1 port=5432 user=pgscv dbname=pgscv_fixtures",
22 | },
23 | }
24 | }
25 |
26 | // TestPgbouncerService returns pgbouncer service for testing purposes
27 | func TestPgbouncerService() Service {
28 | return Service{
29 | ServiceID: "pgbouncer:6432",
30 | ConnSettings: ConnSetting{
31 | ServiceType: model.ServiceTypePgbouncer,
32 | Conninfo: "host=127.0.0.1 port=6432 user=pgscv dbname=pgbouncer password=pgscv",
33 | },
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/internal/collector/pgscv_services.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/prometheus/client_golang/prometheus"
7 | )
8 |
9 | // pgscvServicesCollector defines metrics about discovered and monitored services.
10 | type pgscvServicesCollector struct {
11 | service typedDesc
12 | }
13 |
14 | // NewPgscvServicesCollector creates new collector.
15 | func NewPgscvServicesCollector(constLabels labels, settings model.CollectorSettings) (Collector, error) {
16 | return &pgscvServicesCollector{
17 | service: newBuiltinTypedDesc(
18 | descOpts{"pgscv", "services", "registered_total", "Total number of services registered by pgSCV.", 0},
19 | prometheus.GaugeValue,
20 | []string{"service"}, constLabels,
21 | settings.Filters,
22 | )}, nil
23 | }
24 |
25 | // Update method is used for sending pgscvServicesCollector's metrics.
26 | func (c *pgscvServicesCollector) Update(config Config, ch chan<- prometheus.Metric) error {
27 | ch <- c.service.newConstMetric(1, config.ServiceType)
28 |
29 | return nil
30 | }
31 |
--------------------------------------------------------------------------------
/internal/collector/linux_load_average_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "os"
6 | "testing"
7 | )
8 |
9 | func TestLoadAverageCollector_Update(t *testing.T) {
10 | var input = pipelineInput{
11 | required: []string{
12 | "node_load1",
13 | "node_load5",
14 | "node_load15",
15 | },
16 | collector: NewLoadAverageCollector,
17 | }
18 |
19 | pipeline(t, input)
20 | }
21 |
22 | func Test_getLoadAverageStats(t *testing.T) {
23 | loads, err := getLoadAverageStats()
24 | assert.NoError(t, err)
25 | assert.Len(t, loads, 3)
26 | }
27 |
28 | func Test_parseLoadAverageStats(t *testing.T) {
29 | data, err := os.ReadFile("./testdata/proc/loadavg.golden")
30 | assert.NoError(t, err)
31 |
32 | loads, err := parseLoadAverageStats(string(data))
33 | assert.NoError(t, err)
34 | assert.Equal(t, 1.15, loads[0])
35 | assert.Equal(t, 1.36, loads[1])
36 | assert.Equal(t, 1.24, loads[2])
37 |
38 | _, err = parseLoadAverageStats("invalid data")
39 | assert.Error(t, err)
40 |
41 | _, err = parseLoadAverageStats("1 qq 2 1/123 12312")
42 | assert.Error(t, err)
43 | }
44 |
--------------------------------------------------------------------------------
/internal/collector/collector_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/prometheus/client_golang/prometheus"
5 | "github.com/stretchr/testify/assert"
6 | "testing"
7 | )
8 |
9 | func TestPgscvCollector_Collect(t *testing.T) {
10 | // Create test stuff - factory and collector, register system only metrics.
11 | f := Factories{}
12 | f.RegisterSystemCollectors([]string{})
13 | c, err := NewPgscvCollector("test:0", f, Config{})
14 | assert.NoError(t, err)
15 | assert.NotNil(t, c)
16 |
17 | // Create channel and run Collect method which collect metrics and transmit them into channel.
18 | ch := make(chan prometheus.Metric)
19 |
20 | go func() {
21 | c.Collect(ch)
22 | close(ch)
23 | }()
24 |
25 | // Catch metrics until channel is opened.
26 | var metrics []prometheus.Metric
27 | for m := range ch {
28 | //metric := &io_prometheus_client.Metric{}
29 | //_ = m.Write(metric)
30 | //fmt.Println("debug: ", proto.MarshalTextString(metric))
31 |
32 | metrics = append(metrics, m)
33 | }
34 |
35 | // Check metrics slice should not be nil or empty.
36 | assert.NotNil(t, metrics)
37 | assert.Greater(t, len(metrics), 0)
38 | }
39 |
--------------------------------------------------------------------------------
/internal/collector/strings.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "fmt"
6 | "slices"
7 | "strconv"
8 | "strings"
9 | )
10 |
11 | // stringsContains returns true if array of strings contains specific string
12 | func stringsContains(ss []string, s string) bool {
13 | return slices.Contains(ss, s)
14 | }
15 |
16 | // semverStringToInt parse valid semver version string and returns numeric representation.
17 | func semverStringToInt(version string) (int, error) {
18 | // remove additional suffix in patch version if exists.
19 | version = strings.TrimSuffix(version, strings.TrimLeft(version, "1234567890."))
20 |
21 | nums := strings.Split(version, ".")
22 | if len(nums) < 3 {
23 | return 0, fmt.Errorf("invalid version string: '%s'", version)
24 | }
25 |
26 | var res string
27 | for i, num := range nums {
28 | if i > 2 {
29 | break
30 | }
31 |
32 | switch i {
33 | case 1, 2:
34 | if len(num) < 2 {
35 | num = "0" + num
36 | }
37 | }
38 | res = res + num
39 | }
40 |
41 | v, err := strconv.Atoi(res)
42 | if err != nil {
43 | return 0, err
44 | }
45 |
46 | return v, nil
47 | }
48 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the problem is.
12 |
13 | **Steps to reproduce**
14 | Describe the steps to reproduce the problem.
15 |
16 | **Expected behavior**
17 | A clear and concise description of what you expected to happen.
18 |
19 | **pgSCV startup options**
20 | Describe used pgSCV configuration options (YAML configuration or environment variables).
21 |
22 | **Errors and Logs**
23 | Add any useful information from logs if possible or errors if there is any.
24 |
25 | **Environment (please complete the following information):**
26 | - Used OS (or Containers): [output of `uname -a` and `cat /etc/os-release` or `docker --version`]
27 | - pgSCV version [output of `pgscv --version`]
28 | - pgSCV file info [output of `ls -l /usr/sbin/pgscv` and `file /usr/sbin/pgscv`]
29 | - PostgreSQL version [output of `psql -c 'select version()'`]
30 | - Do PostgreSQL and pgSCV are running on the same host?
31 |
32 | **Additional context**
33 | Add any other context about the problem here.
34 |
--------------------------------------------------------------------------------
/deploy/helm-chart/Chart.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v2
2 | name: pgscv
3 | description: A Helm chart for pgSCV
4 | # A chart can be either an 'application' or a 'library' chart.
5 | #
6 | # Application charts are a collection of templates that can be packaged into versioned archives
7 | # to be deployed.
8 | #
9 | # Library charts provide useful utilities or functions for the chart developer. They're included as
10 | # a dependency of application charts to inject those utilities and functions into the rendering
11 | # pipeline. Library charts do not define any templates and therefore cannot be deployed.
12 | type: application
13 | # This is the chart version. This version number should be incremented each time you make changes
14 | # to the chart and its templates, including the app version.
15 | # Versions are expected to follow Semantic Versioning (https://semver.org/)
16 | version: 0.1.0
17 | # This is the version number of the application being deployed. This version number should be
18 | # incremented each time you make changes to the application. Versions are not expected to
19 | # follow Semantic Versioning. They should reflect the version the application is using.
20 | # It is recommended to use it with quotes.
21 | appVersion: "0.8.3"
22 |
--------------------------------------------------------------------------------
/testing/docker-test-runner/Dockerfile:
--------------------------------------------------------------------------------
1 | # cherts/pgscv-test-runner
2 | # __release_tag__ postrges v17.6 was released 2025-08-14
3 | # __release_tag__ golang v1.25.4 was released 2025-11-05
4 | # __release_tag__ revive v1.12.0 was released 2025-08-28
5 | # __release_tag__ gosec v2.22.10 was released 2025-10-15
6 | FROM postgres:17.6
7 |
8 | LABEL version="1.0.12"
9 |
10 | # install dependencies
11 | RUN apt-get update && \
12 | apt-get -y upgrade && \
13 | apt-get install -y vim make gcc git curl pgbouncer && \
14 | curl -s -L https://go.dev/dl/go1.25.4.linux-amd64.tar.gz -o - | tar xzf - -C /usr/local && \
15 | export PATH=$PATH:/usr/local/go/bin && \
16 | curl -s -L https://github.com/mgechev/revive/releases/download/v1.12.0/revive_linux_amd64.tar.gz | tar xzf - -C $(go env GOROOT)/bin revive && \
17 | curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $(go env GOROOT)/bin v2.22.10 && \
18 | mkdir /opt/testing/ && \
19 | rm -rf /var/lib/apt/lists/*
20 |
21 | ENV PATH="${PATH}:/usr/local/bin:/usr/local/go/bin"
22 |
23 | # copy prepare test environment scripts
24 | COPY prepare-test-environment.sh /usr/bin/
25 | COPY fixtures.sql /opt/testing/
26 |
27 | CMD ["echo", "I'm pgscv test runner 1.0.12"]
--------------------------------------------------------------------------------
/internal/collector/linux_netdev_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/cherts/pgscv/internal/filter"
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/stretchr/testify/assert"
7 | "os"
8 | "path/filepath"
9 | "testing"
10 | )
11 |
12 | func TestNetdevCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "node_network_bytes_total",
16 | "node_network_packets_total",
17 | "node_network_events_total",
18 | },
19 | collector: NewNetdevCollector,
20 | collectorSettings: model.CollectorSettings{Filters: filter.New()},
21 | }
22 |
23 | pipeline(t, input)
24 | }
25 |
26 | func Test_parseNetdevStats(t *testing.T) {
27 | file, err := os.Open(filepath.Clean("testdata/proc/netdev.golden"))
28 | assert.NoError(t, err)
29 | defer func() { _ = file.Close() }()
30 |
31 | stats, err := parseNetdevStats(file)
32 | assert.NoError(t, err)
33 |
34 | want := map[string][]float64{
35 | "enp2s0": {34899781, 60249, 10, 20, 30, 40, 50, 447, 8935189, 63211, 60, 70, 80, 90, 100, 110},
36 | "lo": {31433180, 57694, 15, 25, 48, 75, 71, 18, 31433180, 57694, 12, 48, 82, 38, 66, 17},
37 | "wlxc8be19e6279d": {68384665, 50991, 0, 85, 0, 17, 0, 44, 4138903, 29619, 1, 0, 74, 0, 4, 0},
38 | }
39 |
40 | assert.Equal(t, want, stats)
41 | }
42 |
--------------------------------------------------------------------------------
/internal/collector/postgres_custom_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/cherts/pgscv/internal/model"
5 | "testing"
6 | )
7 |
8 | func TestPostgresCustomCollector_Update(t *testing.T) {
9 | settings := model.CollectorSettings{
10 | Subsystems: map[string]model.MetricsSubsystem{
11 | "example1": {
12 | Databases: "pgscv_fixtures",
13 | Query: "SELECT 'label1' as l1, 1 as v1",
14 | Metrics: model.Metrics{
15 | {ShortName: "v1", Usage: "COUNTER", Value: "v1", Labels: []string{"l1"}, Description: "v1 description"},
16 | },
17 | },
18 | "example2": {
19 | Query: "SELECT 'label1' as l1, 'label2' as l2, 'label3' as l3, 1 as v1, 2 as v2",
20 | Metrics: model.Metrics{
21 | {ShortName: "v1", Usage: "COUNTER", Value: "v1", Labels: []string{"l1", "l2", "l3"}, Description: "v1 description"},
22 | {ShortName: "v2", Usage: "GAUGE", Value: "v2", Labels: []string{"l1", "l2", "l3"}, Description: "v2 description"},
23 | },
24 | },
25 | },
26 | }
27 |
28 | var input = pipelineInput{
29 | required: []string{
30 | "postgres_example1_v1",
31 | "postgres_example2_v1",
32 | "postgres_example2_v2",
33 | },
34 | collector: NewPostgresCustomCollector,
35 | collectorSettings: settings,
36 | service: model.ServiceTypePostgresql,
37 | }
38 |
39 | pipeline(t, input)
40 | }
41 |
--------------------------------------------------------------------------------
/internal/http/testdata/example.crt:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDsTCCApkCFFSDjh0dfvnuFsLcTXJXB+LCgXeJMA0GCSqGSIb3DQEBCwUAMIGU
3 | MQswCQYDVQQGEwJSVTEcMBoGA1UECAwTU3ZlcmRsb3Zza2F5YSBzdGF0ZTEWMBQG
4 | A1UEBwwNWWVrYXRlcmluYnVyZzENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
5 | dDESMBAGA1UEAwwJbG9jYWxob3N0MR0wGwYJKoZIhvcNAQkBFg5yb290QGxvY2Fs
6 | aG9zdDAeFw0yMTA3MDMwODQ2MzlaFw00MTA2MjgwODQ2MzlaMIGUMQswCQYDVQQG
7 | EwJSVTEcMBoGA1UECAwTU3ZlcmRsb3Zza2F5YSBzdGF0ZTEWMBQGA1UEBwwNWWVr
8 | YXRlcmluYnVyZzENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDESMBAGA1UE
9 | AwwJbG9jYWxob3N0MR0wGwYJKoZIhvcNAQkBFg5yb290QGxvY2FsaG9zdDCCASIw
10 | DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKss1OvjsVz9gRl67BW6lgDPH3j7
11 | /V22FjQE4uaF2egfsOuYbYK4op3UAsqupfpjIsuLuW7YuCxdl9pZJlNjf/lLrbYP
12 | eDDOwFyf6HY9JkGg1s9aSpSgi5HgvqZFi4PUolM5qQBICejveUBD6xddYYCD7zqs
13 | kNZuV1+cBeyeaCKwNjxzkEywZAjaQZhZ521FVWsA+iMipLUJehbR2kTgE0FVEJCr
14 | hZuFVqKrU1nk0KYqL5yzLut2cljjM7N1FVncmuFAElrglwfVrk04nii9pc6jRemF
15 | nM/3aJcohd5ZTWQaL/G3NJ7ertRewu6fLHnM/LDocvUaCt/hxuBrGfqvmzECAwEA
16 | ATANBgkqhkiG9w0BAQsFAAOCAQEAKtbpttcmiQwhCU0KYDZ+uuJrBFO3ZcSdQJ94
17 | iFSJDmCY+ZKalq+TAmDoCLpqnHlRvaLxqfEJ5rId68Mb0x2TtMlnXNayn8flPsXA
18 | Tifvurfc6UPPeknSwB4c9+zWq1jjMZtgWBmooFAm/d2MP5NuZ2HRtUtte0xv7DjF
19 | tknlIUY7ymUjzKyPoMtet9OGlxtG63qRlfstQQc/j34xfAMavAkgJ6Nmjv+vMCL1
20 | +Vx3RrWuXcdznRlRapO87yVqeXGI69mGlP5Gm2gfEgqPwoSKfsHjrFbigKvLBFec
21 | SfdwH3w2N65imoisvC1jCYeIql36Wh5tzkrrePclHKVEd0ECqA==
22 | -----END CERTIFICATE-----
23 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbouncer/generate_userlist.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # A single script to generate an entry in for userlist.txt
4 | # Usage:
5 | #
6 | # ./generate_userlist.sh >> userlist.txt
7 | # ./generate_userlist.sh username >> userlist.txt
8 | #
9 |
10 | # Don't edit this config
11 | SOURCE="${BASH_SOURCE[0]}"
12 | while [ -h "$SOURCE" ]; do
13 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
14 | SOURCE="$(readlink "$SOURCE")"
15 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
16 | done
17 | SCRIPT_DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
18 | SCRIPT_NAME=$(basename "$0")
19 |
20 | # Check command exist function
21 | _command_exists() {
22 | type "$1" &> /dev/null
23 | }
24 |
25 | # Detect openssl
26 | if _command_exists openssl; then
27 | OPENSSL_BIN=$(which openssl)
28 | else
29 | echo "ERROR: Command 'openssl' not found."
30 | exit 1
31 | fi
32 |
33 | # Detect xxd
34 | if _command_exists xxd; then
35 | XXD_BIN=$(which xxd)
36 | else
37 | echo "ERROR: Command 'xxd' not found."
38 | exit 1
39 | fi
40 |
41 | if [[ $# -eq 1 ]]; then
42 | USERNAME="$1"
43 | else
44 | read -r -p "Enter username: " USERNAME
45 | fi
46 |
47 | read -r -s -p "Enter password: " PASSWORD
48 | echo >&2
49 |
50 | # Using openssl md5 to avoid differences between OSX and Linux (`md5` vs `md5sum`)
51 | ENC_PASSWORD="md5$(printf "%s%s" "${PASSWORD}" "${USERNAME}" | ${OPENSSL_BIN} md5 -binary | ${XXD_BIN} -p)"
52 |
53 | echo "\"${USERNAME}\" \"${ENC_PASSWORD}\""
--------------------------------------------------------------------------------
/discovery/factory/sd.go:
--------------------------------------------------------------------------------
1 | // Package factory create service discovery from config
2 | package factory
3 |
4 | import (
5 | "fmt"
6 | "github.com/cherts/pgscv/discovery"
7 | "github.com/cherts/pgscv/discovery/log"
8 | "github.com/cherts/pgscv/internal/discovery/service"
9 | "gopkg.in/yaml.v2"
10 | )
11 |
12 | // Instantiate returns initialized service discoverers (converting abstract configs to determined structures)
13 | func Instantiate(discoveryConfig discovery.Config) (*map[string]discovery.Discovery, error) {
14 | log.Debug("[SD] Initializing discovery services...")
15 | var config = make(map[string]discovery.SdConfig)
16 | var out, err = yaml.Marshal(discoveryConfig)
17 | if err != nil {
18 | return nil, err
19 | }
20 | err = yaml.Unmarshal(out, config)
21 | if err != nil {
22 | return nil, err
23 | }
24 | var services = make(map[string]discovery.Discovery)
25 | for id, srv := range config {
26 | log.Debugf("[SD] Found service discovery type '%s'", srv.Type)
27 | switch srv.Type {
28 | case discovery.YandexMDB:
29 | services[id] = service.NewYandexDiscovery()
30 | default:
31 | err := fmt.Errorf("[SD] Unknown service discovery type '%s'", srv.Type)
32 | log.Debug(err.Error())
33 | return nil, err
34 | }
35 | err := services[id].Init(srv.Config)
36 | if err != nil {
37 | log.Errorf("[SD] Failed to initializing discovery service '%s', error: %s", id, err.Error())
38 | return nil, err
39 | }
40 | }
41 | return &services, nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/discovery/cloud/yandex/filter.go:
--------------------------------------------------------------------------------
1 | package yandex
2 |
3 | import "regexp"
4 |
5 | // Filter struct implementing regexp filters for clusters and theirs databases
6 | type Filter struct {
7 | nameRegexp *regexp.Regexp
8 | dbRegexp *regexp.Regexp
9 | excludeNameRegexp *regexp.Regexp
10 | excludeDbRegexp *regexp.Regexp
11 | }
12 |
13 | // NewFilter return Filter structure with compiled regexps
14 | func NewFilter(name string, db, excludeName, excludeDb *string) *Filter {
15 | f := &Filter{}
16 | f.nameRegexp = regexp.MustCompile(name)
17 | if db != nil {
18 | f.dbRegexp = regexp.MustCompile(*db)
19 | }
20 | if excludeName != nil {
21 | f.excludeNameRegexp = regexp.MustCompile(*excludeName)
22 | }
23 | if excludeDb != nil {
24 | f.excludeDbRegexp = regexp.MustCompile(*excludeDb)
25 | }
26 | return f
27 | }
28 |
29 | // MatchName check name is matched name regexp and not matched exclude name regexp
30 | func (f *Filter) MatchName(name string) bool {
31 | if f.excludeNameRegexp != nil && f.excludeNameRegexp.MatchString(name) {
32 | return false
33 | }
34 | return f.nameRegexp.MatchString(name)
35 | }
36 |
37 | // MatchDb check database is matched name regexp and not matched exclude name regexp
38 | func (f *Filter) MatchDb(name string) bool {
39 | if f.excludeDbRegexp != nil && f.excludeDbRegexp.MatchString(name) {
40 | return false
41 | }
42 | if f.dbRegexp == nil {
43 | return true
44 | }
45 | return f.dbRegexp.MatchString(name)
46 | }
47 |
--------------------------------------------------------------------------------
/internal/collector/testdata/datadir/postgresql.log.golden:
--------------------------------------------------------------------------------
1 | 2020-09-30 14:26:29.766 +05 797911 pgscv@invalid from 127.0.0.1 [vxid:7/23328 txid:0] [startup] FATAL: database "invalid" does not exist
2 | 2020-09-30 14:26:29.769 +05 797913 [unknown]@[unknown] from 127.0.0.1 [vxid: txid:0] [] LOG: PID 0 in cancel request did not match any process
3 | 2020-09-30 14:26:29.774 +05 797915 pgscv@__invalid__ from 127.0.0.1 [vxid:8/19992 txid:0] [startup] FATAL: database "__invalid__" does not exist
4 | 2020-09-30 14:26:29.777 +05 797922 [unknown]@[unknown] from 127.0.0.1 [vxid: txid:0] [] LOG: PID 0 in cancel request did not match any process
5 | 2020-09-30 14:26:29.784 +05 797923 pgscv@pgscv_fixtures from 127.0.0.1 [vxid:8/19995 txid:0] [idle] ERROR: syntax error at or near "invalid" at character 1
6 | 2020-09-30 14:26:29.784 +05 797923 pgscv@pgscv_fixtures from 127.0.0.1 [vxid:8/19995 txid:0] [idle] STATEMENT: invalid
7 | 2020-09-30 14:26:29.812 +05 797908 pgscv@pgscv_fixtures from 127.0.0.1 [vxid:6/0 txid:0] [idle] LOG: could not receive data from client: Connection reset by peer
8 | 2020-09-30 14:26:34.999 +05 10638 lesovsky@lesovsky from [local] [vxid:4/7010 txid:0] [idle] LOG: statement: alter system set log_filename to 'postgresql-%Y-%m-%d-%H%M.log';
9 | 2020-09-30 14:26:36.447 +05 1118 @ from [vxid: txid:0] [] LOG: received SIGHUP, reloading configuration files
10 | 2020-09-30 14:26:36.448 +05 1118 @ from [vxid: txid:0] [] LOG: parameter "log_filename" changed to "postgresql-%Y-%m-%d-%H%M.log"
11 |
--------------------------------------------------------------------------------
/discovery/common.go:
--------------------------------------------------------------------------------
1 | // Package discovery is main package of service discovery module
2 | package discovery
3 |
4 | import (
5 | "context"
6 | )
7 |
8 | // Config abstract configuration SdConfig.config
9 | type Config any
10 |
11 | // Service abstract service definition
12 | type Service struct {
13 | DSN string
14 | ConstLabels map[string]string
15 | TargetLabels map[string]string
16 | }
17 |
18 | // AddServiceFunc services arg is map serviceId -> data source name
19 | type AddServiceFunc func(services map[string]Service) error
20 |
21 | // RemoveServiceFunc serviceIds is array of service IDs
22 | type RemoveServiceFunc func(serviceIds []string) error
23 |
24 | // Discovery interface of abstract discovery services
25 | type Discovery interface {
26 | //Init casting abstract Config to determined structure
27 | Init(c Config) error
28 | //Start is the discoverer starting point
29 | Start(ctx context.Context, errCh chan<- error) error
30 | //Subscribe - binding "add" and "remove" functions. Functions will be called when services appear or disappear
31 | Subscribe(subscriberID string, addService AddServiceFunc, removeService RemoveServiceFunc) error
32 | //Unsubscribe - remove subscriber from list
33 | Unsubscribe(subscriberID string) error
34 | }
35 |
36 | const (
37 | // YandexMDB constant SdConfig.type
38 | YandexMDB = "yandex-mdb"
39 | )
40 |
41 | // SdConfig top level of configuration tree
42 | type SdConfig struct {
43 | Type string `yaml:"type"`
44 | Config Config `yaml:"config"`
45 | }
46 |
--------------------------------------------------------------------------------
/internal/pgscv/testdata/pgscv-full-merge-example.yaml:
--------------------------------------------------------------------------------
1 | listen_address: "127.0.0.1:8888"
2 | services:
3 | "postgres":
4 | service_type: "postgres"
5 | conninfo: "host=127.0.0.1 port=5432 dbname=pgscv_fixtures user=pgscv"
6 | defaults:
7 | postgres_username: "testuser"
8 | postgres_password: "testpassword"
9 | pgbouncer_username: "testuser2"
10 | pgbouncer_password: "testapassword2"
11 | disable_collectors:
12 | - fisrt-disabled-collector
13 | - second-disabled-collector
14 | collectors:
15 | postgres/custom:
16 | echo: "example"
17 | subsystems:
18 | activity:
19 | query: "select datname as database,xact_commit,xact_rollback,blks_read as read,blks_write as write from pg_stat_database"
20 | metrics:
21 | - name: xact_commit_total
22 | usage: COUNTER
23 | labels:
24 | - database
25 | value: xact_commit
26 | description: "description"
27 | - name: "blocks_total"
28 | usage: COUNTER
29 | labels:
30 | - database
31 | labeled_values:
32 | access: [ "read", "write" ]
33 | description: "description"
34 | bgwriter:
35 | query: "select maxwritten_clean from pg_stat_bgwriter"
36 | metrics:
37 | - name: "maxwritten_clean_total"
38 | usage: COUNTER
39 | value: maxwritten_clean
40 | description: "description"
41 | authentication:
42 | username: user
43 | password: supersecret
44 | keyfile: example.key
45 | certfile: example.cert
--------------------------------------------------------------------------------
/internal/collector/postgres_stat_ssl_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "testing"
6 |
7 | "github.com/cherts/pgscv/internal/model"
8 | "github.com/jackc/pgproto3/v2"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestPostgresStatSslCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "postgres_stat_ssl_conn_number",
16 | },
17 | collector: NewPostgresStatSslCollector,
18 | service: model.ServiceTypePostgresql,
19 | }
20 |
21 | pipeline(t, input)
22 | }
23 |
24 | func Test_parsePostgresStatSsl(t *testing.T) {
25 | var testCases = []struct {
26 | name string
27 | res *model.PGResult
28 | want map[string]postgresStatSsl
29 | }{
30 | {
31 | name: "normal output, Postgres 16",
32 | res: &model.PGResult{
33 | Nrows: 1,
34 | Ncols: 3,
35 | Colnames: []pgproto3.FieldDescription{
36 | {Name: []byte("database")}, {Name: []byte("username")}, {Name: []byte("ssl_conn_number")},
37 | },
38 | Rows: [][]sql.NullString{
39 | {
40 | {String: "NULL", Valid: true}, {String: "postgres", Valid: true}, {String: "1", Valid: true},
41 | },
42 | },
43 | },
44 | want: map[string]postgresStatSsl{
45 | "NULL/postgres": {
46 | Database: "NULL", Username: "postgres", ConnNumber: 1,
47 | },
48 | },
49 | },
50 | }
51 |
52 | for _, tc := range testCases {
53 | t.Run(tc.name, func(t *testing.T) {
54 | got := parsePostgresStatSsl(tc.res, []string{"database", "username"})
55 | assert.EqualValues(t, tc.want, got)
56 | })
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/collector/strings_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "testing"
6 | )
7 |
8 | func Test_stringsContains(t *testing.T) {
9 | ss := []string{"first_example_string", "second_example_string", "third_example_string"}
10 |
11 | assert.True(t, stringsContains(ss, "first_example_string"))
12 | assert.False(t, stringsContains(ss, "unknown_string"))
13 | assert.False(t, stringsContains(nil, "example"))
14 | }
15 |
16 | func Test_semverStringToInt(t *testing.T) {
17 | testcases := []struct {
18 | valid bool
19 | version string
20 | want int
21 | }{
22 | {valid: true, version: "0.0.1-pre0", want: 1},
23 | {valid: true, version: "0.0.1", want: 1},
24 | {valid: true, version: "0.0.1.2", want: 1},
25 | {valid: true, version: "0.1.2", want: 102},
26 | {valid: true, version: "0.1.2-pre0", want: 102},
27 | {valid: true, version: "1.2.3", want: 10203},
28 | {valid: true, version: "1.2.3-pre0", want: 10203},
29 | {valid: true, version: "1.2.13", want: 10213},
30 | {valid: true, version: "1.2.13-pre0", want: 10213},
31 | {valid: true, version: "1.12.23", want: 11223},
32 | {valid: true, version: "1.12.23-pre0", want: 11223},
33 | {valid: true, version: "11.22.33", want: 112233},
34 | {valid: true, version: "11.22.33-pre0", want: 112233},
35 | {valid: false, version: "22.33"},
36 | }
37 |
38 | for _, tc := range testcases {
39 | got, err := semverStringToInt(tc.version)
40 | if tc.valid {
41 | assert.NoError(t, err)
42 | assert.Equal(t, tc.want, got)
43 | } else {
44 | assert.Error(t, err)
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2021, Weaponry
4 | Copyright (c) 2024, Mikhail Grigorev
5 | All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions are met:
9 |
10 | 1. Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | 2. Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | 3. Neither the name of the copyright holder nor the names of its
18 | contributors may be used to endorse or promote products derived from
19 | this software without specific prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/internal/discovery/cloud/yandex/key.go:
--------------------------------------------------------------------------------
1 | package yandex
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "time"
9 |
10 | "github.com/cherts/pgscv/discovery/log"
11 | "github.com/go-playground/validator/v10"
12 | )
13 |
14 | type authorizedKey struct {
15 | ID string `json:"id"`
16 | ServiceAccountID string `json:"service_account_id" validate:"required"`
17 | CreatedAt time.Time `json:"created_at" validate:"required"`
18 | KeyAlgorithm string `json:"key_algorithm" validate:"required"`
19 | PublicKey string `json:"public_key" validate:"required"`
20 | PrivateKey string `json:"private_key" validate:"required"`
21 | }
22 |
23 | func (k *authorizedKey) validate() error {
24 | v := validator.New()
25 |
26 | err := v.Struct(k)
27 | if err != nil {
28 | return fmt.Errorf("validate | %w", err)
29 | }
30 |
31 | return nil
32 | }
33 |
34 | func loadAuthorizedKey(filePath string) (*authorizedKey, error) {
35 |
36 | log.Debugf("[SD] Loading authorized key from path '%s'", filePath)
37 | data, err := os.ReadFile(filepath.Clean(filePath))
38 | if err != nil {
39 | log.Errorf("[SD] Failed to load authorized key, error: %s", err.Error())
40 | return nil, err
41 | }
42 |
43 | var key authorizedKey
44 |
45 | err = json.Unmarshal(data, &key)
46 | if err != nil {
47 | log.Errorf("[SD] Failed to parse authorized key, JSON parse error: %s", err.Error())
48 | return nil, err
49 | }
50 |
51 | err = key.validate()
52 | if err != nil {
53 | log.Errorf("[SD] Failed to validate authorized key, error: %s", err.Error())
54 | }
55 |
56 | return &key, nil
57 | }
58 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/meminfo.golden:
--------------------------------------------------------------------------------
1 | MemTotal: 32839484 kB
2 | MemFree: 21570088 kB
3 | MemAvailable: 26190600 kB
4 | Buffers: 604064 kB
5 | Cached: 4361844 kB
6 | SwapCached: 0 kB
7 | Active: 7785324 kB
8 | Inactive: 2591484 kB
9 | Active(anon): 5448748 kB
10 | Inactive(anon): 344784 kB
11 | Active(file): 2336576 kB
12 | Inactive(file): 2246700 kB
13 | Unevictable: 0 kB
14 | Mlocked: 0 kB
15 | SwapTotal: 16777212 kB
16 | SwapFree: 16777212 kB
17 | Dirty: 36404 kB
18 | Writeback: 0 kB
19 | AnonPages: 5410948 kB
20 | Mapped: 1197820 kB
21 | Shmem: 386884 kB
22 | KReclaimable: 502080 kB
23 | Slab: 692516 kB
24 | SReclaimable: 502080 kB
25 | SUnreclaim: 190436 kB
26 | KernelStack: 16848 kB
27 | PageTables: 54472 kB
28 | NFS_Unstable: 0 kB
29 | Bounce: 0 kB
30 | WritebackTmp: 0 kB
31 | CommitLimit: 33196952 kB
32 | Committed_AS: 12808144 kB
33 | VmallocTotal: 34359738367 kB
34 | VmallocUsed: 34976 kB
35 | VmallocChunk: 0 kB
36 | Percpu: 6528 kB
37 | HardwareCorrupted: 0 kB
38 | AnonHugePages: 0 kB
39 | ShmemHugePages: 0 kB
40 | ShmemPmdMapped: 0 kB
41 | FileHugePages: 0 kB
42 | FilePmdMapped: 0 kB
43 | CmaTotal: 0 kB
44 | CmaFree: 0 kB
45 | HugePages_Total: 0
46 | HugePages_Free: 0
47 | HugePages_Rsvd: 0
48 | HugePages_Surp: 0
49 | Hugepagesize: 2048 kB
50 | Hugetlb: 0 kB
51 | DirectMap4k: 482128 kB
52 | DirectMap2M: 13101056 kB
53 | DirectMap1G: 19922944 kB
54 |
--------------------------------------------------------------------------------
/internal/collector/filesystem_common_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 | )
9 |
10 | func Test_parseProcMounts(t *testing.T) {
11 | file, err := os.Open(filepath.Clean("testdata/proc/mounts.golden"))
12 | assert.NoError(t, err)
13 | defer func() { _ = file.Close() }()
14 |
15 | stats, err := parseProcMounts(file)
16 | assert.NoError(t, err)
17 |
18 | want := []mount{
19 | {device: "/dev/mapper/ssd-root", mountpoint: "/", fstype: "ext4", options: "rw,relatime,discard,errors=remount-ro"},
20 | {device: "/dev/sda1", mountpoint: "/boot", fstype: "ext3", options: "rw,relatime"},
21 | {device: "/dev/mapper/ssd-data", mountpoint: "/data", fstype: "ext4", options: "rw,relatime,discard"},
22 | {device: "/dev/sdc1", mountpoint: "/archive", fstype: "xfs", options: "rw,relatime"},
23 | }
24 |
25 | assert.Equal(t, want, stats)
26 |
27 | // test with wrong format file
28 | file, err = os.Open(filepath.Clean("testdata/proc/netdev.golden"))
29 | assert.NoError(t, err)
30 | defer func() { _ = file.Close() }()
31 |
32 | stats, err = parseProcMounts(file)
33 | assert.Error(t, err)
34 | assert.Nil(t, stats)
35 | }
36 |
37 | func Test_truncateDeviceName(t *testing.T) {
38 | var testcases = []struct {
39 | name string
40 | path string
41 | want string
42 | }{
43 | {name: "valid 1", path: "testdata/dev/sda", want: "sda"},
44 | {name: "valid 2", path: "testdata/dev/sdb2", want: "sdb2"},
45 | {name: "valid 3", path: "testdata/dev/mapper/ssd-root", want: "dm-1"},
46 | {name: "unknown", path: "testdata/dev/unknown", want: "unknown"},
47 | }
48 |
49 | for _, tc := range testcases {
50 | assert.Equal(t, tc.want, truncateDeviceName(tc.path))
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/internal/http/testdata/example.key:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIEowIBAAKCAQEAqyzU6+OxXP2BGXrsFbqWAM8fePv9XbYWNATi5oXZ6B+w65ht
3 | griindQCyq6l+mMiy4u5bti4LF2X2lkmU2N/+Uuttg94MM7AXJ/odj0mQaDWz1pK
4 | lKCLkeC+pkWLg9SiUzmpAEgJ6O95QEPrF11hgIPvOqyQ1m5XX5wF7J5oIrA2PHOQ
5 | TLBkCNpBmFnnbUVVawD6IyKktQl6FtHaROATQVUQkKuFm4VWoqtTWeTQpiovnLMu
6 | 63ZyWOMzs3UVWdya4UASWuCXB9WuTTieKL2lzqNF6YWcz/dolyiF3llNZBov8bc0
7 | nt6u1F7C7p8secz8sOhy9RoK3+HG4GsZ+q+bMQIDAQABAoIBAQCLgYo2hedzRhgF
8 | UC1AuESwfB3GWHjx+wi1dJYIEma5y7pBCIWX2CqQPs3XqecT3d/pzAJg2LehUNYF
9 | 2kpmA920q3zzuD/YZ2hXFOw8ETIwoojvjULjRsT5KxW2JU/DLXTuJzwZQpzw/trv
10 | CWt8K1rfhqdeRm30lREYluwtIBz2xxjvBjNfHNbnK6j8TxzUSTijX/Ex7dgxNK6s
11 | 6uKNFqsdozfFovySAsR1WJoaHKMYEueu9MFHSTM4OEbxzJAbA/w/f4o7KTKa5eZ+
12 | TejxtGWcyrVR9BoimCrFkPWC3/PJvAt4ZeYng1aX64xd/QyqVIzkc16+ggbO43m+
13 | 8RY1TCABAoGBANPmRLCPh5uozAXiV4rlCi7jW1OzMaRx76/eE/+yGoDf/2lhtv0L
14 | YH6qdRksCy/DGx5vS+crYwqaPVjN79vI62r2lRf+v/E3zgNzDuJgq1g59pFeY9NK
15 | 079k7F7+vIW0EyzKfCKm+eNxSNAtQdi7lG2aN3TjlKSYlSp2wxuuVCmBAoGBAM7M
16 | 0mW0YK2D/YPHElN3gOSOn2+PD8zAk1HuO5qKNx/7KERPjZcDhcOW6QZ/kOeDHS27
17 | +z8aiQfq6vQ2Y5uS9GlbjU+fbEp1er+iJC1lHqwNpYtIY25E2HGXBaHD2EHSk1zb
18 | CSdqK1Ewp2IxdG98zRrsIP9c9UeFzqs9c6sP+WmxAoGATx15Zgag1hBm5IeGsfgk
19 | Hi+LCKwuC7zyhdI/20cPODDp9tmh7caSp5hTEivsnU+WT320dEIxv2KpJv/03zWc
20 | GBqqvgPCfHiedZE+7Gy1bMJvegUo9lnIx3wR+MHZd34tbprHUFTRlgbU7c0H+bjH
21 | iUh8Ditucyn4/5rJ7Arhp4ECgYB+Ro+KzvPhwCEDYIGOOgCYj4ZHhqHtMwJCGyiG
22 | GzPB8YkK/VDGD76USggMkcSXuXYNwSWPyNI35XiGmteD3d4kn2TQY9aqOMY1Ufqp
23 | RX/PK54USKV+ZceMxN0JhB7/Qmf9YTpbuPauYvkyemRQ13IeqGUVyVt0yv4Bjkqc
24 | /+oaYQKBgFWgxJ/FB/ecOa3h7IxwMqf7pje15VckIq0trQTFXDnQE5lzE5pnsuxE
25 | mw51bjmXm7S0f2JiteMJPyCXh1ewSc0IKa1hTXTt+zQTTkyl60kHwPph7BjxuVBV
26 | HFtLve6eUyNNtslbT7GPs5qzgViozBU4R6Ed9fWhWsfjwqV9syhc
27 | -----END RSA PRIVATE KEY-----
28 |
--------------------------------------------------------------------------------
/internal/discovery/cloud/yandex/filter_test.go:
--------------------------------------------------------------------------------
1 | package yandex
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func stringPtr(s string) *string {
8 | return &s
9 | }
10 |
11 | func TestMatchName(t *testing.T) {
12 | filter := NewFilter(".*test.*", nil, stringPtr("exclude"), nil)
13 |
14 | testCases := []struct {
15 | name string
16 | want bool
17 | message string
18 | }{
19 | {"test", true, "should match simple test"},
20 | {"mytest", true, "should match mytest"},
21 | {"exclude", false, "should not match exclude"},
22 | {"another exclude", false, "should not match another exclude"},
23 | }
24 |
25 | for _, tc := range testCases {
26 | got := filter.MatchName(tc.name)
27 | if got != tc.want {
28 | t.Errorf("MatchName(%q) = %v; want %v. %s", tc.name, got, tc.want, tc.message)
29 | }
30 | }
31 | }
32 |
33 | func TestMatchDb(t *testing.T) {
34 | filter := NewFilter(".*", stringPtr(".*db.*"), nil, stringPtr("exclude"))
35 |
36 | testCases := []struct {
37 | name string
38 | want bool
39 | message string
40 | }{
41 | {"mydb", true, "should match mydb"},
42 | {"testdb", true, "should match testdb"},
43 | {"exclude", false, "should not match exclude"},
44 | }
45 |
46 | for _, tc := range testCases {
47 | got := filter.MatchDb(tc.name)
48 | if got != tc.want {
49 | t.Errorf("MatchDb(%q) = %v; want %v. %s", tc.name, got, tc.want, tc.message)
50 | }
51 | }
52 | }
53 |
54 | func TestNewFilter(t *testing.T) {
55 | //Test for nil values
56 | filter := NewFilter(".*", nil, nil, nil)
57 | if filter.dbRegexp != nil {
58 | t.Errorf("dbRegexp should be nil")
59 | }
60 | if filter.excludeDbRegexp != nil {
61 | t.Errorf("excludeDbRegexp should be nil")
62 | }
63 | if filter.excludeNameRegexp != nil {
64 | t.Errorf("excludeNameRegexp should be nil")
65 | }
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/deploy/helm-chart/templates/deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: {{ include "pgscv.fullname" . }}-pgscv
5 | labels:
6 | link-app: pgscv
7 | {{- include "pgscv.labels" . | nindent 4 }}
8 | spec:
9 | replicas: {{ .Values.pgscv.replicas }}
10 | selector:
11 | matchLabels:
12 | link-app: pgscv
13 | {{- include "pgscv.selectorLabels" . | nindent 6 }}
14 | template:
15 | metadata:
16 | labels:
17 | link-app: pgscv
18 | {{- include "pgscv.selectorLabels" . | nindent 8 }}
19 | spec:
20 | containers:
21 | - args: {{- toYaml .Values.pgscv.pgscv.args | nindent 8 }}
22 | env:
23 | - name: KUBERNETES_CLUSTER_DOMAIN
24 | value: {{ quote .Values.kubernetesClusterDomain }}
25 | image: {{ .Values.pgscv.pgscv.image.repository }}:{{ .Values.pgscv.pgscv.image.tag
26 | | default .Chart.AppVersion }}
27 | imagePullPolicy: {{ .Values.pgscv.pgscv.imagePullPolicy }}
28 | name: pgscv
29 | ports:
30 | - containerPort: 9890
31 | name: http
32 | protocol: TCP
33 | resources: {{- toYaml .Values.pgscv.pgscv.resources | nindent 10 }}
34 | volumeMounts:
35 | - mountPath: /app/conf/
36 | name: pgscv-config
37 | dnsPolicy: ClusterFirst
38 | enableServiceLinks: false
39 | nodeSelector: {{- toYaml .Values.pgscv.nodeSelector | nindent 8 }}
40 | priorityClassName: system-cluster-critical
41 | restartPolicy: Always
42 | securityContext: {}
43 | terminationGracePeriodSeconds: 30
44 | tolerations:
45 | - operator: Exists
46 | volumes:
47 | - configMap:
48 | items:
49 | - key: pgscv.yaml
50 | path: pgscv.yaml
51 | name: {{ include "pgscv.fullname" . }}-configmap
52 | name: pgscv-config
--------------------------------------------------------------------------------
/internal/http/http_client.go:
--------------------------------------------------------------------------------
1 | // Package http is a pgSCV http helper
2 | package http
3 |
4 | import (
5 | "crypto/tls"
6 | "net/http"
7 | "time"
8 | )
9 |
10 | // Status code
11 | const (
12 | // Code 200
13 | StatusOK = http.StatusOK
14 | // Code 400
15 | StatusBadRequest = http.StatusBadRequest
16 | // Code 401
17 | StatusUnauthorized = http.StatusUnauthorized
18 | // Code 404
19 | StatusNotFound = http.StatusNotFound
20 | )
21 |
22 | // Client defines local wrapper on standard http.Client.
23 | type Client struct {
24 | client *http.Client
25 | }
26 |
27 | // ClientConfig defines initial configuration when creating Client.
28 | type ClientConfig struct {
29 | Timeout time.Duration
30 | }
31 |
32 | // NewClient creates new HTTP client.
33 | func NewClient(cfg ClientConfig) *Client {
34 | const defaultTimeout = time.Second
35 |
36 | if cfg.Timeout == 0 {
37 | cfg.Timeout = defaultTimeout
38 | }
39 |
40 | return &Client{
41 | client: &http.Client{
42 | Timeout: cfg.Timeout,
43 | Transport: &http.Transport{
44 | MaxIdleConns: 100,
45 | MaxConnsPerHost: 10,
46 | MaxIdleConnsPerHost: 10,
47 | IdleConnTimeout: 120 * time.Second,
48 | },
49 | },
50 | }
51 | }
52 |
53 | // EnableTLSInsecure enables insecure TLS transport for HTTP client.
54 | func (cl *Client) EnableTLSInsecure() {
55 | t := cl.client.Transport.(*http.Transport).Clone()
56 | t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402
57 | cl.client.Transport = t
58 | }
59 |
60 | // Get wraps a standard http.Get method which issues a GET to the specified URL.
61 | func (cl *Client) Get(url string) (*http.Response, error) {
62 | return cl.client.Get(url)
63 | }
64 |
65 | // Do wraps a standard http.Do method which sends an HTTP request and returns an HTTP response.
66 | func (cl *Client) Do(req *http.Request) (*http.Response, error) {
67 | return cl.client.Do(req)
68 | }
69 |
--------------------------------------------------------------------------------
/testing/docker-test-runner/fixtures.sql:
--------------------------------------------------------------------------------
1 | -- schema fixtures
2 | CREATE ROLE pgscv WITH LOGIN SUPERUSER;
3 |
4 | CREATE DATABASE pgscv_fixtures OWNER pgscv;
5 | \c pgscv_fixtures pgscv
6 |
7 | -- create pg_stat_statements
8 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
9 | SELECT pg_stat_statements_reset();
10 |
11 | -- create table with invalid index
12 | CREATE TABLE orders (id SERIAL PRIMARY KEY, name TEXT, status INT);
13 | CREATE INDEX orders_status_idx ON orders (status);
14 | UPDATE pg_index SET indisvalid = false WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'orders_status_idx');
15 |
16 | -- create table with redundant index
17 | CREATE TABLE products (id SERIAL PRIMARY KEY, name TEXT, size INTEGER, weight INTEGER, color INTEGER);
18 | CREATE INDEX products_name_idx ON products (name);
19 | CREATE INDEX products_name_size_idx ON products (name, size);
20 |
21 | -- create table with near-to-overflow sequence
22 | CREATE TABLE events (id SERIAL PRIMARY KEY, key TEXT, payload TEXT);
23 | SELECT setval('events_id_seq', 2000000000);
24 |
25 | -- create tables with non-indexed foreign key
26 | CREATE TABLE accounts (id SERIAL PRIMARY KEY, name TEXT, email TEXT, passowrd TEXT, status INTEGER);
27 | CREATE TABLE statuses (id SERIAL PRIMARY KEY, name TEXT);
28 | ALTER TABLE accounts ADD CONSTRAINT accounts_status_constraint FOREIGN KEY (status) REFERENCES statuses (id);
29 |
30 | -- create tables with foreign key and different columns types
31 | CREATE TABLE persons (id SERIAL PRIMARY KEY, name TEXT, email TEXT, passowrd TEXT, property BIGINT);
32 | CREATE TABLE properties (id SERIAL PRIMARY KEY, name TEXT);
33 | ALTER TABLE persons ADD CONSTRAINT persons_properties_constraint FOREIGN KEY (property) REFERENCES properties (id);
34 |
35 | -- create table with no primary/unique key
36 | CREATE TABLE migrations (id INT, created_at TIMESTAMP, description TEXT);
37 | CREATE INDEX migrations_created_at_idx ON migrations (created_at);
38 |
--------------------------------------------------------------------------------
/internal/collector/postgres_conflicts_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresConflictsCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | optional: []string{
14 | "postgres_recovery_conflicts_total",
15 | },
16 | collector: NewPostgresConflictsCollector,
17 | service: model.ServiceTypePostgresql,
18 | }
19 |
20 | pipeline(t, input)
21 | }
22 |
23 | func Test_parsePostgresConflictsStats(t *testing.T) {
24 | var testCases = []struct {
25 | name string
26 | res *model.PGResult
27 | want map[string]postgresConflictStat
28 | }{
29 | {
30 | name: "normal output",
31 | res: &model.PGResult{
32 | Nrows: 2,
33 | Ncols: 6,
34 | Colnames: []pgproto3.FieldDescription{
35 | {Name: []byte("database")}, {Name: []byte("confl_tablespace")}, {Name: []byte("confl_lock")},
36 | {Name: []byte("confl_snapshot")}, {Name: []byte("confl_bufferpin")}, {Name: []byte("confl_deadlock")},
37 | },
38 | Rows: [][]sql.NullString{
39 | {
40 | {String: "testdb1", Valid: true}, {String: "123", Valid: true}, {String: "548", Valid: true},
41 | {String: "784", Valid: true}, {String: "896", Valid: true}, {String: "896", Valid: true},
42 | },
43 | {
44 | {String: "testdb2", Valid: true}, {}, {}, {}, {}, {},
45 | },
46 | },
47 | },
48 | want: map[string]postgresConflictStat{
49 | "testdb1": {database: "testdb1", tablespace: 123, lock: 548, snapshot: 784, bufferpin: 896, deadlock: 896},
50 | "testdb2": {database: "testdb2"},
51 | },
52 | },
53 | }
54 |
55 | for _, tc := range testCases {
56 | t.Run(tc.name, func(t *testing.T) {
57 | got := parsePostgresConflictStats(tc.res, []string{"database", "reason"})
58 | assert.EqualValues(t, tc.want, got)
59 | })
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | project_name: pgscv
2 |
3 | before:
4 | hooks:
5 | - make dep
6 |
7 | builds:
8 | - binary: pgscv
9 | main: ./cmd
10 | goarch:
11 | - '386'
12 | - amd64
13 | - arm64
14 | - arm
15 | goos:
16 | - linux
17 | # - windows
18 | # - freebsd
19 | # - darwin
20 | #ignore:
21 | # - goarch: arm
22 | # goos: windows
23 | # - goarch: arm64
24 | # goos: windows
25 | # - goarch: arm64
26 | # goos: freebsd
27 | env:
28 | - CGO_ENABLED=0
29 | ldflags:
30 | - -a -installsuffix cgo
31 | - -X main.appName=pgscv -X main.gitTag={{.Tag}} -X main.gitCommit={{.Commit}} -X main.gitBranch={{.Branch}}
32 |
33 | archives:
34 | - builds: [pgscv]
35 | builds_info:
36 | group: root
37 | owner: root
38 | wrap_in_directory: false
39 | files:
40 | - LICENSE
41 | - src: "deploy/pgscv.yaml"
42 | strip_parent: true
43 | info:
44 | owner: root
45 | group: root
46 | mode: 0640
47 | - src: "deploy/pgscv.service"
48 | strip_parent: true
49 | info:
50 | owner: root
51 | group: root
52 | mode: 0644
53 | - src: "deploy/pgscv.default"
54 | strip_parent: true
55 | info:
56 | owner: root
57 | group: root
58 | mode: 0644
59 | name_template: >-
60 | {{- .Binary }}_
61 | {{- .Version }}_
62 | {{- .Os }}_
63 | {{- if eq .Arch "amd64" }}x86_64
64 | {{- else if eq .Arch "386" }}i386
65 | {{- else }}{{ .Arch }}{{ end }}
66 | {{- if .Arm }}v{{ .Arm }}{{ end -}}
67 |
68 | changelog:
69 | sort: asc
70 |
71 | checksum:
72 | name_template: 'checksums.txt'
73 |
74 | nfpms:
75 | - vendor: pgscv
76 | homepage: https://github.com/cherts/pgscv
77 | maintainer: Mikhail Grigorev
78 | description: pgSCV - PostgreSQL ecosystem metrics collector.
79 | license: BSD-3
80 | formats: []
81 | bindir: /usr/sbin
82 |
--------------------------------------------------------------------------------
/deploy/helm-chart/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/*
2 | Expand the name of the chart.
3 | */}}
4 | {{- define "pgscv.name" -}}
5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6 | {{- end }}
7 |
8 | {{/*
9 | Create a default fully qualified app name.
10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
11 | If release name contains chart name it will be used as a full name.
12 | */}}
13 | {{- define "pgscv.fullname" -}}
14 | {{- if .Values.fullnameOverride }}
15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
16 | {{- else }}
17 | {{- $name := default .Chart.Name .Values.nameOverride }}
18 | {{- if contains $name .Release.Name }}
19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }}
20 | {{- else }}
21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
22 | {{- end }}
23 | {{- end }}
24 | {{- end }}
25 |
26 | {{/*
27 | Create chart name and version as used by the chart label.
28 | */}}
29 | {{- define "pgscv.chart" -}}
30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
31 | {{- end }}
32 |
33 | {{/*
34 | Common labels
35 | */}}
36 | {{- define "pgscv.labels" -}}
37 | helm.sh/chart: {{ include "pgscv.chart" . }}
38 | {{ include "pgscv.selectorLabels" . }}
39 | {{- if .Chart.AppVersion }}
40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
41 | {{- end }}
42 | app.kubernetes.io/managed-by: {{ .Release.Service }}
43 | {{- end }}
44 |
45 | {{/*
46 | Selector labels
47 | */}}
48 | {{- define "pgscv.selectorLabels" -}}
49 | app.kubernetes.io/name: {{ include "pgscv.name" . }}
50 | app.kubernetes.io/instance: {{ .Release.Name }}
51 | {{- end }}
52 |
53 | {{/*
54 | Create the name of the service account to use
55 | */}}
56 | {{- define "pgscv.serviceAccountName" -}}
57 | {{- if .Values.serviceAccount.create }}
58 | {{- default (include "pgscv.fullname" .) .Values.serviceAccount.name }}
59 | {{- else }}
60 | {{- default "default" .Values.serviceAccount.name }}
61 | {{- end }}
62 | {{- end }}
63 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Release
3 |
4 | on:
5 | workflow_dispatch:
6 | push:
7 | tags:
8 | - "v*"
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-22.04
13 | container: cherts/pgscv-test-runner:1.0.12
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@v6
17 | - name: Prepare test environment
18 | run: prepare-test-environment.sh
19 | - name: Run test
20 | run: make test
21 |
22 | build:
23 | runs-on: ubuntu-22.04
24 | needs: test
25 | steps:
26 | - name: Checkout code
27 | uses: actions/checkout@v6
28 | with:
29 | fetch-depth: 0
30 | - name: Set docker environment
31 | id: docker
32 | run: |
33 | echo DOCKERHUB_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}" >>$GITHUB_OUTPUT
34 | echo DOCKERHUB_TOKEN="${{ secrets.DOCKERHUB_TOKEN }}" >>$GITHUB_OUTPUT
35 | - name: Lint Dockerfile
36 | run: make docker-lint
37 | - name: Build image
38 | run: make docker-build
39 | - name: Log in to Docker Hub
40 | if: ${{ steps.docker.outputs.DOCKERHUB_USERNAME != '' && steps.docker.outputs.DOCKERHUB_TOKEN != '' }}
41 | run: docker login -u ${{ steps.docker.outputs.DOCKERHUB_USERNAME }} -p ${{ steps.docker.outputs.DOCKERHUB_TOKEN }}
42 | - name: Push image to Docker Hub
43 | if: ${{ steps.docker.outputs.DOCKERHUB_USERNAME != '' && steps.docker.outputs.DOCKERHUB_TOKEN != '' }}
44 | run: make docker-push
45 |
46 | goreleaser:
47 | runs-on: ubuntu-22.04
48 | needs: build
49 | steps:
50 | - name: Run checkout
51 | uses: actions/checkout@v6
52 | with:
53 | fetch-depth: 0
54 | - name: Run setup Go
55 | uses: actions/setup-go@v6
56 | with:
57 | go-version: 1.25
58 | - name: Run GoReleaser
59 | uses: goreleaser/goreleaser-action@v6
60 | with:
61 | version: '~> v2'
62 | args: release --clean
63 | env:
64 | GITHUB_TOKEN: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
65 |
--------------------------------------------------------------------------------
/internal/discovery/mapops/full_join_test.go:
--------------------------------------------------------------------------------
1 | package mapops
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestFullJoin(t *testing.T) {
9 | type test struct {
10 | left map[string]int
11 | right map[string]int
12 | want []struct{ Left, Right *string }
13 | }
14 |
15 | tests := []test{
16 | {
17 | left: map[string]int{"a": 1, "b": 2},
18 | right: map[string]int{"b": 3, "c": 4},
19 | want: []struct{ Left, Right *string }{
20 | {Left: stringPtr("a"), Right: nil},
21 | {Left: stringPtr("b"), Right: stringPtr("b")},
22 | {Left: nil, Right: stringPtr("c")},
23 | },
24 | },
25 | {
26 | left: map[string]int{},
27 | right: map[string]int{"c": 4},
28 | want: []struct{ Left, Right *string }{
29 | {Left: nil, Right: stringPtr("c")},
30 | },
31 | },
32 | {
33 | left: map[string]int{"a": 1},
34 | right: map[string]int{},
35 | want: []struct{ Left, Right *string }{
36 | {Left: stringPtr("a"), Right: nil},
37 | },
38 | },
39 | {
40 | left: map[string]int{},
41 | right: map[string]int{},
42 | want: []struct{ Left, Right *string }{},
43 | },
44 | }
45 |
46 | for i, tt := range tests {
47 | got := FullJoin(tt.left, tt.right)
48 | if !equalJoinResults(got, tt.want) {
49 | t.Errorf("Test %d: got %v, want %v", i, got, tt.want)
50 | }
51 | }
52 | }
53 |
54 | func equalJoinResults(a, b []struct{ Left, Right *string }) bool {
55 | if len(a) != len(b) {
56 | return false
57 | }
58 |
59 | aMap := make(map[string]*string)
60 | bMap := make(map[string]*string)
61 |
62 | for _, item := range a {
63 | key := ""
64 | if item.Left != nil {
65 | key += *item.Left
66 | }
67 | if item.Right != nil {
68 | key += *item.Right
69 | }
70 | aMap[key] = item.Left
71 | }
72 |
73 | for _, item := range b {
74 | key := ""
75 | if item.Left != nil {
76 | key += *item.Left
77 | }
78 | if item.Right != nil {
79 | key += *item.Right
80 | }
81 | bMap[key] = item.Left
82 | }
83 |
84 | return reflect.DeepEqual(aMap, bMap)
85 | }
86 |
87 | func stringPtr(s string) *string {
88 | return &s
89 | }
90 |
--------------------------------------------------------------------------------
/discovery/yandex_test.go:
--------------------------------------------------------------------------------
1 | package discovery_test
2 |
3 | import (
4 | "github.com/cherts/pgscv/discovery"
5 | "github.com/cherts/pgscv/discovery/factory"
6 | "github.com/cherts/pgscv/internal/discovery/service"
7 | "testing"
8 | )
9 |
10 | func TestInstantiate(t *testing.T) {
11 |
12 | testCases := []struct {
13 | name string
14 | cfg map[string]discovery.SdConfig
15 | wantErr bool
16 | }{
17 | {
18 | name: "succeed single service",
19 | cfg: map[string]discovery.SdConfig{
20 | "yandex1": {
21 | Type: discovery.YandexMDB,
22 | Config: []service.YandexConfig{
23 | {
24 | AuthorizedKey: "/tmp/authorized_key.json",
25 | FolderID: "asd234234234",
26 | User: "postgres_exporter",
27 | Password: "132",
28 | Clusters: []service.Cluster{
29 | {
30 | Db: stringPtr(".*"),
31 | ExcludeDb: stringPtr("(postgres|template)"),
32 | },
33 | },
34 | },
35 | },
36 | },
37 | },
38 | wantErr: false,
39 | },
40 | {
41 | name: "wrong single service",
42 | cfg: map[string]discovery.SdConfig{
43 | "yandex1": {
44 | Type: "abcdefg",
45 | Config: []string{},
46 | },
47 | },
48 | wantErr: true,
49 | },
50 | }
51 | for _, tc := range testCases {
52 | t.Run(tc.name, func(t *testing.T) {
53 | s, err := factory.Instantiate(tc.cfg)
54 | if (err != nil) != tc.wantErr {
55 | t.Errorf("Instantiate() error = %v, wantErr %v", err, tc.wantErr)
56 | }
57 | if tc.wantErr {
58 | return
59 | }
60 | for _, v := range *s {
61 | err := v.Subscribe("svc",
62 | func(_ map[string]discovery.Service) error {
63 | return nil
64 | },
65 | func(_ []string) error {
66 | return nil
67 | },
68 | )
69 | if err != nil {
70 | t.Errorf("Subscribe() error = %v", err)
71 | }
72 | err = v.Unsubscribe("svc")
73 | if err != nil {
74 | t.Errorf("Unsubscribe() error = %v", err)
75 | }
76 | }
77 | })
78 |
79 | }
80 |
81 | }
82 |
83 | func stringPtr(s string) *string {
84 | return &s
85 | }
86 |
--------------------------------------------------------------------------------
/internal/collector/postgres_stat_slru_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "testing"
6 |
7 | "github.com/cherts/pgscv/internal/model"
8 | "github.com/jackc/pgproto3/v2"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestPostgresStatSlruCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "postgres_stat_slru_blks_zeroed",
16 | "postgres_stat_slru_blks_hit",
17 | "postgres_stat_slru_blks_read",
18 | "postgres_stat_slru_blks_written",
19 | "postgres_stat_slru_blks_exists",
20 | "postgres_stat_slru_flushes",
21 | "postgres_stat_slru_truncates",
22 | },
23 | collector: NewPostgresStatSlruCollector,
24 | service: model.ServiceTypePostgresql,
25 | }
26 |
27 | pipeline(t, input)
28 | }
29 |
30 | func Test_parsePostgresStatSlru(t *testing.T) {
31 | var testCases = []struct {
32 | name string
33 | res *model.PGResult
34 | want map[string]postgresStatSlru
35 | }{
36 | {
37 | name: "normal output, Postgres 13",
38 | res: &model.PGResult{
39 | Nrows: 1,
40 | Ncols: 8,
41 | Colnames: []pgproto3.FieldDescription{
42 | {Name: []byte("name")}, {Name: []byte("blks_zeroed")}, {Name: []byte("blks_hit")}, {Name: []byte("blks_read")},
43 | {Name: []byte("blks_written")}, {Name: []byte("blks_exists")}, {Name: []byte("flushes")}, {Name: []byte("truncates")},
44 | },
45 | Rows: [][]sql.NullString{
46 | {
47 | {String: "subtransaction", Valid: true}, {String: "2972", Valid: true}, {String: "0", Valid: true}, {String: "0", Valid: true},
48 | {String: "2867", Valid: true}, {String: "0", Valid: true}, {String: "527", Valid: true}, {String: "527", Valid: true},
49 | },
50 | },
51 | },
52 | want: map[string]postgresStatSlru{
53 | "subtransaction": {
54 | SlruName: "subtransaction", BlksZeroed: 2972, BlksHit: 0, BlksRead: 0,
55 | BlksWritten: 2867, BlksExists: 0, Flushes: 527, Truncates: 527,
56 | },
57 | },
58 | },
59 | }
60 |
61 | for _, tc := range testCases {
62 | t.Run(tc.name, func(t *testing.T) {
63 | got := parsePostgresStatSlru(tc.res, []string{"name"})
64 | assert.EqualValues(t, tc.want, got)
65 | })
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/internal/discovery/cloud/yandex/sdk.go:
--------------------------------------------------------------------------------
1 | // Package yandex implements getter for YC MDB PostgreSQL clusters, hosts and databases
2 | package yandex
3 |
4 | import (
5 | "context"
6 | "fmt"
7 | "sync"
8 |
9 | "github.com/cherts/pgscv/discovery/log"
10 | "github.com/yandex-cloud/go-genproto/yandex/cloud/iam/v1"
11 | ycsdk "github.com/yandex-cloud/go-sdk"
12 | "github.com/yandex-cloud/go-sdk/iamkey"
13 | "google.golang.org/protobuf/types/known/timestamppb"
14 | )
15 |
16 | // SDK struct with limited lifespan token
17 | type SDK struct {
18 | sync.RWMutex
19 | key *authorizedKey
20 | ycsdk *ycsdk.SDK
21 | }
22 |
23 | // NewSDK load authorized key from json file and return pointer on SDK structure
24 | func NewSDK(jsonFilePath string) (*SDK, error) {
25 | log.Debug("[SD] Loading authorized key from json file...")
26 |
27 | key, err := loadAuthorizedKey(jsonFilePath)
28 | if err != nil {
29 | return nil, err
30 | }
31 | return &SDK{
32 | key: key,
33 | }, nil
34 | }
35 |
36 | // Build creates an SDK instance
37 | func (sdk *SDK) buildClient(ctx context.Context) (*ycsdk.SDK, error) {
38 | credentials, err := ycsdk.ServiceAccountKey(
39 | &iamkey.Key{ //nolint: exhaustruct
40 | Id: sdk.key.ID,
41 | CreatedAt: timestamppb.New(sdk.key.CreatedAt),
42 | KeyAlgorithm: iam.Key_Algorithm(iam.Key_Algorithm_value[sdk.key.KeyAlgorithm]),
43 | PublicKey: sdk.key.PublicKey,
44 | PrivateKey: sdk.key.PrivateKey,
45 | Subject: &iamkey.Key_ServiceAccountId{ServiceAccountId: sdk.key.ServiceAccountID},
46 | })
47 | if err != nil {
48 | return nil, fmt.Errorf("[SD] failed to construct key | %w", err)
49 | }
50 |
51 | y, err := ycsdk.Build(ctx,
52 | ycsdk.Config{ //nolint: exhaustruct
53 | Credentials: credentials,
54 | },
55 | )
56 | if err != nil {
57 | return nil, fmt.Errorf("[SD] failed to Build yandex sdk | %w", err)
58 | }
59 |
60 | return y, nil
61 | }
62 |
63 | // Build creates an SDK instance
64 | func (sdk *SDK) Build(ctx context.Context) (*ycsdk.SDK, error) {
65 | var err error
66 |
67 | if sdk.ycsdk == nil {
68 | sdk.ycsdk, err = sdk.buildClient(ctx)
69 |
70 | if err != nil {
71 | return nil, err
72 | }
73 | }
74 |
75 | return sdk.ycsdk, nil
76 | }
77 |
--------------------------------------------------------------------------------
/internal/collector/postgres_locks_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresLocksCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | required: []string{
14 | "postgres_locks_in_flight",
15 | "postgres_locks_all_in_flight",
16 | "postgres_locks_not_granted_in_flight",
17 | },
18 | collector: NewPostgresLocksCollector,
19 | service: model.ServiceTypePostgresql,
20 | }
21 |
22 | pipeline(t, input)
23 | }
24 |
25 | func Test_parsePostgresLocksStats(t *testing.T) {
26 | var testcases = []struct {
27 | name string
28 | res *model.PGResult
29 | want locksStat
30 | }{
31 | {
32 | name: "normal output",
33 | res: &model.PGResult{
34 | Nrows: 1,
35 | Ncols: 10,
36 | Colnames: []pgproto3.FieldDescription{
37 | {Name: []byte("access_share_lock")}, {Name: []byte("row_share_lock")},
38 | {Name: []byte("row_exclusive_lock")}, {Name: []byte("share_update_exclusive_lock")},
39 | {Name: []byte("share_lock")}, {Name: []byte("share_row_exclusive_lock")},
40 | {Name: []byte("exclusive_lock")}, {Name: []byte("access_exclusive_lock")},
41 | {Name: []byte("not_granted")}, {Name: []byte("total")},
42 | },
43 | Rows: [][]sql.NullString{
44 | {
45 | {String: "11", Valid: true}, {String: "5", Valid: true},
46 | {String: "4", Valid: true}, {String: "8", Valid: true},
47 | {String: "7", Valid: true}, {String: "9", Valid: true},
48 | {String: "1", Valid: true}, {String: "2", Valid: true},
49 | {String: "6", Valid: true}, {String: "47", Valid: true},
50 | },
51 | },
52 | },
53 | want: locksStat{
54 | accessShareLock: 11, rowShareLock: 5, rowExclusiveLock: 4, shareUpdateExclusiveLock: 8,
55 | shareLock: 7, shareRowExclusiveLock: 9, exclusiveLock: 1, accessExclusiveLock: 2,
56 | notGranted: 6, total: 47,
57 | },
58 | },
59 | }
60 |
61 | for _, tc := range testcases {
62 | t.Run(tc.name, func(t *testing.T) {
63 | got := parsePostgresLocksStats(tc.res)
64 | assert.EqualValues(t, tc.want, got)
65 | })
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/internal/collector/filesystem_common.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "bufio"
6 | "fmt"
7 | "io"
8 | "os"
9 | "strings"
10 |
11 | "github.com/cherts/pgscv/internal/log"
12 | )
13 |
14 | // mount describes properties of mounted filesystems
15 | type mount struct {
16 | device string
17 | mountpoint string
18 | fstype string
19 | options string
20 | }
21 |
22 | // parseProcMounts parses /proc/mounts and returns slice of mounted filesystems properties.
23 | func parseProcMounts(r io.Reader) ([]mount, error) {
24 | log.Debug("parse mounted filesystems")
25 | var (
26 | scanner = bufio.NewScanner(r)
27 | mounts []mount
28 | )
29 |
30 | // Parse line by line, split line to param and value, parse the value to float and save to store.
31 | for scanner.Scan() {
32 | parts := strings.Fields(scanner.Text())
33 |
34 | if len(parts) != 6 {
35 | return nil, fmt.Errorf("invalid input: '%s', skip", scanner.Text())
36 | }
37 |
38 | s := mount{
39 | device: parts[0],
40 | mountpoint: parts[1],
41 | fstype: parts[2],
42 | options: parts[3],
43 | }
44 |
45 | mounts = append(mounts, s)
46 | }
47 |
48 | return mounts, scanner.Err()
49 | }
50 |
51 | // truncateDeviceName truncates passed full path to device to short device name.
52 | func truncateDeviceName(path string) string {
53 | if path == "" {
54 | log.Warnf("cannot truncate empty device path")
55 | return ""
56 | }
57 | // Make name which will be returned in case of later errors occurred.
58 | parts := strings.Split(path, "/")
59 | name := parts[len(parts)-1]
60 |
61 | // Check device path exists.
62 | fi, err := os.Lstat(path)
63 | if err != nil {
64 | log.Debugf("%s, use default '%s'", err, name)
65 | return name
66 | }
67 |
68 | // If path is symlink, try dereference it.
69 | if fi.Mode()&os.ModeSymlink != 0 {
70 | resolved, err := os.Readlink(path)
71 | if err != nil {
72 | log.Warnf("%s, use name's last part '%s'", err, name)
73 | return name
74 | }
75 | // Swap name to dereferenced origin.
76 | parts := strings.Split(resolved, "/")
77 | name = parts[len(parts)-1]
78 | }
79 |
80 | // Return default (or dereferenced) name.
81 | return name
82 | }
83 |
--------------------------------------------------------------------------------
/internal/collector/postgres_archiver_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresWalArchivingCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | required: []string{},
14 | optional: []string{
15 | "postgres_archiver_archived_total",
16 | "postgres_archiver_failed_total",
17 | "postgres_archiver_since_last_archive_seconds",
18 | "postgres_archiver_lag_bytes",
19 | },
20 | collector: NewPostgresWalArchivingCollector,
21 | service: model.ServiceTypePostgresql,
22 | }
23 |
24 | pipeline(t, input)
25 | }
26 |
27 | func Test_parsePostgresWalArchivingStats(t *testing.T) {
28 | var testCases = []struct {
29 | name string
30 | res *model.PGResult
31 | want postgresWalArchivingStat
32 | }{
33 | {
34 | name: "normal output",
35 | res: &model.PGResult{
36 | Nrows: 1,
37 | Ncols: 4,
38 | Colnames: []pgproto3.FieldDescription{
39 | {Name: []byte("archived_count")}, {Name: []byte("failed_count")},
40 | {Name: []byte("since_last_archive_seconds")}, {Name: []byte("lag_files")},
41 | },
42 | Rows: [][]sql.NullString{
43 | {
44 | {String: "4587", Valid: true}, {String: "0", Valid: true},
45 | {String: "17", Valid: true}, {String: "159", Valid: true},
46 | },
47 | },
48 | },
49 | want: postgresWalArchivingStat{archived: 4587, failed: 0, sinceArchivedSeconds: 17, lagFiles: 159},
50 | },
51 | {
52 | name: "no rows output",
53 | res: &model.PGResult{
54 | Nrows: 0,
55 | Ncols: 5,
56 | Colnames: []pgproto3.FieldDescription{
57 | {Name: []byte("archived_count")}, {Name: []byte("failed_count")},
58 | {Name: []byte("since_last_archive_seconds")}, {Name: []byte("lag_bytes")},
59 | },
60 | Rows: [][]sql.NullString{},
61 | },
62 | want: postgresWalArchivingStat{archived: 0, failed: 0, sinceArchivedSeconds: 0, lagFiles: 0},
63 | },
64 | }
65 |
66 | for _, tc := range testCases {
67 | t.Run(tc.name, func(t *testing.T) {
68 | got := parsePostgresWalArchivingStats(tc.res)
69 | assert.EqualValues(t, tc.want, got)
70 | })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/internal/collector/postgres_indexes_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresIndexesCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | optional: []string{
14 | "postgres_index_scans_total",
15 | "postgres_index_tuples_total",
16 | "postgres_index_io_blocks_total",
17 | "postgres_index_size_bytes",
18 | },
19 | collector: NewPostgresIndexesCollector,
20 | service: model.ServiceTypePostgresql,
21 | }
22 |
23 | pipeline(t, input)
24 | }
25 |
26 | func Test_parsePostgresIndexStats(t *testing.T) {
27 | var testCases = []struct {
28 | name string
29 | res *model.PGResult
30 | want map[string]postgresIndexStat
31 | }{
32 | {
33 | name: "normal output",
34 | res: &model.PGResult{
35 | Nrows: 1,
36 | Ncols: 9,
37 | Colnames: []pgproto3.FieldDescription{
38 | {Name: []byte("database")}, {Name: []byte("schema")}, {Name: []byte("table")}, {Name: []byte("index")},
39 | {Name: []byte("idx_scan")}, {Name: []byte("idx_tup_read")}, {Name: []byte("idx_tup_fetch")},
40 | {Name: []byte("idx_blks_read")}, {Name: []byte("idx_blks_hit")},
41 | },
42 | Rows: [][]sql.NullString{
43 | {
44 | {String: "testdb", Valid: true}, {String: "testschema", Valid: true}, {String: "testrelname", Valid: true}, {String: "testindex", Valid: true},
45 | {String: "5842", Valid: true}, {String: "84572", Valid: true}, {String: "485", Valid: true}, {String: "4128", Valid: true}, {String: "847", Valid: true},
46 | },
47 | },
48 | },
49 | want: map[string]postgresIndexStat{
50 | "testdb/testschema/testrelname/testindex": {
51 | database: "testdb", schema: "testschema", table: "testrelname", index: "testindex",
52 | idxscan: 5842, idxtupread: 84572, idxtupfetch: 485, idxread: 4128, idxhit: 847,
53 | },
54 | },
55 | },
56 | }
57 |
58 | for _, tc := range testCases {
59 | t.Run(tc.name, func(t *testing.T) {
60 | got := parsePostgresIndexStats(tc.res, []string{"datname", "schemaname", "relname", "indexrelname"})
61 | assert.EqualValues(t, tc.want, got)
62 | })
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/internal/collector/config_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/cherts/pgscv/internal/store"
5 | "github.com/stretchr/testify/assert"
6 | "testing"
7 | )
8 |
9 | func Test_newPostgresServiceConfig(t *testing.T) {
10 | var testCases = []struct {
11 | name string
12 | connStr string
13 | valid bool
14 | }{
15 | {name: "valid config", connStr: "host=127.0.0.1 dbname=pgscv_fixtures user=pgscv", valid: true},
16 | {name: "invalid config", connStr: "invalid", valid: false},
17 | }
18 |
19 | for _, tc := range testCases {
20 | t.Run(tc.name, func(t *testing.T) {
21 | _, err := newPostgresServiceConfig(tc.connStr, 0)
22 | if tc.valid {
23 | assert.NoError(t, err)
24 | } else {
25 | assert.Error(t, err)
26 | }
27 | })
28 | }
29 | }
30 |
31 | func Test_isAddressLocal(t *testing.T) {
32 | testcases := []struct {
33 | addr string
34 | want bool
35 | }{
36 | {addr: "", want: false},
37 | {addr: "127.0.0.1", want: true},
38 | {addr: "127.1.2.3", want: true},
39 | {addr: "/", want: true},
40 | {addr: "/var/run/postgresql", want: true},
41 | {addr: "localhost", want: true},
42 | {addr: "example", want: false},
43 | {addr: "1.2.3.4", want: false},
44 | {addr: "::1", want: true},
45 | }
46 |
47 | for _, tc := range testcases {
48 | assert.Equal(t, tc.want, isAddressLocal(tc.addr))
49 | }
50 | }
51 |
52 | func Test_discoverPgStatStatements(t *testing.T) {
53 | testcases := []struct {
54 | valid bool
55 | connstr string
56 | }{
57 | {valid: true, connstr: store.TestPostgresConnStr},
58 | {valid: false, connstr: "database"},
59 | {valid: false, connstr: "database=invalid"},
60 | }
61 |
62 | for _, tc := range testcases {
63 | exists, database, schema, err := discoverPgStatStatements(tc.connstr)
64 | if tc.valid {
65 | assert.True(t, exists)
66 | assert.Equal(t, "pgscv_fixtures", database)
67 | assert.Equal(t, "public", schema)
68 | assert.NoError(t, err)
69 | } else {
70 | assert.Error(t, err)
71 | }
72 | }
73 | }
74 |
75 | func Test_extensionInstalledSchema(t *testing.T) {
76 | conn := store.NewTest(t)
77 |
78 | assert.Equal(t, extensionInstalledSchema(conn, "plpgsql"), "pg_catalog")
79 | assert.Equal(t, extensionInstalledSchema(conn, "invalid"), "")
80 | conn.Close()
81 | }
82 |
--------------------------------------------------------------------------------
/internal/collector/linux_network_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "net"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestNetworkCollector_Update(t *testing.T) {
11 | var input = pipelineInput{
12 | required: []string{
13 | "node_network_public_addresses",
14 | "node_network_private_addresses",
15 | },
16 | collector: NewNetworkCollector,
17 | }
18 |
19 | pipeline(t, input)
20 | }
21 |
22 | func Test_parseInterfaceAddresses(t *testing.T) {
23 | addresses := []net.Addr{
24 | &net.IPAddr{IP: net.ParseIP("10.50.20.22")},
25 | &net.IPAddr{IP: net.ParseIP("172.17.40.11")},
26 | &net.IPAddr{IP: net.ParseIP("192.168.122.1")},
27 | &net.IPAddr{IP: net.ParseIP("8.8.8.8")},
28 | &net.IPAddr{IP: net.ParseIP("8.8.4.4")},
29 | &net.IPAddr{IP: net.ParseIP("invalid")},
30 | }
31 | want := map[string]int{
32 | "public": 2,
33 | "private": 3,
34 | }
35 |
36 | got := parseInterfaceAddresses(addresses)
37 | assert.Equal(t, want, got)
38 | }
39 |
40 | func Test_isPrivate(t *testing.T) {
41 | var testcases = []struct {
42 | in string
43 | valid bool
44 | want bool
45 | }{
46 | {in: "127.0.0.1", valid: true, want: true},
47 | {in: "127.0.0.1/32", valid: true, want: true},
48 | {in: "10.20.30.40", valid: true, want: true},
49 | {in: "10.20.30.40/8", valid: true, want: true},
50 | {in: "172.16.8.4", valid: true, want: true},
51 | {in: "172.16.8.4/16", valid: true, want: true},
52 | {in: "192.168.100.55", valid: true, want: true},
53 | {in: "192.168.100.55/24", valid: true, want: true},
54 | {in: "::1", valid: true, want: true},
55 | {in: "fd00:3456::", valid: true, want: true},
56 | {in: "fe80:1234::", valid: true, want: true},
57 | {in: "1.2.4.5", valid: true, want: false},
58 | {in: "12.10.10.1", valid: true, want: false},
59 | {in: "180.142.250.11", valid: true, want: false},
60 | {in: "195.85.48.44", valid: true, want: false},
61 | {in: "invalid", valid: false, want: false},
62 | {in: "1.1", valid: false, want: false},
63 | {in: "1:1", valid: false, want: false},
64 | {in: "169.254.67.118", valid: true, want: true},
65 | }
66 |
67 | for _, tc := range testcases {
68 | got, err := isPrivate(tc.in)
69 | if tc.valid {
70 | assert.NoError(t, err)
71 | assert.Equal(t, tc.want, got)
72 | } else {
73 | assert.Error(t, err)
74 | assert.Equal(t, tc.want, got)
75 | }
76 |
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # MacOS General
2 | .DS_Store
3 | .AppleDouble
4 | .LSOverride
5 | # MacOS Icon must end with two \r
6 | Icon
7 | # MacOS Thumbnails
8 | ._*
9 | # MacOS Files that might appear in the root of a volume
10 | .DocumentRevisions-V100
11 | .fseventsd
12 | .Spotlight-V100
13 | .TemporaryItems
14 | .Trashes
15 | .VolumeIcon.icns
16 | .com.apple.timemachine.donotpresent
17 | # MacOS Directories potentially created on remote AFP share
18 | .AppleDB
19 | .AppleDesktop
20 | Network Trash Folder
21 | Temporary Items
22 | .apdisk
23 | # Linux
24 | *~
25 | # Linux temporary files which can be created if a process still has a handle open of a deleted file
26 | .fuse_hidden*
27 | # Linux KDE directory preferences
28 | .directory
29 | # Linux trash folder which might appear on any partition or disk
30 | .Trash-*
31 | # Linux .nfs files are created when an open file is removed but is still being accessed
32 | .nfs*
33 | # VSCode settings
34 | .vscode
35 | .vs
36 | # Debug
37 | debug_run.*
38 | # Bin
39 | bin/*
40 | # Cert
41 | *.pem
42 | *.key
43 | *.crt
44 | # Test
45 | .test_coverage.*
46 | # Exclude demo-stack data
47 | deploy/demo-lab/pgbench/stop_pgbench_*
48 | deploy/demo-lab/grafana/data/*
49 | deploy/demo-lab/patroni/etc_data1/*
50 | deploy/demo-lab/patroni/etc_data2/*
51 | deploy/demo-lab/patroni/etc_data3/*
52 | deploy/demo-lab/patroni/pg_data1/*
53 | deploy/demo-lab/patroni/pg_data2/*
54 | deploy/demo-lab/patroni/pg_data3/*
55 | deploy/demo-lab/postgres/pg9data/*
56 | deploy/demo-lab/postgres/pg10data/*
57 | deploy/demo-lab/postgres/pg11data/*
58 | deploy/demo-lab/postgres/pg12data/*
59 | deploy/demo-lab/postgres/pg13data/*
60 | deploy/demo-lab/postgres/pg14data/*
61 | deploy/demo-lab/postgres/pg15data/*
62 | deploy/demo-lab/postgres/pg16data/*
63 | deploy/demo-lab/postgres/pg17data/*
64 | deploy/demo-lab/postgres/pg9replica1data
65 | deploy/demo-lab/postgres/pg9replica2data
66 | deploy/demo-lab/postgres/pg10replica1data
67 | deploy/demo-lab/postgres/pg11replica1data
68 | deploy/demo-lab/postgres/pg12replica1data
69 | deploy/demo-lab/postgres/pg13replica1data
70 | deploy/demo-lab/postgres/pg14replica1data
71 | deploy/demo-lab/postgres/pg15replica1data
72 | deploy/demo-lab/postgres/pg16replica1data
73 | deploy/demo-lab/postgres/pg17replica1data
74 | deploy/demo-lab/postgres/pg17replica2data
75 | deploy/demo-lab/victoriametrics/data/*
76 | deploy/demo-lab/vmagent/data/*
77 | deploy/demo-lab/pgscv/*debug.yaml
78 | deploy/demo-lab/*.debug.*
--------------------------------------------------------------------------------
/deploy/demo-lab/compose.pgscv.yml:
--------------------------------------------------------------------------------
1 | services:
2 | pgscv:
3 | container_name: pgscv
4 | image: cherts/pgscv:latest
5 | ports:
6 | - 9890:9890
7 | env_file:
8 | - path: ${PWD}/.env
9 | required: true
10 | command:
11 | - --config-file=/app/conf/pgscv.yaml
12 | volumes:
13 | - ${PWD}/pgscv:/app/conf
14 | networks: [ monitoring ]
15 | depends_on:
16 | patroni1:
17 | condition: service_healthy
18 | restart: true
19 | patroni2:
20 | condition: service_healthy
21 | restart: true
22 | patroni3:
23 | condition: service_healthy
24 | restart: true
25 | postgres9:
26 | condition: service_healthy
27 | restart: true
28 | postgres10:
29 | condition: service_healthy
30 | restart: true
31 | postgres11:
32 | condition: service_healthy
33 | restart: true
34 | postgres12:
35 | condition: service_healthy
36 | restart: true
37 | postgres13:
38 | condition: service_healthy
39 | restart: true
40 | postgres14:
41 | condition: service_healthy
42 | restart: true
43 | postgres15:
44 | condition: service_healthy
45 | restart: true
46 | postgres16:
47 | condition: service_healthy
48 | restart: true
49 | postgres17:
50 | condition: service_healthy
51 | restart: true
52 | postgres9replica1:
53 | condition: service_healthy
54 | restart: true
55 | postgres9replica2:
56 | condition: service_healthy
57 | restart: true
58 | postgres10replica1:
59 | condition: service_healthy
60 | restart: true
61 | postgres11replica1:
62 | condition: service_healthy
63 | restart: true
64 | postgres12replica1:
65 | condition: service_healthy
66 | restart: true
67 | postgres13replica1:
68 | condition: service_healthy
69 | restart: true
70 | postgres14replica1:
71 | condition: service_healthy
72 | restart: true
73 | postgres15replica1:
74 | condition: service_healthy
75 | restart: true
76 | postgres16replica1:
77 | condition: service_healthy
78 | restart: true
79 | postgres17replica1:
80 | condition: service_healthy
81 | restart: true
82 |
83 | networks:
84 | monitoring:
85 |
--------------------------------------------------------------------------------
/internal/collector/postgres_replication_slots_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresReplicationSlotCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | required: []string{},
14 | optional: []string{
15 | "postgres_replication_slot_wal_retain_bytes",
16 | },
17 | collector: NewPostgresReplicationSlotsCollector,
18 | service: model.ServiceTypePostgresql,
19 | }
20 |
21 | pipeline(t, input)
22 | }
23 |
24 | func Test_parsePostgresReplicationSlotStats(t *testing.T) {
25 | var testCases = []struct {
26 | name string
27 | res *model.PGResult
28 | want map[string]postgresReplicationSlotStat
29 | }{
30 | {
31 | name: "normal output",
32 | res: &model.PGResult{
33 | Nrows: 1,
34 | Ncols: 15,
35 | Colnames: []pgproto3.FieldDescription{
36 | {Name: []byte("slot_name")}, {Name: []byte("slot_type")}, {Name: []byte("database")}, {Name: []byte("active")}, {Name: []byte("since_restart_bytes")},
37 | },
38 | Rows: [][]sql.NullString{
39 | {
40 | {String: "testslot", Valid: true}, {String: "testtype", Valid: true}, {String: "testdb", Valid: true}, {String: "t", Valid: true}, {String: "25485425", Valid: true},
41 | },
42 | },
43 | },
44 | want: map[string]postgresReplicationSlotStat{
45 | "testdb/testslot/testtype": {slotname: "testslot", slottype: "testtype", database: "testdb", active: "t", retainedBytes: 25485425},
46 | },
47 | },
48 | }
49 |
50 | for _, tc := range testCases {
51 | t.Run(tc.name, func(t *testing.T) {
52 | got := parsePostgresReplicationSlotStats(tc.res, []string{"slot_name", "slot_type", "database", "active"})
53 | assert.EqualValues(t, tc.want, got)
54 | })
55 | }
56 | }
57 |
58 | func Test_selectReplicationSlotQuery(t *testing.T) {
59 | var testcases = []struct {
60 | version int
61 | want string
62 | }{
63 | {version: 90600, want: postgresReplicationSlotQuery96},
64 | {version: 90605, want: postgresReplicationSlotQuery96},
65 | {version: 100000, want: postgresReplicationSlotQueryLatest},
66 | {version: 100005, want: postgresReplicationSlotQueryLatest},
67 | }
68 |
69 | for _, tc := range testcases {
70 | t.Run("", func(t *testing.T) {
71 | assert.Equal(t, tc.want, selectReplicationSlotQuery(tc.version))
72 | })
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/cmd/pgscv.go:
--------------------------------------------------------------------------------
1 | // Package main is a pgSCV main package
2 | package main
3 |
4 | import (
5 | "context"
6 | "fmt"
7 | "os"
8 | "os/signal"
9 | "syscall"
10 |
11 | "github.com/cherts/pgscv/discovery/factory"
12 | sdlog "github.com/cherts/pgscv/discovery/log"
13 |
14 | "github.com/alecthomas/kingpin/v2"
15 | "github.com/cherts/pgscv/internal/log"
16 | "github.com/cherts/pgscv/internal/pgscv"
17 | //_ "net/http/pprof"
18 | )
19 |
20 | var (
21 | appName, gitTag, gitCommit, gitBranch string
22 | )
23 |
24 | func main() {
25 | var (
26 | showVersion = kingpin.Flag("version", "show version and exit").Default().Bool()
27 | logLevel = kingpin.Flag("log-level", "set log level: debug, info, warn, error").Default("info").Envar("LOG_LEVEL").String()
28 | configFile = kingpin.Flag("config-file", "path to config file").Default("").Envar("PGSCV_CONFIG_FILE").String()
29 | )
30 | kingpin.Parse()
31 | log.SetLevel(*logLevel)
32 | log.SetApplication(appName)
33 | sdlog.Logger.Debug = log.Debug
34 | sdlog.Logger.Errorf = log.Errorf
35 | sdlog.Logger.Infof = log.Infof
36 | sdlog.Logger.Debugf = log.Debugf
37 | if *showVersion {
38 | fmt.Printf("%s %s %s-%s\n", appName, gitTag, gitCommit, gitBranch)
39 | os.Exit(0)
40 | }
41 |
42 | log.Infoln("starting ", appName, " ", gitTag, " ", gitCommit, "-", gitBranch)
43 |
44 | //go func() {
45 | // log.Infoln(http.ListenAndServe(":6060", nil))
46 | //}()
47 |
48 | config, err := pgscv.NewConfig(*configFile)
49 | if err != nil {
50 | log.Errorln("create config failed: ", err)
51 | os.Exit(1)
52 | }
53 |
54 | if config.DiscoveryConfig != nil {
55 | config.DiscoveryServices, err = factory.Instantiate(*config.DiscoveryConfig)
56 | if err != nil {
57 | log.Errorln("instantiate service discovery failed: ", err)
58 | os.Exit(1)
59 | }
60 | }
61 |
62 | if err := config.Validate(); err != nil {
63 | log.Errorln("validate config failed: ", err)
64 | os.Exit(1)
65 | }
66 |
67 | ctx, cancel := context.WithCancel(context.Background())
68 |
69 | var doExit = make(chan error, 2)
70 | go func() {
71 | doExit <- listenSignals()
72 | cancel()
73 | }()
74 |
75 | go func() {
76 | doExit <- pgscv.Start(ctx, config)
77 | cancel()
78 | }()
79 |
80 | log.Warnf("received shutdown signal: '%s'", <-doExit)
81 | }
82 |
83 | func listenSignals() error {
84 | c := make(chan os.Signal, 1)
85 | defer signal.Stop(c)
86 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
87 | return fmt.Errorf("%s", <-c)
88 | }
89 |
--------------------------------------------------------------------------------
/internal/collector/linux_filesystem_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/cherts/pgscv/internal/filter"
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/stretchr/testify/assert"
7 | "os"
8 | "path/filepath"
9 | "testing"
10 | "time"
11 | )
12 |
13 | func TestFilesystemCollector_Update(t *testing.T) {
14 | var input = pipelineInput{
15 | required: []string{
16 | "node_filesystem_bytes",
17 | "node_filesystem_bytes_total",
18 | "node_filesystem_files",
19 | "node_filesystem_files_total",
20 | },
21 | collector: NewFilesystemCollector,
22 | collectorSettings: model.CollectorSettings{Filters: filter.New()},
23 | }
24 |
25 | pipeline(t, input)
26 | }
27 |
28 | func Test_getFilesystemStats(t *testing.T) {
29 | got, err := getFilesystemStats()
30 | assert.NoError(t, err)
31 | assert.NotNil(t, got)
32 | assert.Greater(t, len(got), 0)
33 | }
34 |
35 | func Test_parseFilesystemStats(t *testing.T) {
36 | file, err := os.Open(filepath.Clean("testdata/proc/mounts.golden"))
37 | assert.NoError(t, err)
38 |
39 | stats, err := parseFilesystemStats(file)
40 | assert.NoError(t, err)
41 | assert.Greater(t, len(stats), 1)
42 | assert.Greater(t, stats[0].size, float64(0))
43 | assert.Greater(t, stats[0].free, float64(0))
44 | assert.Greater(t, stats[0].avail, float64(0))
45 | assert.Greater(t, stats[0].files, float64(0))
46 | assert.Greater(t, stats[0].filesfree, float64(0))
47 |
48 | _ = file.Close()
49 |
50 | // test with wrong format file
51 | file, err = os.Open(filepath.Clean("testdata/proc/netdev.golden"))
52 | assert.NoError(t, err)
53 |
54 | stats, err = parseFilesystemStats(file)
55 | assert.Error(t, err)
56 | assert.Nil(t, stats)
57 | _ = file.Close()
58 | }
59 |
60 | func Test_readMountpointStat(t *testing.T) {
61 | stat, err := readMountpointStat("/")
62 | assert.NoError(t, err)
63 | assert.Greater(t, stat.size, float64(0))
64 | assert.Greater(t, stat.free, float64(0))
65 | assert.Greater(t, stat.avail, float64(0))
66 | assert.Greater(t, stat.files, float64(0))
67 | assert.Greater(t, stat.filesfree, float64(0))
68 |
69 | // unknown filesystem
70 | stat, err = readMountpointStat("/invalid")
71 | assert.Error(t, err)
72 | }
73 |
74 | func Test_readMountpointStatWithTimeout(t *testing.T) {
75 | stat, err := readMountpointStatWithTimeout("/", time.Second)
76 | assert.NoError(t, err)
77 | assert.Greater(t, stat.Blocks, uint64(0))
78 |
79 | // unknown filesystem
80 | _, err = readMountpointStatWithTimeout("/invalid", time.Second)
81 | assert.Error(t, err)
82 | }
83 |
--------------------------------------------------------------------------------
/deploy/demo-lab/compose.victoriametrics.yml:
--------------------------------------------------------------------------------
1 | services:
2 | victoriametrics:
3 | container_name: victoriametrics
4 | image: victoriametrics/victoria-metrics:stable
5 | volumes:
6 | - vmsingle_data:/data
7 | command:
8 | - "-storageDataPath=/data"
9 | - "-retentionPeriod=2d"
10 | ports:
11 | - 8428:8428
12 | networks: [ monitoring ]
13 | vmagent:
14 | container_name: vmagent
15 | image: victoriametrics/vmagent:stable
16 | volumes:
17 | - vmagent_data:/vmagentdata
18 | - ${PWD}/vmagent/vmagent.yaml:/etc/vmagent.yaml
19 | command:
20 | - "-promscrape.config=/etc/vmagent.yaml"
21 | - "-remoteWrite.url=http://victoriametrics:8428/api/v1/write"
22 | - "-remoteWrite.tmpDataPath=/vmagentdata"
23 | - "-promscrape.httpSDCheckInterval=30s"
24 | depends_on:
25 | - victoriametrics
26 | - pgscv
27 | ports:
28 | - 8429:8429
29 | networks: [ monitoring ]
30 | grafana:
31 | container_name: grafana
32 | image: grafana/grafana:main
33 | volumes:
34 | - grafana_data:/var/lib/grafana
35 | - ${PWD}/grafana/provisioning/datasources/single.yml:/etc/grafana/provisioning/datasources/single.yml
36 | - ${PWD}/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards
37 | - ${PWD}/grafana/dashboards/vmetrics_single.json:/var/lib/grafana/dashboards/vmetrics_single.json
38 | - ${PWD}/grafana/dashboards/vmagent.json:/var/lib/grafana/dashboards/vmagent.json
39 | - ${PWD}/grafana/dashboards/pgSCV_System.json:/var/lib/grafana/dashboards/pgSCV_System.json
40 | - ${PWD}/grafana/dashboards/pgSCV_PostgreSQL.json:/var/lib/grafana/dashboards/pgSCV_PostgreSQL.json
41 | - ${PWD}/grafana/dashboards/pgSCV_Pgbouncer.json:/var/lib/grafana/dashboards/pgSCV_Pgbouncer.json
42 | - ${PWD}/grafana/dashboards/pgSCV_Patroni.json:/var/lib/grafana/dashboards/pgSCV_Patroni.json
43 | environment:
44 | GF_SECURITY_ADMIN_PASSWORD: "admin"
45 | depends_on: [ victoriametrics ]
46 | ports:
47 | - 3000:3000
48 | networks: [ monitoring ]
49 |
50 | volumes:
51 | vmagent_data:
52 | driver: local
53 | driver_opts:
54 | o: bind
55 | type: rw
56 | device: ${PWD}/vmagent/data
57 | vmsingle_data:
58 | driver: local
59 | driver_opts:
60 | o: bind
61 | type: rw
62 | device: ${PWD}/victoriametrics/data
63 | grafana_data:
64 | driver: local
65 | driver_opts:
66 | o: bind
67 | type: rw
68 | device: ${PWD}/grafana/data
69 |
70 | networks:
71 | monitoring:
72 |
--------------------------------------------------------------------------------
/internal/pgscv/pgscv_test.go:
--------------------------------------------------------------------------------
1 | package pgscv
2 |
3 | import (
4 | "context"
5 | "github.com/cherts/pgscv/internal/http"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/cherts/pgscv/internal/service"
8 | "github.com/cherts/pgscv/internal/store"
9 | "github.com/stretchr/testify/assert"
10 | "io"
11 | "sync"
12 | "testing"
13 | "time"
14 | )
15 |
16 | func TestStart(t *testing.T) {
17 | writeSrv := http.TestServer(t, http.StatusOK, "")
18 | defer writeSrv.Close()
19 |
20 | // Create app config.
21 | config := &Config{
22 | ListenAddress: "127.0.0.1:5002",
23 | ServicesConnsSettings: map[string]service.ConnSetting{
24 | "postgres:5432": {ServiceType: model.ServiceTypePostgresql, Conninfo: store.TestPostgresConnStr},
25 | },
26 | }
27 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
28 | defer cancel()
29 |
30 | // Start app, wait until context expires and do cleanup.
31 | assert.NoError(t, Start(ctx, config))
32 | }
33 |
34 | func Test_runMetricsListener(t *testing.T) {
35 | config := &Config{ListenAddress: "127.0.0.1:5003"}
36 | wg := sync.WaitGroup{}
37 |
38 | // Running listener function with short-live context in concurrent goroutine.
39 | wg.Add(1)
40 | go func() {
41 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
42 | defer cancel()
43 |
44 | err := runMetricsListener(ctx, config, nil)
45 | assert.NoError(t, err)
46 | wg.Done()
47 | }()
48 |
49 | // Sleep a little hoping it will be enough for running listener goroutine.
50 | time.Sleep(500 * time.Millisecond)
51 |
52 | // Make request to '/' and assert response.
53 | cl := http.NewClient(http.ClientConfig{})
54 | resp, err := cl.Get("http://127.0.0.1:5003/")
55 | assert.NoError(t, err)
56 | assert.Equal(t, resp.StatusCode, http.StatusOK)
57 | body, err := io.ReadAll(resp.Body)
58 | assert.NoError(t, err)
59 | assert.Contains(t, string(body), `pgSCV / PostgreSQL metrics collector, for more info visit Github page.`)
60 | assert.NoError(t, resp.Body.Close())
61 |
62 | // Make request to '/metrics' and assert response.
63 | resp, err = cl.Get("http://127.0.0.1:5003/metrics")
64 | assert.NoError(t, err)
65 | assert.Equal(t, resp.StatusCode, http.StatusOK)
66 |
67 | body, err = io.ReadAll(resp.Body)
68 | assert.NoError(t, err)
69 | assert.Contains(t, string(body), "go_gc_duration_seconds")
70 | assert.Contains(t, string(body), "process_cpu_seconds_total")
71 | assert.Contains(t, string(body), "promhttp_metric_handler_requests_in_flight")
72 | assert.NoError(t, resp.Body.Close())
73 |
74 | // Waiting for listener goroutine.
75 | wg.Wait()
76 | }
77 |
--------------------------------------------------------------------------------
/internal/collector/linux_load_average.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "fmt"
6 | "os"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/cherts/pgscv/internal/log"
11 | "github.com/cherts/pgscv/internal/model"
12 | "github.com/prometheus/client_golang/prometheus"
13 | )
14 |
15 | type loadaverageCollector struct {
16 | load1 typedDesc
17 | load5 typedDesc
18 | load15 typedDesc
19 | }
20 |
21 | // NewLoadAverageCollector returns a new Collector exposing load average statistics.
22 | func NewLoadAverageCollector(constLabels labels, settings model.CollectorSettings) (Collector, error) {
23 | return &loadaverageCollector{
24 | load1: newBuiltinTypedDesc(
25 | descOpts{"node", "", "load1", "1m load average.", 0},
26 | prometheus.GaugeValue,
27 | nil, constLabels,
28 | settings.Filters,
29 | ),
30 | load5: newBuiltinTypedDesc(
31 | descOpts{"node", "", "load5", "5m load average.", 0},
32 | prometheus.GaugeValue,
33 | nil, constLabels,
34 | settings.Filters,
35 | ),
36 | load15: newBuiltinTypedDesc(
37 | descOpts{"node", "", "load15", "15m load average.", 0},
38 | prometheus.GaugeValue,
39 | nil, constLabels,
40 | settings.Filters,
41 | ),
42 | }, nil
43 | }
44 |
45 | // Update implements Collector and exposes load average related metrics from /proc/loadavg.
46 | func (c *loadaverageCollector) Update(_ Config, ch chan<- prometheus.Metric) error {
47 | stats, err := getLoadAverageStats()
48 | if err != nil {
49 | return fmt.Errorf("get load average stats failed: %s", err)
50 | }
51 |
52 | ch <- c.load1.newConstMetric(stats[0])
53 | ch <- c.load5.newConstMetric(stats[1])
54 | ch <- c.load15.newConstMetric(stats[2])
55 |
56 | return nil
57 | }
58 |
59 | // getLoadAverageStats reads /proc/loadavg and return load stats.
60 | func getLoadAverageStats() ([]float64, error) {
61 | data, err := os.ReadFile("/proc/loadavg")
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | return parseLoadAverageStats(string(data))
67 | }
68 |
69 | // parseLoadAverageStats parses content from /proc/loadavg and return load stats.
70 | func parseLoadAverageStats(data string) ([]float64, error) {
71 | log.Debug("parse load average stats")
72 |
73 | parts := strings.Fields(data)
74 | if len(parts) < 3 {
75 | return nil, fmt.Errorf("invalid input, '%s': too few values", data)
76 | }
77 |
78 | var err error
79 | loads := make([]float64, 3)
80 | for i, load := range parts[0:3] {
81 | loads[i], err = strconv.ParseFloat(load, 64)
82 | if err != nil {
83 | return nil, fmt.Errorf("invalid input, parse '%s' failed: %w", load, err)
84 | }
85 | }
86 | return loads, nil
87 | }
88 |
--------------------------------------------------------------------------------
/deploy/demo-lab/stop_and_cleanup_data.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Don't edit this config
4 | SOURCE="${BASH_SOURCE[0]}"
5 | while [ -h "$SOURCE" ]; do
6 | DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
7 | SOURCE="$(readlink "$SOURCE")"
8 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
9 | done
10 | SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
11 | SCRIPT_NAME=$(basename "$0")
12 |
13 | # Check command exist function
14 | _command_exists() {
15 | type "$1" &>/dev/null
16 | }
17 |
18 | # Detect Docker Compose
19 | if _command_exists docker-compose; then
20 | DC_BIN=$(which docker-compose)
21 | else
22 | echo "ERROR: docker-compose binary not found."
23 | exit 1
24 | fi
25 |
26 | PG_VERSIONS=(
27 | "9,1.4.5"
28 | "10,1.4.5"
29 | "11,1.4.5"
30 | "12,1.4.5"
31 | "13,1.4.6"
32 | "14,1.4.7"
33 | "15,1.4.8"
34 | "16,1.5.0"
35 | "17,1.5.0"
36 | )
37 |
38 | echo "Stopping all container via docker-compose, please waiting..."
39 | if [ -d "${SCRIPT_DIR}/victoriametrics/data/data" ]; then
40 | ${DC_BIN} -f docker-compose.yml down --volumes >/dev/null 2>&1
41 | else
42 | ${DC_BIN} -f docker-compose.vm-cluster.yml down --volumes >/dev/null 2>&1
43 | fi
44 | if [ $? -eq 0 ]; then
45 | shopt -s dotglob
46 | echo "Remove grafana data..."
47 | rm -rf ${SCRIPT_DIR}/grafana/data/* >/dev/null 2>&1
48 | rm -rf ${SCRIPT_DIR}/grafana/cluster_data/* >/dev/null 2>&1
49 | echo "Remove patroni data..."
50 | rm -rf ${SCRIPT_DIR}/patroni/etc_data1/* >/dev/null 2>&1
51 | rm -rf ${SCRIPT_DIR}/patroni/etc_data2/* >/dev/null 2>&1
52 | rm -rf ${SCRIPT_DIR}/patroni/etc_data3/* >/dev/null 2>&1
53 | rm -rf ${SCRIPT_DIR}/patroni/pg_data1/* >/dev/null 2>&1
54 | rm -rf ${SCRIPT_DIR}/patroni/pg_data2/* >/dev/null 2>&1
55 | rm -rf ${SCRIPT_DIR}/patroni/pg_data3/* >/dev/null 2>&1
56 | for DATA in ${PG_VERSIONS[@]}; do
57 | PG_VER=$(echo "${DATA}" | awk -F',' '{print $1}')
58 | echo "Remove postgres v${PG_VER} data..."
59 | rm -rf ${SCRIPT_DIR}/postgres/pg${PG_VER}data/* >/dev/null 2>&1
60 | rm -rf ${SCRIPT_DIR}/postgres/pg${PG_VER}replica1data/* >/dev/null 2>&1
61 | rm -rf ${SCRIPT_DIR}/postgres/pg${PG_VER}replica2data/* >/dev/null 2>&1
62 | done
63 | echo "Remove victoriametrics data..."
64 | rm -rf ${SCRIPT_DIR}/victoriametrics/data/* >/dev/null 2>&1
65 | rm -rf ${SCRIPT_DIR}/victoriametrics/vmstorage1data/* >/dev/null 2>&1
66 | rm -rf ${SCRIPT_DIR}/victoriametrics/vmstorage2data/* >/dev/null 2>&1
67 | rm -rf ${SCRIPT_DIR}/vmagent/data/* >/dev/null 2>&1
68 | rm -rf ${SCRIPT_DIR}/vmagent/data1/* >/dev/null 2>&1
69 | rm -rf ${SCRIPT_DIR}/vmagent/data2/* >/dev/null 2>&1
70 | echo "All done."
71 | else
72 | echo "ERROR: Container not stopped. Run 'docker-compose down' and see log."
73 | exit 1
74 | fi
75 |
--------------------------------------------------------------------------------
/internal/collector/linux_cpu_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 | )
9 |
10 | func TestCPUCollector_Update(t *testing.T) {
11 | var input = pipelineInput{
12 | required: []string{
13 | "node_cpu_seconds_total",
14 | "node_cpu_seconds_all_total",
15 | "node_cpu_guest_seconds_total",
16 | "node_uptime_up_seconds_total",
17 | "node_uptime_idle_seconds_total",
18 | },
19 | collector: NewCPUCollector,
20 | }
21 |
22 | pipeline(t, input)
23 | }
24 |
25 | func Test_parseProcCPUStat(t *testing.T) {
26 | testcases := []struct {
27 | in string
28 | valid bool
29 | want cpuStat
30 | }{
31 | {in: "testdata/proc/stat.golden", valid: true, want: cpuStat{
32 | user: 30976.68,
33 | nice: 15.93,
34 | system: 14196.18,
35 | idle: 1322422.58,
36 | iowait: 425.35,
37 | irq: 0,
38 | softirq: 3846.86,
39 | steal: 0,
40 | guest: 0,
41 | guestnice: 0,
42 | }},
43 | {in: "testdata/proc/stat.invalid", valid: false},
44 | }
45 |
46 | for _, tc := range testcases {
47 | file, err := os.Open(filepath.Clean(tc.in))
48 | assert.NoError(t, err)
49 |
50 | got, err := parseProcCPUStat(file, 100)
51 | if tc.valid {
52 | assert.NoError(t, err)
53 | assert.Equal(t, tc.want, got)
54 | } else {
55 | assert.Error(t, err)
56 | }
57 | assert.NoError(t, file.Close())
58 | }
59 | }
60 |
61 | func Test_parseCPUStat(t *testing.T) {
62 | var testcases = []struct {
63 | valid bool
64 | line string
65 | want cpuStat
66 | }{
67 | {
68 | valid: true,
69 | line: "cpu 3097668 1593 1419618 132242258 42535 0 384686 0 0 0",
70 | want: cpuStat{
71 | user: 30976.68, nice: 15.93, system: 14196.18, idle: 1322422.58, iowait: 425.35,
72 | irq: 0, softirq: 3846.86, steal: 0, guest: 0, guestnice: 0,
73 | },
74 | },
75 | {valid: false, line: "invalid 3097668 1593 1419618 132242258 42535 0 384686"},
76 | {valid: false, line: "invalid invalid"},
77 | }
78 |
79 | // assume that sys_ticks is 100
80 | for _, tc := range testcases {
81 | got, err := parseCPUStat(tc.line, 100)
82 | if tc.valid {
83 | assert.NoError(t, err)
84 | assert.Equal(t, tc.want, got)
85 | } else {
86 | assert.Error(t, err)
87 | }
88 | }
89 | }
90 |
91 | func Test_getProcUptime(t *testing.T) {
92 | up, idle, err := getProcUptime("testdata/proc/uptime.golden")
93 | assert.NoError(t, err)
94 | assert.Equal(t, float64(187477.470), up)
95 | assert.Equal(t, float64(1397296.120), idle)
96 |
97 | _, _, err = getProcUptime("testdata/proc/stat.golden")
98 | assert.Error(t, err)
99 | }
100 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/cherts/pgscv
2 |
3 | go 1.25.4
4 |
5 | require (
6 | github.com/alecthomas/kingpin/v2 v2.4.0
7 | github.com/jackc/pgproto3/v2 v2.3.3
8 | github.com/jackc/pgx/v4 v4.18.3
9 | github.com/nxadm/tail v1.4.11
10 | github.com/prometheus/client_golang v1.23.2
11 | github.com/rs/zerolog v1.34.0
12 | github.com/stretchr/testify v1.11.1
13 | golang.org/x/crypto v0.46.0 // indirect
14 | golang.org/x/net v0.48.0
15 | golang.org/x/sys v0.39.0 // indirect
16 | gopkg.in/yaml.v2 v2.4.0
17 | )
18 |
19 | require (
20 | github.com/go-playground/validator/v10 v10.30.0
21 | github.com/yandex-cloud/go-genproto v0.43.0
22 | github.com/yandex-cloud/go-sdk v0.30.0
23 | google.golang.org/protobuf v1.36.11
24 | )
25 |
26 | require (
27 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
28 | github.com/beorn7/perks v1.0.1 // indirect
29 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
30 | github.com/davecgh/go-spew v1.1.1 // indirect
31 | github.com/fsnotify/fsnotify v1.9.0 // indirect
32 | github.com/gabriel-vasile/mimetype v1.4.12 // indirect
33 | github.com/ghodss/yaml v1.0.0 // indirect
34 | github.com/go-playground/locales v0.14.1 // indirect
35 | github.com/go-playground/universal-translator v0.18.1 // indirect
36 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
37 | github.com/hashicorp/errwrap v1.1.0 // indirect
38 | github.com/hashicorp/go-multierror v1.1.1 // indirect
39 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect
40 | github.com/jackc/pgconn v1.14.3 // indirect
41 | github.com/jackc/pgio v1.0.0 // indirect
42 | github.com/jackc/pgpassfile v1.0.0 // indirect
43 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
44 | github.com/jackc/pgtype v1.14.4 // indirect
45 | github.com/leodido/go-urn v1.4.0 // indirect
46 | github.com/mattn/go-colorable v0.1.14 // indirect
47 | github.com/mattn/go-isatty v0.0.20 // indirect
48 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
49 | github.com/pmezard/go-difflib v1.0.0 // indirect
50 | github.com/prometheus/client_model v0.6.2 // indirect
51 | github.com/prometheus/common v0.67.4 // indirect
52 | github.com/prometheus/procfs v0.19.2 // indirect
53 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
54 | go.yaml.in/yaml/v2 v2.4.3 // indirect
55 | golang.org/x/text v0.32.0 // indirect
56 | google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect
57 | google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
58 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
59 | google.golang.org/grpc v1.78.0 // indirect
60 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
61 | gopkg.in/yaml.v3 v3.0.1 // indirect
62 | )
63 |
--------------------------------------------------------------------------------
/internal/collector/pgbouncer_stats_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | // Important: this test might produce some warns because collector doesn't collect averages stored in stats.
12 | func TestPgbouncerStatsCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "pgbouncer_up",
16 | "pgbouncer_transactions_total",
17 | "pgbouncer_queries_total",
18 | "pgbouncer_bytes_total",
19 | "pgbouncer_spent_seconds_total",
20 | },
21 | collector: NewPgbouncerStatsCollector,
22 | service: model.ServiceTypePgbouncer,
23 | }
24 |
25 | pipeline(t, input)
26 | }
27 |
28 | func Test_parsePgbouncerStatsStats(t *testing.T) {
29 | var testCases = []struct {
30 | name string
31 | res *model.PGResult
32 | want map[string]pgbouncerStatsStat
33 | }{
34 | {
35 | name: "normal output",
36 | res: &model.PGResult{
37 | Nrows: 2,
38 | Ncols: 8,
39 | Colnames: []pgproto3.FieldDescription{
40 | {Name: []byte("database")},
41 | {Name: []byte("total_xact_count")}, {Name: []byte("total_query_count")}, {Name: []byte("total_received")}, {Name: []byte("total_sent")},
42 | {Name: []byte("total_xact_time")}, {Name: []byte("total_query_time")}, {Name: []byte("total_wait_time")},
43 | },
44 | Rows: [][]sql.NullString{
45 | {
46 | {String: "testdb1", Valid: true},
47 | {String: "452789541", Valid: true}, {String: "45871254", Valid: true}, {String: "845758921", Valid: true}, {String: "584752366", Valid: true},
48 | {String: "854236758964", Valid: true}, {String: "489685327856", Valid: true}, {String: "865421752", Valid: true},
49 | },
50 | {
51 | {String: "testdb2", Valid: true},
52 | {String: "781245657", Valid: true}, {String: "45875233", Valid: true}, {String: "785452498", Valid: true}, {String: "587512688", Valid: true},
53 | {String: "786249684545", Valid: true}, {String: "871401521458", Valid: true}, {String: "4547111201", Valid: true},
54 | },
55 | },
56 | },
57 | want: map[string]pgbouncerStatsStat{
58 | "testdb1": {
59 | database: "testdb1", xacts: 452789541, queries: 45871254, received: 845758921, sent: 584752366, xacttime: 854236758964, querytime: 489685327856, waittime: 865421752,
60 | },
61 | "testdb2": {
62 | database: "testdb2", xacts: 781245657, queries: 45875233, received: 785452498, sent: 587512688, xacttime: 786249684545, querytime: 871401521458, waittime: 4547111201,
63 | },
64 | },
65 | },
66 | }
67 |
68 | for _, tc := range testCases {
69 | t.Run(tc.name, func(t *testing.T) {
70 | got := parsePgbouncerStatsStats(tc.res, []string{"database"})
71 | assert.EqualValues(t, tc.want, got)
72 | })
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/internal/collector/postgres_functions_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | /* IMPORTANT: this test will fail if there are no functions stats in the databases or track_functions is disabled */
12 |
13 | func TestPostgresFunctionsCollector_Update(t *testing.T) {
14 | var input = pipelineInput{
15 | required: []string{
16 | "postgres_function_calls_total",
17 | "postgres_function_total_time_seconds_total",
18 | "postgres_function_self_time_seconds_total",
19 | },
20 | collector: NewPostgresFunctionsCollector,
21 | service: model.ServiceTypePostgresql,
22 | }
23 |
24 | pipeline(t, input)
25 | }
26 |
27 | func Test_parsePostgresFunctionsStat(t *testing.T) {
28 | var testCases = []struct {
29 | name string
30 | res *model.PGResult
31 | want map[string]postgresFunctionStat
32 | }{
33 | {
34 | name: "normal output",
35 | res: &model.PGResult{
36 | Nrows: 3,
37 | Ncols: 6,
38 | Colnames: []pgproto3.FieldDescription{
39 | {Name: []byte("database")}, {Name: []byte("schema")}, {Name: []byte("function")},
40 | {Name: []byte("calls")}, {Name: []byte("total_time")}, {Name: []byte("self_time")},
41 | },
42 | Rows: [][]sql.NullString{
43 | {
44 | {String: "testdb", Valid: true}, {String: "testschema1", Valid: true}, {String: "testfunction1", Valid: true},
45 | {String: "10", Valid: true}, {String: "1000", Valid: true}, {String: "900", Valid: true},
46 | },
47 | {
48 | {String: "testdb", Valid: true}, {String: "testschema2", Valid: true}, {String: "testfunction2", Valid: true},
49 | {String: "20", Valid: true}, {String: "2000", Valid: true}, {String: "700", Valid: true},
50 | },
51 | {
52 | {String: "testdb", Valid: true}, {String: "testschema3", Valid: true}, {String: "testfunction3", Valid: true},
53 | {String: "30", Valid: true}, {String: "3000", Valid: true}, {String: "600", Valid: true},
54 | },
55 | },
56 | },
57 | want: map[string]postgresFunctionStat{
58 | "testdb/testschema1/testfunction1": {
59 | database: "testdb", schema: "testschema1", function: "testfunction1", calls: 10, totaltime: 1000, selftime: 900,
60 | },
61 | "testdb/testschema2/testfunction2": {
62 | database: "testdb", schema: "testschema2", function: "testfunction2", calls: 20, totaltime: 2000, selftime: 700,
63 | },
64 | "testdb/testschema3/testfunction3": {
65 | database: "testdb", schema: "testschema3", function: "testfunction3", calls: 30, totaltime: 3000, selftime: 600,
66 | },
67 | },
68 | },
69 | }
70 |
71 | for _, tc := range testCases {
72 | t.Run(tc.name, func(t *testing.T) {
73 | got := parsePostgresFunctionsStats(tc.res, []string{"database", "schema", "function"})
74 | assert.EqualValues(t, tc.want, got)
75 | })
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/internal/collector/linux_sysconfig_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 | )
9 |
10 | func TestSystemCollector_Update(t *testing.T) {
11 | var input = pipelineInput{
12 | required: []string{
13 | "node_system_sysctl",
14 | "node_system_cpu_cores_total",
15 | "node_system_numa_nodes_total",
16 | "node_context_switches_total",
17 | "node_forks_total",
18 | "node_boot_time_seconds",
19 | },
20 | optional: []string{
21 | "node_system_scaling_governors_total",
22 | },
23 | collector: NewSysconfigCollector,
24 | }
25 |
26 | pipeline(t, input)
27 | }
28 |
29 | func Test_readSysctls(t *testing.T) {
30 | var list = []string{"vm.dirty_ratio", "vm.dirty_background_ratio", "vm.dirty_expire_centisecs", "vm.dirty_writeback_centisecs"}
31 |
32 | sysctls := readSysctls(list)
33 | assert.NotNil(t, sysctls)
34 | assert.Len(t, sysctls, 4)
35 |
36 | for _, s := range list {
37 | if _, ok := sysctls[s]; !ok {
38 | assert.Fail(t, "sysctl not found in the list")
39 | continue
40 | }
41 | assert.Greater(t, sysctls[s], float64(0))
42 | }
43 |
44 | // unknown sysctl
45 | res := readSysctls([]string{"invalid"})
46 | assert.Len(t, res, 0)
47 |
48 | // non-float64 sysctl
49 | res = readSysctls([]string{"kernel.version"})
50 | assert.Len(t, res, 0)
51 | }
52 |
53 | func Test_countCPUCores(t *testing.T) {
54 | online, offline, err := countCPUCores("testdata/sys/devices.system/cpu/cpu*")
55 | assert.NoError(t, err)
56 | assert.Equal(t, float64(2), online)
57 | assert.Equal(t, float64(1), offline)
58 | }
59 |
60 | func Test_countScalingGovernors(t *testing.T) {
61 | want := map[string]float64{
62 | "powersave": 2,
63 | "performance": 2,
64 | }
65 |
66 | governors, err := countScalingGovernors("testdata/sys/devices.system/cpu/cpu*")
67 | assert.NoError(t, err)
68 | assert.Equal(t, want, governors)
69 | }
70 |
71 | func Test_countNumaNodes(t *testing.T) {
72 | n, err := countNumaNodes("testdata/sys/devices.system/node/node*")
73 | assert.NoError(t, err)
74 | assert.Equal(t, float64(2), n)
75 | }
76 |
77 | func Test_parseProcStat(t *testing.T) {
78 | testcases := []struct {
79 | in string
80 | valid bool
81 | want systemProcStat
82 | }{
83 | {in: "testdata/proc/stat.golden", valid: true, want: systemProcStat{
84 | ctxt: 3253088019,
85 | btime: 1596255715,
86 | forks: 214670,
87 | }},
88 | {in: "testdata/proc/stat.invalid", valid: false},
89 | }
90 |
91 | for _, tc := range testcases {
92 | file, err := os.Open(filepath.Clean(tc.in))
93 | assert.NoError(t, err)
94 |
95 | got, err := parseProcStat(file)
96 | if tc.valid {
97 | assert.NoError(t, err)
98 | assert.Equal(t, tc.want, got)
99 | } else {
100 | assert.Error(t, err)
101 | }
102 | assert.NoError(t, file.Close())
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/deploy/demo-lab/pgbench/start_pgbench_test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PG_VER=$1
4 | PG_HOST=$2
5 | PG_PORT=$3
6 | ONLY_SELECT=${4:-"0"}
7 |
8 | if [ -z "${PG_VER}" ]; then
9 | PG_VER=16
10 | fi
11 |
12 | if [ -z "${PG_HOST}" ]; then
13 | PG_HOST="postgres${PG_VER}"
14 | fi
15 |
16 | if [ -z "${PG_PORT}" ]; then
17 | PG_PORT=5432
18 | fi
19 |
20 | STOP_FLAG="/pg_repack/stop_pgbench_${PG_HOST}_${PG_PORT}"
21 | DATE_START=$(date +"%s")
22 |
23 | # Logging function
24 | _logging() {
25 | local MSG=${1}
26 | local ENDLINE=${2:-"1"}
27 | if [[ "${ENDLINE}" -eq 0 ]]; then
28 | printf "%s: %s" "$(date "+%d.%m.%Y %H:%M:%S")" "${MSG}" 2>/dev/null
29 | else
30 | printf "%s: %s\n" "$(date "+%d.%m.%Y %H:%M:%S")" "${MSG}" 2>/dev/null
31 | fi
32 | }
33 |
34 | # Calculate duration function
35 | _duration() {
36 | local DATE_START=${1:-"$(date +'%s')"}
37 | local FUNC_NAME=${2:-""}
38 | local DATE_END=$(date +"%s")
39 | local D_MSG=""
40 | local DATE_DIFF=$((${DATE_END} - ${DATE_START}))
41 | if [ -n "${FUNC_NAME}" ]; then
42 | local D_MSG=" of execute function '${FUNC_NAME}'"
43 | fi
44 | _logging "Duration${D_MSG}: $((${DATE_DIFF} / 3600)) hours $(((${DATE_DIFF} % 3600) / 60)) minutes $((${DATE_DIFF} % 60)) seconds"
45 | }
46 |
47 | _logging "Starting script."
48 |
49 | source /pg_repack/.env
50 |
51 | _logging "Use pgbench for PostgreSQL v${PG_VER}, host=${PG_HOST}, port=${PG_PORT}"
52 | _logging "STOP_FLAG: ${STOP_FLAG}"
53 | _logging "ONLY_SELECT: ${ONLY_SELECT}"
54 | rm -f "${STOP_FLAG}" >/dev/null 2>&1
55 |
56 | if [[ ${ONLY_SELECT} -eq 0 ]]; then
57 | _logging "Prepare pgbench database..."
58 | pgbench -h ${PG_HOST} -p ${PG_PORT} -U pgbench pgbench -i -s 10
59 | fi
60 |
61 | ITERATION=1
62 | while true; do
63 | if [[ ${ONLY_SELECT} -eq 0 ]]; then
64 | CL_NUM=$((1 + $RANDOM % 7))
65 | TEST_DUR=30
66 | _logging "Run pgbench tests, iteration '${ITERATION}', duration of benchmark test = '${TEST_DUR}s', client number = '${CL_NUM}'..."
67 | pgbench -h ${PG_HOST} -p ${PG_PORT} -U pgbench pgbench -T ${TEST_DUR} -j 4 -P 10 -c ${CL_NUM}
68 | if [ -f "${STOP_FLAG}" ]; then
69 | _logging "Found stop-file '${STOP_FLAG}', end pgbench process."
70 | rm -f "${STOP_FLAG}" >/dev/null 2>&1
71 | break
72 | fi
73 | fi
74 | _logging "Run pgbench tests (select only), iteration '${ITERATION}', duration of benchmark test = '${TEST_DUR}s', client number = '${CL_NUM}'..."
75 | CL_NUM=$((1 + $RANDOM % 7))
76 | TEST_DUR=30
77 | pgbench -h ${PG_HOST} -p ${PG_PORT} -U pgbench pgbench -T ${TEST_DUR} -j 4 -P 10 -c ${CL_NUM} -S -n
78 | if [ -f "${STOP_FLAG}" ]; then
79 | _logging "Found stop-file '${STOP_FLAG}', end pgbench process."
80 | rm -f "${STOP_FLAG}" >/dev/null 2>&1
81 | break
82 | fi
83 | ((ITERATION++))
84 | done
85 |
86 | if [[ ${ONLY_SELECT} -eq 0 ]]; then
87 | _logging "Remove pgbench database..."
88 | pgbench -h ${PG_HOST} -p ${PG_PORT} -U pgbench pgbench -i -I d
89 | fi
90 |
91 | _logging "All done."
92 | _duration "${DATE_START}"
93 |
94 | _logging "End script. Goodbye ;)"
95 |
--------------------------------------------------------------------------------
/internal/collector/linux_network.go:
--------------------------------------------------------------------------------
1 | // Package collector is a pgSCV collectors
2 | package collector
3 |
4 | import (
5 | "fmt"
6 | "net"
7 | "strings"
8 |
9 | "github.com/cherts/pgscv/internal/log"
10 | "github.com/cherts/pgscv/internal/model"
11 | "github.com/prometheus/client_golang/prometheus"
12 | )
13 |
14 | type networkCollector struct {
15 | privateAddresses typedDesc
16 | publicAddresses typedDesc
17 | }
18 |
19 | // NewNetworkCollector returns a new Collector exposing network interfaces addresses.
20 | func NewNetworkCollector(constLabels labels, settings model.CollectorSettings) (Collector, error) {
21 | return &networkCollector{
22 | publicAddresses: newBuiltinTypedDesc(
23 | descOpts{"node", "network", "public_addresses", "Number of public network addresses present on the system, by type.", 0},
24 | prometheus.GaugeValue,
25 | nil, constLabels,
26 | settings.Filters,
27 | ),
28 | privateAddresses: newBuiltinTypedDesc(
29 | descOpts{"node", "network", "private_addresses", "Number of private network addresses present on the system, by type.", 0},
30 | prometheus.GaugeValue,
31 | nil, constLabels,
32 | settings.Filters,
33 | ),
34 | }, nil
35 | }
36 |
37 | func (c *networkCollector) Update(_ Config, ch chan<- prometheus.Metric) error {
38 | addresses, err := net.InterfaceAddrs()
39 | if err != nil {
40 | return err
41 | }
42 |
43 | stats := parseInterfaceAddresses(addresses)
44 |
45 | ch <- c.publicAddresses.newConstMetric(float64(stats["public"]))
46 | ch <- c.privateAddresses.newConstMetric(float64(stats["private"]))
47 |
48 | return nil
49 | }
50 |
51 | func parseInterfaceAddresses(addresses []net.Addr) map[string]int {
52 | log.Debug("parse network addresses")
53 | addrByType := map[string]int{
54 | "private": 0,
55 | "public": 0,
56 | }
57 |
58 | for _, addr := range addresses {
59 | private, err := isPrivate(addr.String())
60 | if err != nil {
61 | log.Warnf("invalid input, parse '%s' failed: %s, skip", addr.String(), err)
62 | continue
63 | }
64 |
65 | if private {
66 | addrByType["private"]++
67 | } else {
68 | addrByType["public"]++
69 | }
70 | }
71 |
72 | return addrByType
73 | }
74 |
75 | func isPrivate(address string) (bool, error) {
76 | ip6networks := []string{
77 | "::1/128", // IPv6 loopback
78 | "fe80::/10", // IPv6 link-local
79 | "fc00::/7", // IPv6 unique-local
80 | }
81 |
82 | address = strings.Split(address, "/")[0]
83 | ip := net.ParseIP(address)
84 | if ip == nil {
85 | return false, fmt.Errorf("invalid ip address: %s", address)
86 | }
87 |
88 | if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsPrivate() {
89 | return true, nil
90 | }
91 |
92 | for _, cidr := range ip6networks {
93 | _, conv, err := net.ParseCIDR(cidr)
94 | if err != nil {
95 | return false, err
96 | }
97 | if conv.Contains(ip) {
98 | return true, nil
99 | }
100 | }
101 | return false, nil
102 | }
103 |
--------------------------------------------------------------------------------
/deploy/deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: pgscv
5 | namespace: default
6 | labels:
7 | link-app: pgscv
8 | spec:
9 | replicas: 1
10 | selector:
11 | matchLabels:
12 | link-app: pgscv
13 | template:
14 | metadata:
15 | labels:
16 | link-app: pgscv
17 | spec:
18 | #hostNetwork: true
19 | dnsPolicy: ClusterFirst
20 | containers:
21 | - name: pgscv
22 | image: cherts/pgscv:latest
23 | imagePullPolicy: Always
24 | args:
25 | - --config-file=/app/conf/pgscv.yaml
26 | # env:
27 | # - name: PGSCV_LISTEN_ADDRESS
28 | # value: "0.0.0.0:9890"
29 | # - name: PGSCV_DISABLE_COLLECTORS
30 | # value: "system"
31 | # - name: POSTGRES_DSN
32 | # value: "postgres://postgres:password@127.0.0.1:5432/postgres"
33 | # - name: PGBOUNCER_DSN
34 | # value: "postgres://pgbouncer:password@127.0.0.1:6432/pgbouncer"
35 | ports:
36 | - name: http
37 | containerPort: 9890
38 | protocol: TCP
39 | # Set up resources
40 | resources:
41 | limits:
42 | cpu: "1"
43 | memory: 500Mi
44 | ephemeral-storage: "100Mi"
45 | #securityContext:
46 | # privileged: true
47 | volumeMounts:
48 | - name: pgscv-config
49 | mountPath: /app/conf/
50 | # Disable automatic injection of service information into environment variables
51 | enableServiceLinks: false
52 | restartPolicy: Always
53 | terminationGracePeriodSeconds: 30
54 | nodeSelector:
55 | kubernetes.io/os: linux
56 | securityContext: { }
57 | tolerations:
58 | - operator: Exists
59 | # Set priority
60 | priorityClassName: system-cluster-critical
61 | # Volume
62 | volumes:
63 | - name: pgscv-config
64 | configMap:
65 | name: pgscv-configmap
66 | items:
67 | - key: pgscv.yaml
68 | path: pgscv.yaml
69 |
70 | ---
71 | apiVersion: v1
72 | kind: Service
73 | metadata:
74 | name: pgscv
75 | namespace: default
76 | labels:
77 | link-app: pgscv
78 | spec:
79 | ports:
80 | - name: http
81 | port: 9890
82 | targetPort: 9890
83 | protocol: TCP
84 | selector:
85 | link-app: pgscv
86 | sessionAffinity: ClientIP
87 | type: ClusterIP
88 |
89 | ---
90 | apiVersion: v1
91 | kind: ConfigMap
92 | metadata:
93 | name: pgscv-configmap
94 | namespace: default
95 | data:
96 | pgscv.yaml: |
97 | listen_address: 0.0.0.0:9890
98 | disable_collectors:
99 | - system
100 | services:
101 | "postgres:5432":
102 | service_type: "postgres"
103 | conninfo: "postgres://postgres:password@127.0.0.1:5432/postgres"
104 | "pgbouncer:6432":
105 | service_type: "pgbouncer"
106 | conninfo: "postgres://pgbouncer:password@127.0.0.1:6432/pgbouncer"
107 |
--------------------------------------------------------------------------------
/deploy/demo-lab/README.md:
--------------------------------------------------------------------------------
1 | # pgSCV demo laboratory
2 |
3 | ### Requirements
4 |
5 | - Docker
6 | - Docker Compose
7 |
8 | ### List of running containers and ports
9 |
10 | - pgscv (listen port: 9890)
11 | - grafana (listen port: 3000)
12 | - vmagent (listen port: 8429)
13 | - victoriametrics (listen port: 8428)
14 | - postgres9 (listen port: 5429)
15 | - postgres10 (listen port: 5430)
16 | - postgres11 (listen port: 5431)
17 | - postgres12 (listen port: 5432)
18 | - postgres13 (listen port: 5433)
19 | - postgres14 (listen port: 5434)
20 | - postgres15 (listen port: 5435)
21 | - postgres16 (listen port: 5436)
22 | - postgres17 (listen port: 5437)
23 | - postgres9replica1 (listen port: 4429)
24 | - postgres9replica2 (cascade, listen port: 3429)
25 | - postgres10replica1 (listen port: 4430)
26 | - postgres11replica1 (listen port: 4431)
27 | - postgres12replica1 (listen port: 4432)
28 | - postgres13replica1 (listen port: 4433)
29 | - postgres14replica1 (listen port: 4434)
30 | - postgres15replica1 (listen port: 4435)
31 | - postgres16replica1 (listen port: 4436)
32 | - postgres17replica1 (listen port: 4437)
33 | - postgres17replica2 (cascade, listen port: 3437)
34 | - pgbouncer9 (listen port: 6429)
35 | - pgbouncer10 (listen port: 6430)
36 | - pgbouncer11 (listen port: 6431)
37 | - pgbouncer12 (listen port: 6432)
38 | - pgbouncer13 (listen port: 6433)
39 | - pgbouncer14 (listen port: 6434)
40 | - pgbouncer15 (listen port: 6435)
41 | - pgbouncer16 (listen port: 6436)
42 | - pgbouncer17 (listen port: 6437)
43 | - etcd1
44 | - etcd2
45 | - etcd3
46 | - patroni1 (listen port: 7432, 8008)
47 | - patroni2 (listen port: 7433, 8009)
48 | - patroni3 (listen port: 7434, 8010)
49 | - haproxy (listen port: 5000, 5001)
50 | - pgbench_9
51 | - pgbench_10
52 | - pgbench_11
53 | - pgbench_12
54 | - pgbench_13
55 | - pgbench_14
56 | - pgbench_15
57 | - pgbench_16
58 | - pgbench_17
59 | - pgbench_patroni
60 | - pgbench_patroni_s
61 |
62 | A total of 40 containers are launched!
63 |
64 | ### Quick start
65 |
66 | Prepare demo laboratory::
67 |
68 | ```bash
69 | cat docker-compose.yml docker-compose.vm-cluster.yml compose.*.yml | grep device | awk -F' ' '{print $2}' | sed -e 's/${PWD}\///g' | xargs mkdir -p
70 | cat docker-compose.yml docker-compose.vm-cluster.yml compose.*.yml | grep device | awk -F' ' '{print $2}' | sed -e 's/${PWD}\///g' | xargs chmod 777
71 | ```
72 |
73 | Start demo laboratory:
74 |
75 | ```bash
76 | docker-compose -f docker-compose.yml up --detach
77 | ```
78 |
79 | Start pgbench tests:
80 |
81 | ```bash
82 | ./start_pgbench.sh
83 | ```
84 |
85 | Open Grafana into Web browser URL:
86 |
87 | Login: admin
88 |
89 | Password: admin
90 |
91 | Open pgSCV dashboards, enjoy and drink coffee ;)
92 |
93 | View pgSCV logs:
94 |
95 | ```bash
96 | docker logs pgscv -f
97 | ```
98 |
99 | ### Stop demo laboratory and cleanup data
100 |
101 | Stop pgbench tests:
102 |
103 | ```bash
104 | ./stop_pgbench.sh
105 | ```
106 |
107 | Stop pgSCV demo laboratory and cleanup demo data:
108 |
109 | ```bash
110 | docker-compose -f docker-compose.yml down --volumes
111 | ./stop_and_cleanup_data.sh
112 | ```
113 |
--------------------------------------------------------------------------------
/.github/workflows/beta.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Beta
3 |
4 | on:
5 | workflow_dispatch:
6 | push:
7 | branches:
8 | - beta/*
9 | - dev/*
10 | pull_request:
11 | types: [assigned, unassigned]
12 | paths:
13 | - "cmd/**"
14 | - "internal/**"
15 | - "discovery/**"
16 | - "Makefile"
17 | - "go.mod"
18 | - "go.sum"
19 |
20 | jobs:
21 | test:
22 | runs-on: ubuntu-22.04
23 | container: cherts/pgscv-test-runner:1.0.12
24 | steps:
25 | - name: Checkout code
26 | uses: actions/checkout@v6
27 | - name: Prepare test environment
28 | run: prepare-test-environment.sh
29 | - name: Run test
30 | run: make test
31 |
32 | build:
33 | runs-on: ubuntu-22.04
34 | needs: test
35 | permissions:
36 | contents: read
37 | packages: write
38 | id-token: write
39 | attestations: write
40 | env:
41 | REGISTRY: ghcr.io
42 | IMAGE_NAME: ${{ github.repository }}
43 | steps:
44 | - name: Checkout code
45 | uses: actions/checkout@v6
46 | with:
47 | fetch-depth: 0
48 | - name: Set environment
49 | id: version
50 | run: |
51 | echo VERSION="1.0" >>$GITHUB_OUTPUT
52 | echo GIT_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >>$GITHUB_OUTPUT
53 | echo GIT_COMMIT="$(git rev-parse --short "${GITHUB_SHA}")" >>$GITHUB_OUTPUT
54 | echo GIT_DATE="$(git log -1 --format=%cd --date=format:"%Y%m%d")" >>$GITHUB_OUTPUT
55 | - name: Login to registry
56 | uses: docker/login-action@v3
57 | with:
58 | registry: ${{ env.REGISTRY }}
59 | username: ${{ github.actor }}
60 | password: ${{ secrets.GITHUB_TOKEN }}
61 | - name: Set up Docker meta
62 | id: meta
63 | uses: docker/metadata-action@v5
64 | with:
65 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
66 | tags: |
67 | type=raw,value=v${{ steps.version.outputs.VERSION }}-${{ steps.version.outputs.GIT_BRANCH }}-${{ steps.version.outputs.GIT_COMMIT }}-${{ steps.version.outputs.GIT_DATE }}-beta
68 | - name: Set up QEMU
69 | uses: docker/setup-qemu-action@v3
70 | - name: Set up Docker Buildx
71 | uses: docker/setup-buildx-action@v3
72 | - name: Build and push
73 | id: push
74 | uses: docker/build-push-action@v6
75 | with:
76 | context: .
77 | file: ./Dockerfile.beta
78 | platforms: linux/amd64
79 | push: true
80 | tags: ${{ steps.meta.outputs.tags }}
81 | - name: Generate artifact attestation
82 | uses: actions/attest-build-provenance@v3
83 | id: attest
84 | with:
85 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
86 | subject-digest: ${{ steps.push.outputs.digest }}
87 | push-to-registry: true
88 | - name: Cleanup registry
89 | uses: actions/delete-package-versions@v5
90 | with:
91 | owner: ${{ github.repository_owner }}
92 | package-name: 'pgscv'
93 | package-type: container
94 | min-versions-to-keep: 50
95 | # delete-only-untagged-versions: 'true'
96 |
--------------------------------------------------------------------------------
/internal/filter/filter.go:
--------------------------------------------------------------------------------
1 | // Package filter is a pgSCV filter
2 | package filter
3 |
4 | import (
5 | "regexp"
6 |
7 | "github.com/cherts/pgscv/internal/log"
8 | )
9 |
10 | // Filter describes settings for filtering stats values for metrics.
11 | type Filter struct {
12 | // Exclude pattern string.
13 | Exclude string `yaml:"exclude,omitempty"`
14 | // Compiled exclude pattern regexp.
15 | ExcludeRE *regexp.Regexp
16 | // Include pattern string.
17 | Include string `yaml:"include,omitempty"`
18 | // Compiled include pattern regexp.
19 | IncludeRE *regexp.Regexp
20 | }
21 |
22 | // Pass checks that target is satisfied to filter's regexps.
23 | func (f *Filter) Pass(target string) bool {
24 | // Filters not specified - pass the target.
25 | if f.ExcludeRE == nil && f.IncludeRE == nil {
26 | return true
27 | }
28 |
29 | if f.ExcludeRE != nil && f.IncludeRE != nil {
30 | // Target matches to 'exclude' and 'include' - reject, exclude has higher priority.
31 | if f.ExcludeRE.MatchString(target) && f.IncludeRE.MatchString(target) {
32 | return false
33 | }
34 | // Target neither match 'exclude' nor 'include' - reject, target doesn't match to include explicitly.
35 | if !f.ExcludeRE.MatchString(target) && !f.IncludeRE.MatchString(target) {
36 | return false
37 | }
38 | // Target matches to 'exclude' and doesn't match to 'include' - reject.
39 | if f.ExcludeRE.MatchString(target) && !f.IncludeRE.MatchString(target) {
40 | return false
41 | }
42 | // Target doesn't match to 'exclude' and matches to 'include' - pass.
43 | if !f.ExcludeRE.MatchString(target) && f.IncludeRE.MatchString(target) {
44 | return true
45 | }
46 | }
47 |
48 | // Exclude is specified and target matches 'exclude' - reject.
49 | if f.ExcludeRE != nil && f.ExcludeRE.MatchString(target) {
50 | return false
51 | }
52 | // Include is specified and target doesn't match 'include' - reject.
53 | if f.IncludeRE != nil && !f.IncludeRE.MatchString(target) {
54 | return false
55 | }
56 | // Here means Include is specified and target matches 'include' - pass.
57 | return true
58 | }
59 |
60 | // Filters is the set of named filters
61 | type Filters map[string]Filter
62 |
63 | // New create new and empty Filters object.
64 | func New() Filters {
65 | return map[string]Filter{}
66 | }
67 |
68 | // Add add new filter to existing set of filters. After adding new filter, filters should be recompiled.
69 | func (f Filters) Add(name string, filter Filter) {
70 | f[name] = filter
71 | }
72 |
73 | // Compile walk trough filters and compile them.
74 | func (f Filters) Compile() error {
75 | log.Debug("compile filters")
76 |
77 | for key, filter := range f {
78 | if filter.Exclude != "" {
79 | re, err := regexp.Compile(filter.Exclude)
80 | if err != nil {
81 | return err
82 | }
83 | filter.ExcludeRE = re
84 | }
85 |
86 | if filter.Include != "" {
87 | re, err := regexp.Compile(filter.Include)
88 | if err != nil {
89 | return err
90 | }
91 | filter.IncludeRE = re
92 | }
93 |
94 | // Save updated filter back to map.
95 | f[key] = filter
96 | }
97 |
98 | log.Debug("filters compiled successfully")
99 | return nil
100 | }
101 |
--------------------------------------------------------------------------------
/internal/collector/postgres_bgwriter_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "testing"
6 |
7 | "github.com/cherts/pgscv/internal/model"
8 | "github.com/jackc/pgproto3/v2"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestPostgresBgwriterCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "postgres_checkpoints_total",
16 | "postgres_checkpoints_all_total",
17 | "postgres_checkpoints_seconds_total",
18 | "postgres_checkpoints_seconds_all_total",
19 | "postgres_written_bytes_total",
20 | "postgres_bgwriter_maxwritten_clean_total",
21 | "postgres_backends_fsync_total",
22 | "postgres_backends_allocated_bytes_total",
23 | "postgres_bgwriter_stats_age_seconds_total",
24 | "postgres_checkpoints_stats_age_seconds_total",
25 | "postgres_checkpoints_restartpoints_req",
26 | "postgres_checkpoints_restartpoints_done",
27 | "postgres_checkpoints_restartpoints_timed",
28 | },
29 | collector: NewPostgresBgwriterCollector,
30 | service: model.ServiceTypePostgresql,
31 | }
32 |
33 | pipeline(t, input)
34 | }
35 |
36 | func Test_parsePostgresBgwriterStats(t *testing.T) {
37 | var testCases = []struct {
38 | name string
39 | res *model.PGResult
40 | want postgresBgwriterStat
41 | }{
42 | {
43 | name: "normal output",
44 | res: &model.PGResult{
45 | Nrows: 1,
46 | Ncols: 15,
47 | Colnames: []pgproto3.FieldDescription{
48 | {Name: []byte("checkpoints_timed")}, {Name: []byte("checkpoints_req")},
49 | {Name: []byte("checkpoint_write_time")}, {Name: []byte("checkpoint_sync_time")},
50 | {Name: []byte("buffers_checkpoint")}, {Name: []byte("buffers_clean")}, {Name: []byte("maxwritten_clean")},
51 | {Name: []byte("buffers_backend")}, {Name: []byte("buffers_backend_fsync")}, {Name: []byte("buffers_alloc")},
52 | {Name: []byte("bgwr_stats_age_seconds")}, {Name: []byte("ckpt_stats_age_seconds")}, {Name: []byte("restartpoints_timed")},
53 | {Name: []byte("restartpoints_req")}, {Name: []byte("restartpoints_done")},
54 | },
55 | Rows: [][]sql.NullString{
56 | {
57 | {String: "55", Valid: true}, {String: "17", Valid: true},
58 | {String: "548425", Valid: true}, {String: "5425", Valid: true},
59 | {String: "5482", Valid: true}, {String: "7584", Valid: true}, {String: "452", Valid: true},
60 | {String: "6895", Valid: true}, {String: "2", Valid: true}, {String: "48752", Valid: true},
61 | {String: "5488", Valid: true}, {String: "54388", Valid: true}, {String: "47352", Valid: true},
62 | {String: "5288", Valid: true}, {String: "1438", Valid: true},
63 | },
64 | },
65 | },
66 | want: postgresBgwriterStat{
67 | ckptTimed: 55, ckptReq: 17, ckptWriteTime: 548425, ckptSyncTime: 5425, ckptBuffers: 5482, bgwrBuffers: 7584, bgwrMaxWritten: 452,
68 | backendBuffers: 6895, backendFsync: 2, backendAllocated: 48752, bgwrStatsAgeSeconds: 5488, ckptStatsAgeSeconds: 54388, ckptRestartpointsTimed: 47352,
69 | ckptRestartpointsReq: 5288, ckptRestartpointsDone: 1438,
70 | },
71 | },
72 | }
73 |
74 | for _, tc := range testCases {
75 | t.Run(tc.name, func(t *testing.T) {
76 | got := parsePostgresBgwriterStats(tc.res)
77 | assert.EqualValues(t, tc.want, got)
78 | })
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/deploy/demo-lab/compose.pgbouncer.yml:
--------------------------------------------------------------------------------
1 | services:
2 | pgbouncer9:
3 | container_name: pgbouncer9
4 | image: edoburu/pgbouncer:latest
5 | hostname: pgbouncer9
6 | volumes:
7 | - ${PWD}/pgbouncer/conf9:/etc/pgbouncer
8 | networks: [ monitoring ]
9 | ports:
10 | - "6429:5432"
11 | depends_on:
12 | postgres9:
13 | condition: service_healthy
14 | pgbouncer10:
15 | container_name: pgbouncer10
16 | image: edoburu/pgbouncer:latest
17 | hostname: pgbouncer10
18 | volumes:
19 | - ${PWD}/pgbouncer/conf10:/etc/pgbouncer
20 | networks: [ monitoring ]
21 | ports:
22 | - "6430:5432"
23 | depends_on:
24 | postgres10:
25 | condition: service_healthy
26 | pgbouncer11:
27 | container_name: pgbouncer11
28 | image: edoburu/pgbouncer:latest
29 | hostname: pgbouncer11
30 | volumes:
31 | - ${PWD}/pgbouncer/conf11:/etc/pgbouncer
32 | networks: [ monitoring ]
33 | ports:
34 | - "6431:5432"
35 | depends_on:
36 | postgres11:
37 | condition: service_healthy
38 | pgbouncer12:
39 | container_name: pgbouncer12
40 | image: edoburu/pgbouncer:latest
41 | hostname: pgbouncer12
42 | volumes:
43 | - ${PWD}/pgbouncer/conf12:/etc/pgbouncer
44 | networks: [ monitoring ]
45 | ports:
46 | - "6432:5432"
47 | depends_on:
48 | postgres12:
49 | condition: service_healthy
50 | pgbouncer13:
51 | container_name: pgbouncer13
52 | image: edoburu/pgbouncer:latest
53 | hostname: pgbouncer13
54 | volumes:
55 | - ${PWD}/pgbouncer/conf13:/etc/pgbouncer
56 | networks: [ monitoring ]
57 | ports:
58 | - "6433:5432"
59 | depends_on:
60 | postgres13:
61 | condition: service_healthy
62 | pgbouncer14:
63 | container_name: pgbouncer14
64 | image: edoburu/pgbouncer:latest
65 | hostname: pgbouncer14
66 | volumes:
67 | - ${PWD}/pgbouncer/conf14:/etc/pgbouncer
68 | networks: [ monitoring ]
69 | ports:
70 | - "6434:5432"
71 | depends_on:
72 | - postgres14
73 | healthcheck:
74 | test: ['CMD', 'pg_isready', '-h', 'localhost']
75 | pgbouncer15:
76 | container_name: pgbouncer15
77 | image: edoburu/pgbouncer:latest
78 | hostname: pgbouncer15
79 | volumes:
80 | - ${PWD}/pgbouncer/conf15:/etc/pgbouncer
81 | networks: [ monitoring ]
82 | ports:
83 | - "6435:5432"
84 | depends_on:
85 | postgres15:
86 | condition: service_healthy
87 | pgbouncer16:
88 | container_name: pgbouncer16
89 | image: edoburu/pgbouncer:latest
90 | hostname: pgbouncer16
91 | volumes:
92 | - ${PWD}/pgbouncer/conf16:/etc/pgbouncer
93 | networks: [ monitoring ]
94 | ports:
95 | - "6436:5432"
96 | depends_on:
97 | postgres16:
98 | condition: service_healthy
99 | pgbouncer17:
100 | container_name: pgbouncer17
101 | image: edoburu/pgbouncer:latest
102 | hostname: pgbouncer17
103 | volumes:
104 | - ${PWD}/pgbouncer/conf17:/etc/pgbouncer
105 | networks: [ monitoring ]
106 | ports:
107 | - "6437:5432"
108 | depends_on:
109 | postgres17:
110 | condition: service_healthy
111 |
112 | networks:
113 | monitoring:
114 |
--------------------------------------------------------------------------------
/internal/discovery/service/yandex_engine.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "maps"
7 | "strings"
8 | "sync"
9 | "time"
10 |
11 | "github.com/cherts/pgscv/discovery/log"
12 | "github.com/cherts/pgscv/internal/discovery/cloud/yandex"
13 | )
14 |
15 | type clusterDSN struct {
16 | dsn, name string
17 | labels []Label
18 | }
19 |
20 | type hostDb string
21 |
22 | type yandexEngine struct {
23 | sync.RWMutex
24 | sdk *yandex.SDK
25 | config YandexConfig
26 | dsn map[hostDb]clusterDSN
27 | version version
28 | }
29 |
30 | func (ye *yandexEngine) Start(ctx context.Context) error {
31 | go func() {
32 | ye.RLock()
33 | interval := time.Duration(ye.config.RefreshInterval) * time.Minute
34 | folderID := ye.config.FolderID
35 | filter := make([]yandex.Filter, 0, len(ye.config.Clusters))
36 | password := ye.config.Password
37 | username := ye.config.User
38 | for _, c := range ye.config.Clusters {
39 | filter = append(filter, *yandex.NewFilter(c.Name, c.Db, c.ExcludeName, c.ExcludeDb))
40 | }
41 | ye.RUnlock()
42 | ctx, cancel := context.WithCancel(ctx)
43 | for {
44 |
45 | clusters, err := ye.sdk.GetPostgreSQLClusters(ctx, folderID, filter)
46 | if err != nil {
47 | log.Errorf("[Yandex.Cloud SD] Failed to get cluster list, error: %v", err)
48 | select {
49 | case <-ctx.Done():
50 | log.Debug("[Yandex.Cloud SD] Context canceled, shutting down Yandex Discovery Engine.")
51 | cancel()
52 | return
53 | default:
54 | time.Sleep(interval)
55 | continue
56 | }
57 | }
58 |
59 | clustersMap := make(map[hostDb]clusterDSN, len(clusters))
60 | for _, cluster := range clusters {
61 | for _, host := range cluster.Hosts {
62 | for _, database := range cluster.Databases {
63 | hostDb := hostDb(makeValidMetricName(fmt.Sprintf("%s_%s", host.Name, database.Name)))
64 | dsn := clusterDSN{
65 | dsn: fmt.Sprintf("postgresql://%s:%s@%s:6432/%s", username, password, host.Name, database.Name),
66 | name: cluster.Name,
67 | }
68 | clustersMap[hostDb] = dsn
69 | if ye.config.TargetLabels != nil {
70 | dsn.labels = *ye.config.TargetLabels
71 | }
72 | clustersMap[hostDb] = dsn
73 | }
74 | }
75 | }
76 |
77 | ye.Lock()
78 | for hostDb := range maps.Keys(clustersMap) {
79 | if _, found := ye.dsn[hostDb]; !found {
80 | ye.dsn[hostDb] = clustersMap[hostDb]
81 | ye.version++
82 | }
83 | }
84 | for hostDb := range maps.Keys(ye.dsn) {
85 | if _, found := clustersMap[hostDb]; !found {
86 | delete(ye.dsn, hostDb)
87 | ye.version++
88 | }
89 | }
90 | ye.Unlock()
91 | select {
92 | case <-ctx.Done():
93 | log.Debug("[Yandex.Cloud SD] Context canceled, shutting down Yandex Discovery Engine.")
94 | cancel()
95 | return
96 | default:
97 | time.Sleep(interval)
98 | }
99 | }
100 | }()
101 | return nil
102 | }
103 |
104 | func makeValidMetricName(s string) string {
105 | var ret = ""
106 | for i, b := range strings.Replace(s, ".mdb.yandexcloud.net", "", 1) {
107 | if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0) {
108 | ret += string(b)
109 | } else {
110 | ret += "_"
111 | }
112 | }
113 | return ret
114 | }
115 |
--------------------------------------------------------------------------------
/internal/collector/testing.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "github.com/cherts/pgscv/internal/log"
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/prometheus/client_golang/prometheus"
7 | "github.com/stretchr/testify/assert"
8 | "regexp"
9 | "testing"
10 | )
11 |
12 | // pipelineInput
13 | type pipelineInput struct {
14 | // Metrics names that must be generated during collector runtime. If any metric is not generated, pipeline fails.
15 | required []string
16 | // Metrics names that optionally should be generated during collector runtime. If some metric is not generated, pipeline
17 | // prints warning.
18 | optional []string
19 | // collector defines a function used for creating metric collector.
20 | collector func(labels, model.CollectorSettings) (Collector, error)
21 | // collectorSettings defines collector settings used during testing.
22 | collectorSettings model.CollectorSettings
23 | // Service type related to collector.
24 | service string
25 | }
26 |
27 | // Pipeline accepts input data (see pipelineInput), creates 'collector' and executes Update method for generating metrics.
28 | // Generated metrics are catched and checked against passed slices of required/optional metrics.
29 | // Pipeline fails in following cases 1) required metrics are not generated; 2) generated metrics are not present in required
30 | // or optional slices
31 | func pipeline(t *testing.T, input pipelineInput) {
32 | // requiredMetricNamesCounter is the counter of how many times metrics have been collected
33 | metricNamesCounter := map[string]int{}
34 |
35 | collector, err := input.collector(labels{"example_label": "example_value"}, input.collectorSettings)
36 | assert.NoError(t, err)
37 | ch := make(chan prometheus.Metric)
38 |
39 | var config Config
40 | switch input.service {
41 | case model.ServiceTypePostgresql:
42 | config.DatabasesRE, err = regexp.Compile(".+")
43 | assert.NoError(t, err)
44 | config.ConnString = "postgres://pgscv@127.0.0.1/postgres"
45 | cfg, err := newPostgresServiceConfig(config.ConnString, 0)
46 | assert.NoError(t, err)
47 | config.postgresServiceConfig = cfg
48 | case model.ServiceTypePgbouncer:
49 | config.ConnString = "postgres://pgscv:pgscv@127.0.0.1:6432/pgbouncer"
50 | }
51 |
52 | go func() {
53 | err := collector.Update(config, ch)
54 | assert.NoError(t, err)
55 | close(ch)
56 | }()
57 |
58 | // receive metrics from channel, extract name from the metric and check name of received metric exists in the test slice
59 | for metric := range ch {
60 | // skip nil values
61 | if metric == nil {
62 | continue
63 | }
64 |
65 | //log.Infoln("debug purpose: ", metric.Desc().String())
66 | re := regexp.MustCompile(`fqName: "([a-zA-Z0-9_]+)"`)
67 | match := re.FindStringSubmatch(metric.Desc().String())[1]
68 | assert.Contains(t, append(input.required, input.optional...), match)
69 | metricNamesCounter[match]++
70 | }
71 |
72 | for _, s := range input.required {
73 | if v, ok := metricNamesCounter[s]; !ok {
74 | assert.Fail(t, "necessary metric not found in the map: ", s)
75 | } else {
76 | assert.Greater(t, v, 0)
77 | }
78 | }
79 |
80 | // it'd be good if optional metrics counted, but not fail if they're not counted (old kernel?)
81 | for _, s := range input.optional {
82 | if v, ok := metricNamesCounter[s]; !ok {
83 | log.Warnf("optional metric not found in the map: %s, ", s)
84 | } else {
85 | assert.Greater(t, v, 0)
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/internal/collector/postgres_schema_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "context"
5 | "github.com/cherts/pgscv/internal/model"
6 | "github.com/cherts/pgscv/internal/store"
7 | "github.com/stretchr/testify/assert"
8 | "testing"
9 | )
10 |
11 | func TestPostgresSchemaCollector_Update(t *testing.T) {
12 | var input = pipelineInput{
13 | required: []string{
14 | "postgres_schema_system_catalog_bytes",
15 | "postgres_schema_non_pk_tables",
16 | "postgres_schema_invalid_indexes_bytes",
17 | "postgres_schema_non_indexed_fkeys",
18 | "postgres_schema_redundant_indexes_bytes",
19 | "postgres_schema_sequence_exhaustion_ratio",
20 | "postgres_schema_mistyped_fkeys",
21 | },
22 | collector: NewPostgresSchemasCollector,
23 | service: model.ServiceTypePostgresql,
24 | }
25 |
26 | pipeline(t, input)
27 | }
28 |
29 | func Test_getSystemCatalogSize(t *testing.T) {
30 | conn := store.NewTest(t)
31 | got, err := getSystemCatalogSize(conn)
32 | assert.NoError(t, err)
33 | assert.NotEqual(t, float64(0), got)
34 |
35 | _ = conn.Conn().Close(context.Background())
36 | got, err = getSystemCatalogSize(conn)
37 | assert.Error(t, err)
38 | assert.Equal(t, float64(0), got)
39 | }
40 |
41 | func Test_getSchemaNonPKTables(t *testing.T) {
42 | conn := store.NewTest(t)
43 | got, err := getSchemaNonPKTables(conn)
44 | assert.NoError(t, err)
45 | assert.Less(t, 0, len(got))
46 |
47 | _ = conn.Conn().Close(context.Background())
48 | got, err = getSchemaNonPKTables(conn)
49 | assert.Error(t, err)
50 | assert.Equal(t, 0, len(got))
51 | }
52 |
53 | func Test_getSchemaInvalidIndexes(t *testing.T) {
54 | conn := store.NewTest(t)
55 | got, err := getSchemaInvalidIndexes(conn)
56 | assert.NoError(t, err)
57 | assert.Less(t, 0, len(got))
58 |
59 | _ = conn.Conn().Close(context.Background())
60 | got, err = getSchemaInvalidIndexes(conn)
61 | assert.Error(t, err)
62 | assert.Equal(t, 0, len(got))
63 | }
64 |
65 | func Test_getSchemaNonIndexedFK(t *testing.T) {
66 | conn := store.NewTest(t)
67 | got, err := getSchemaNonIndexedFK(conn)
68 | assert.NoError(t, err)
69 | assert.Less(t, 0, len(got))
70 |
71 | _ = conn.Conn().Close(context.Background())
72 | got, err = getSchemaNonIndexedFK(conn)
73 | assert.Error(t, err)
74 | assert.Equal(t, 0, len(got))
75 | }
76 |
77 | func Test_getSchemaRedundantIndexes(t *testing.T) {
78 | conn := store.NewTest(t)
79 | got, err := getSchemaRedundantIndexes(conn)
80 | assert.NoError(t, err)
81 | assert.Less(t, 0, len(got))
82 |
83 | _ = conn.Conn().Close(context.Background())
84 | got, err = getSchemaRedundantIndexes(conn)
85 | assert.Error(t, err)
86 | assert.Equal(t, 0, len(got))
87 | }
88 |
89 | func Test_getSchemaSequences(t *testing.T) {
90 | conn := store.NewTest(t)
91 | got, err := getSchemaSequences(conn)
92 | assert.NoError(t, err)
93 | assert.Less(t, 0, len(got))
94 |
95 | _ = conn.Conn().Close(context.Background())
96 | got, err = getSchemaSequences(conn)
97 | assert.Error(t, err)
98 | assert.Equal(t, 0, len(got))
99 | }
100 |
101 | func Test_getSchemaFKDatatypeMismatch(t *testing.T) {
102 | conn := store.NewTest(t)
103 | got, err := getSchemaFKDatatypeMismatch(conn)
104 | assert.NoError(t, err)
105 | assert.Less(t, 0, len(got))
106 |
107 | _ = conn.Conn().Close(context.Background())
108 | got, err = getSchemaFKDatatypeMismatch(conn)
109 | assert.Error(t, err)
110 | assert.Equal(t, 0, len(got))
111 | }
112 |
--------------------------------------------------------------------------------
/deploy/demo-lab/stop_pgbench.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # postgres OR pgbouncer
4 | PG_HOST="pgbouncer"
5 | # default port
6 | PG_PORT=5432
7 | # stop only this pgbench
8 | # ALL - stop all versions of pgbench using PG_VERSIONS array
9 | # PATRONI - stop pgbench for patroni
10 | # 9 - stop only pgbench for postgres v9
11 | # ...
12 | # 17 - stop only pgbench for postgres v17
13 | PG_BENCH_VERSION_RUN=${1:-"ALL"}
14 |
15 | # Don't edit this config
16 | SOURCE="${BASH_SOURCE[0]}"
17 | while [ -h "$SOURCE" ]; do
18 | DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
19 | SOURCE="$(readlink "$SOURCE")"
20 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
21 | done
22 | SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
23 | SCRIPT_NAME=$(basename "$0")
24 |
25 | DATE_START=$(date +"%s")
26 |
27 | # Logging function
28 | _logging() {
29 | local MSG=${1}
30 | local ENDLINE=${2:-"1"}
31 | if [[ "${ENDLINE}" -eq 0 ]]; then
32 | printf "%s: %s" "$(date "+%d.%m.%Y %H:%M:%S")" "${MSG}" 2>/dev/null
33 | else
34 | printf "%s: %s\n" "$(date "+%d.%m.%Y %H:%M:%S")" "${MSG}" 2>/dev/null
35 | fi
36 | }
37 |
38 | # Calculate duration function
39 | _duration() {
40 | local DATE_START=${1:-"$(date +'%s')"}
41 | local FUNC_NAME=${2:-""}
42 | local DATE_END=$(date +"%s")
43 | local D_MSG=""
44 | local DATE_DIFF=$((${DATE_END} - ${DATE_START}))
45 | if [ -n "${FUNC_NAME}" ]; then
46 | local D_MSG=" of execute function '${FUNC_NAME}'"
47 | fi
48 | _logging "Duration${D_MSG}: $((${DATE_DIFF} / 3600)) hours $(((${DATE_DIFF} % 3600) / 60)) minutes $((${DATE_DIFF} % 60)) seconds"
49 | }
50 |
51 | PG_VERSIONS=(
52 | "9,1.4.5"
53 | "10,1.4.5"
54 | "11,1.4.5"
55 | "12,1.4.5"
56 | "13,1.4.6"
57 | "14,1.4.7"
58 | "15,1.4.8"
59 | "16,1.5.0"
60 | "17,1.5.0"
61 | )
62 |
63 | _logging "Starting script."
64 |
65 | PG_BENCHES_RUN=()
66 | if [[ "${PG_BENCH_VERSION_RUN}" == "ALL" ]]; then
67 | PG_BENCHES_RUN=(${PG_VERSIONS[@]})
68 | RUN_PGBENCH_PATRONI=1
69 | _logging "Selected to stop for all versions of pgbench."
70 | elif [[ "${PG_BENCH_VERSION_RUN}" == "PATRONI" ]]; then
71 | _logging "Selected to stop pgbench for patroni"
72 | RUN_PGBENCH_PATRONI=1
73 | else
74 | for DATA in ${PG_VERSIONS[@]}; do
75 | PG_VER=$(echo "${DATA}" | awk -F',' '{print $1}')
76 | PGREPACK_VER=$(echo "${DATA}" | awk -F',' '{print $2}')
77 | if [[ "${PG_BENCH_VERSION_RUN}" == "${PG_VER}" ]]; then
78 | PG_BENCHES_RUN=(${PG_VER},${PGREPACK_VER})
79 | break
80 | fi
81 | done
82 | _logging "Selected to stop pgbench version v${PG_VER}"
83 | fi
84 |
85 | for DATA in ${PG_BENCHES_RUN[@]}; do
86 | PG_VER=$(echo "${DATA}" | awk -F',' '{print $1}')
87 | PGREPACK_VER=$(echo "${DATA}" | awk -F',' '{print $2}')
88 | STOP_FILE="${SCRIPT_DIR}/pgbench/stop_pgbench_${PG_HOST}${PG_VER}_${PG_PORT}"
89 | _logging "Creating stop-file '${STOP_FILE}'"
90 | touch "${STOP_FILE}" >/dev/null 2>&1
91 | done
92 |
93 | if [[ ${RUN_PGBENCH_PATRONI} -eq 1 ]]; then
94 | STOP_FILE="${SCRIPT_DIR}/pgbench/stop_pgbench_haproxy_5000"
95 | _logging "Creating stop-file '${STOP_FILE}'"
96 | touch "${STOP_FILE}" >/dev/null 2>&1
97 | STOP_FILE="${SCRIPT_DIR}/pgbench/stop_pgbench_haproxy_5001"
98 | _logging "Creating stop-file '${STOP_FILE}'"
99 | touch "${STOP_FILE}" >/dev/null 2>&1
100 | fi
101 |
102 | _logging "Wait for all containers running pgbench to complete, this may take up to 2 minutes."
103 | _duration "${DATE_START}"
104 |
105 | _logging "End script. Goodbye ;)"
106 |
--------------------------------------------------------------------------------
/deploy/demo-lab/README.VM-CLUSTER.md:
--------------------------------------------------------------------------------
1 | # pgSCV demo laboratory (use VictoriaMetrics cluster)
2 |
3 | ### Requirements
4 |
5 | - Docker
6 | - Docker Compose
7 |
8 | ### List of running containers and ports
9 |
10 | - pgscv (listen port: 9890)
11 | - grafana (listen port: 3000)
12 | - vmagent-1 (listen port: 8429)
13 | - vmagent-2 (listen port: 9429)
14 | - vmauth (listen port: 8427)
15 | - vmalert (listen port: 8880)
16 | - alertmanager (listen port: 9093)
17 | - vminsert-1 (listen port: 8480)
18 | - vminsert-2 (listen port: 9480)
19 | - vmselect-1
20 | - vmselect-2
21 | - vmstorage-1
22 | - vmstorage-1
23 | - postgres9 (listen port: 5429)
24 | - postgres10 (listen port: 5430)
25 | - postgres11 (listen port: 5431)
26 | - postgres12 (listen port: 5432)
27 | - postgres13 (listen port: 5433)
28 | - postgres14 (listen port: 5434)
29 | - postgres15 (listen port: 5435)
30 | - postgres16 (listen port: 5436)
31 | - postgres17 (listen port: 5437)
32 | - postgres9replica1 (listen port: 4429)
33 | - postgres9replica2 (cascade, listen port: 3429)
34 | - postgres10replica1 (listen port: 4430)
35 | - postgres11replica1 (listen port: 4431)
36 | - postgres12replica1 (listen port: 4432)
37 | - postgres13replica1 (listen port: 4433)
38 | - postgres14replica1 (listen port: 4434)
39 | - postgres15replica1 (listen port: 4435)
40 | - postgres16replica1 (listen port: 4436)
41 | - postgres17replica1 (listen port: 4437)
42 | - postgres17replica2 (cascade, listen port: 3437)
43 | - pgbouncer9 (listen port: 6429)
44 | - pgbouncer10 (listen port: 6430)
45 | - pgbouncer11 (listen port: 6431)
46 | - pgbouncer12 (listen port: 6432)
47 | - pgbouncer13 (listen port: 6433)
48 | - pgbouncer14 (listen port: 6434)
49 | - pgbouncer15 (listen port: 6435)
50 | - pgbouncer16 (listen port: 6436)
51 | - pgbouncer17 (listen port: 6437)
52 | - etcd1
53 | - etcd2
54 | - etcd3
55 | - patroni1 (listen port: 7432, 8008)
56 | - patroni2 (listen port: 7433, 8009)
57 | - patroni3 (listen port: 7434, 8010)
58 | - haproxy (listen port: 5000, 5001)
59 | - pgbench_9
60 | - pgbench_10
61 | - pgbench_11
62 | - pgbench_12
63 | - pgbench_13
64 | - pgbench_14
65 | - pgbench_15
66 | - pgbench_16
67 | - pgbench_17
68 | - pgbench_patroni
69 | - pgbench_patroni_s
70 |
71 | A total of 50 containers are launched!
72 |
73 | ### Quick start
74 |
75 | Prepare demo laboratory::
76 |
77 | ```bash
78 | cat docker-compose.yml docker-compose.vm-cluster.yml compose.*.yml | grep device | awk -F' ' '{print $2}' | sed -e 's/${PWD}\///g' | xargs mkdir -p
79 | cat docker-compose.yml docker-compose.vm-cluster.yml compose.*.yml | grep device | awk -F' ' '{print $2}' | sed -e 's/${PWD}\///g' | xargs chmod 777
80 | ```
81 |
82 | Start demo laboratory:
83 |
84 | ```bash
85 | docker-compose -f docker-compose.vm-cluster.yml up --detach
86 | ```
87 |
88 | Start pgbench tests:
89 |
90 | ```bash
91 | ./start_pgbench.sh
92 | ```
93 |
94 | Open Grafana into Web browser URL:
95 |
96 | Login: admin
97 |
98 | Password: admin
99 |
100 | Open pgSCV dashboards, enjoy and drink coffee ;)
101 |
102 | View pgSCV logs:
103 |
104 | ```bash
105 | docker logs pgscv -f
106 | ```
107 |
108 | ### Stop demo laboratory and cleanup data
109 |
110 | Stop pgbench tests:
111 |
112 | ```bash
113 | ./stop_pgbench.sh
114 | ```
115 |
116 | Stop pgSCV demo laboratory and cleanup demo data:
117 |
118 | ```bash
119 | docker-compose -f docker-compose.vm-cluster.yml down --volumes
120 | ./stop_and_cleanup_data.sh
121 | ```
122 |
--------------------------------------------------------------------------------
/internal/collector/testdata/proc/vmstat.golden:
--------------------------------------------------------------------------------
1 | nr_free_pages 509240
2 | nr_zone_inactive_anon 129292
3 | nr_zone_active_anon 3113580
4 | nr_zone_inactive_file 2071093
5 | nr_zone_active_file 1933629
6 | nr_zone_unevictable 24
7 | nr_zone_write_pending 2815
8 | nr_mlock 24
9 | nr_page_table_pages 24221
10 | nr_kernel_stack 31888
11 | nr_bounce 0
12 | nr_zspages 0
13 | nr_free_cma 0
14 | numa_hit 2666477445
15 | numa_miss 0
16 | numa_foreign 0
17 | numa_interleave 63844
18 | numa_local 2666477445
19 | numa_other 0
20 | nr_inactive_anon 129292
21 | nr_active_anon 3113580
22 | nr_inactive_file 2071093
23 | nr_active_file 1933629
24 | nr_unevictable 24
25 | nr_slab_reclaimable 280253
26 | nr_slab_unreclaimable 80096
27 | nr_isolated_anon 0
28 | nr_isolated_file 0
29 | workingset_nodes 0
30 | workingset_refault 0
31 | workingset_activate 0
32 | workingset_restore 0
33 | workingset_nodereclaim 0
34 | nr_anon_pages 3109050
35 | nr_mapped 476095
36 | nr_file_pages 4129212
37 | nr_dirty 2834
38 | nr_writeback 0
39 | nr_writeback_temp 0
40 | nr_shmem 187131
41 | nr_shmem_hugepages 0
42 | nr_shmem_pmdmapped 0
43 | nr_file_hugepages 0
44 | nr_file_pmdmapped 0
45 | nr_anon_transparent_hugepages 10
46 | nr_unstable 0
47 | nr_vmscan_write 0
48 | nr_vmscan_immediate_reclaim 0
49 | nr_dirtied 19464579
50 | nr_written 15070392
51 | nr_kernel_misc_reclaimable 0
52 | nr_dirty_threshold 889314
53 | nr_dirty_background_threshold 444114
54 | pgpgin 10296479
55 | pgpgout 72628728
56 | pswpin 0
57 | pswpout 0
58 | pgalloc_dma 0
59 | pgalloc_dma32 49732892
60 | pgalloc_normal 2797799457
61 | pgalloc_movable 0
62 | allocstall_dma 0
63 | allocstall_dma32 0
64 | allocstall_normal 0
65 | allocstall_movable 0
66 | pgskip_dma 0
67 | pgskip_dma32 0
68 | pgskip_normal 0
69 | pgskip_movable 0
70 | pgfree 2848045998
71 | pgactivate 57995375
72 | pgdeactivate 0
73 | pglazyfree 1374512
74 | pgfault 3603549394
75 | pgmajfault 25586
76 | pglazyfreed 0
77 | pgrefill 0
78 | pgsteal_kswapd 0
79 | pgsteal_direct 0
80 | pgscan_kswapd 0
81 | pgscan_direct 0
82 | pgscan_direct_throttle 0
83 | zone_reclaim_failed 0
84 | pginodesteal 0
85 | slabs_scanned 0
86 | kswapd_inodesteal 0
87 | kswapd_low_wmark_hit_quickly 0
88 | kswapd_high_wmark_hit_quickly 0
89 | pageoutrun 0
90 | pgrotated 211
91 | drop_pagecache 0
92 | drop_slab 0
93 | oom_kill 10
94 | numa_pte_updates 0
95 | numa_huge_pte_updates 0
96 | numa_hint_faults 0
97 | numa_hint_faults_local 0
98 | numa_pages_migrated 0
99 | pgmigrate_success 0
100 | pgmigrate_fail 0
101 | compact_migrate_scanned 0
102 | compact_free_scanned 0
103 | compact_isolated 0
104 | compact_stall 0
105 | compact_fail 0
106 | compact_success 0
107 | compact_daemon_wake 0
108 | compact_daemon_migrate_scanned 0
109 | compact_daemon_free_scanned 0
110 | htlb_buddy_alloc_success 0
111 | htlb_buddy_alloc_fail 0
112 | unevictable_pgs_culled 60941
113 | unevictable_pgs_scanned 0
114 | unevictable_pgs_rescued 2202
115 | unevictable_pgs_mlocked 2233
116 | unevictable_pgs_munlocked 2209
117 | unevictable_pgs_cleared 0
118 | unevictable_pgs_stranded 0
119 | thp_fault_alloc 11491
120 | thp_fault_fallback 0
121 | thp_collapse_alloc 5198
122 | thp_collapse_alloc_failed 0
123 | thp_file_alloc 0
124 | thp_file_mapped 0
125 | thp_split_page 2152
126 | thp_split_page_failed 0
127 | thp_deferred_split_page 16335
128 | thp_split_pmd 4888
129 | thp_split_pud 0
130 | thp_zero_page_alloc 2
131 | thp_zero_page_alloc_failed 0
132 | thp_swpout 0
133 | thp_swpout_fallback 0
134 | balloon_inflate 0
135 | balloon_deflate 0
136 | balloon_migrate 0
137 | swap_ra 0
138 | swap_ra_hit 0
139 |
--------------------------------------------------------------------------------
/internal/log/log.go:
--------------------------------------------------------------------------------
1 | // Package log is a pgSCV logger
2 | package log
3 |
4 | import (
5 | "fmt"
6 | "os"
7 |
8 | "github.com/rs/zerolog"
9 | )
10 |
11 | // Logger is the global logger with predefined settings
12 | var Logger = zerolog.New(os.Stdout).With().Timestamp().Logger()
13 |
14 | // KV is a simple key-value store
15 | type KV map[string]string
16 |
17 | // SetLevel sets logging level
18 | func SetLevel(level string) {
19 | switch level {
20 | case "debug":
21 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
22 | case "info":
23 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
24 | case "warn":
25 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
26 | case "error":
27 | zerolog.SetGlobalLevel(zerolog.ErrorLevel)
28 | default:
29 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
30 | }
31 | }
32 |
33 | // New create logger
34 | func New() zerolog.Logger {
35 | var logger = Logger
36 | return logger
37 | }
38 |
39 | // SetApplication appends application name string to log messages
40 | func SetApplication(app string) {
41 | Logger = Logger.With().Str("service", app).Logger()
42 | }
43 |
44 | // Debug prints message with DEBUG severity
45 | func Debug(msg string) {
46 | Logger.Debug().Msg(msg)
47 | }
48 |
49 | // Debugf prints formatted message with DEBUG severity
50 | func Debugf(format string, v ...any) {
51 | Logger.Debug().Msgf(format, v...)
52 | }
53 |
54 | // Debugln concatenates arguments and prints them with DEBUG severity
55 | func Debugln(v ...any) {
56 | Logger.Debug().Msg(fmt.Sprint(v...))
57 | }
58 |
59 | // Info prints message with INFO severity
60 | func Info(msg string) {
61 | Logger.Info().Msg(msg)
62 | }
63 |
64 | // Infof prints formatted message with INFO severity
65 | func Infof(format string, v ...any) {
66 | Logger.Info().Msgf(format, v...)
67 | }
68 |
69 | // Infoln concatenates arguments and prints them with INFO severity
70 | func Infoln(v ...any) {
71 | Logger.Info().Msg(fmt.Sprint(v...))
72 | }
73 |
74 | // Warn prints message with WARNING severity
75 | func Warn(msg string) {
76 | Logger.Warn().Msg(msg)
77 | }
78 |
79 | // Warnf prints formatted message with WARNING severity
80 | func Warnf(format string, v ...any) {
81 | Logger.Warn().Msgf(format, v...)
82 | }
83 |
84 | // Warnln concatenates arguments and prints them with WARNING severity
85 | func Warnln(v ...any) {
86 | Logger.Warn().Msg(fmt.Sprint(v...))
87 | }
88 |
89 | // Error prints message with ERROR severity
90 | func Error(msg string) {
91 | Logger.Error().Msg(msg)
92 | }
93 |
94 | // Errorf prints formatted message with ERROR severity
95 | func Errorf(format string, v ...any) {
96 | Logger.Error().Msgf(format, v...)
97 | }
98 |
99 | // Errorln concatenates arguments and prints them with ERROR severity
100 | func Errorln(v ...any) {
101 | Logger.Error().Msg(fmt.Sprint(v...))
102 | }
103 |
104 | // KVError prints message with ERROR severity with attached KV map
105 | func KVError(kv KV, msg string) {
106 | log := Logger.Error()
107 | for k, v := range kv {
108 | log.Str(k, v)
109 | }
110 | log.Msg(msg)
111 | }
112 |
113 | // KVErrorf prints formatted message with ERROR severity with attached KV map
114 | func KVErrorf(kv KV, format string, v ...any) {
115 | log := Logger.Error()
116 | for k, v := range kv {
117 | log.Str(k, v)
118 | }
119 | log.Msgf(format, v...)
120 | }
121 |
122 | // KVErrorln concatenates arguments and prints them with ERROR severity with attached KV map
123 | func KVErrorln(kv KV, v ...any) {
124 | log := Logger.Error()
125 | for k, v := range kv {
126 | log.Str(k, v)
127 | }
128 | log.Msg(fmt.Sprint(v...))
129 | }
130 |
--------------------------------------------------------------------------------
/internal/collector/pgbouncer_settings_test.go:
--------------------------------------------------------------------------------
1 | package collector
2 |
3 | import (
4 | "database/sql"
5 | "github.com/jackc/pgproto3/v2"
6 | "github.com/cherts/pgscv/internal/model"
7 | "github.com/cherts/pgscv/internal/store"
8 | "github.com/stretchr/testify/assert"
9 | "testing"
10 | )
11 |
12 | func TestPgbouncerSettingsCollector_Update(t *testing.T) {
13 | var input = pipelineInput{
14 | required: []string{
15 | "pgbouncer_version",
16 | "pgbouncer_service_settings_info",
17 | "pgbouncer_service_database_settings_info",
18 | "pgbouncer_service_database_pool_size",
19 | },
20 | collector: NewPgbouncerSettingsCollector,
21 | service: model.ServiceTypePgbouncer,
22 | }
23 |
24 | pipeline(t, input)
25 | }
26 |
27 | func Test_queryPgbouncerVersion(t *testing.T) {
28 | db := store.NewTestPgbouncer(t)
29 |
30 | str, num, err := queryPgbouncerVersion(db)
31 | assert.NoError(t, err)
32 | assert.NotEqual(t, "", str)
33 | assert.NotEqual(t, 0, num)
34 | }
35 |
36 | func Test_parsePgbouncerSettings(t *testing.T) {
37 | var testCases = []struct {
38 | name string
39 | res *model.PGResult
40 | want map[string]string
41 | }{
42 | {
43 | name: "normal output",
44 | res: &model.PGResult{
45 | Nrows: 2,
46 | Ncols: 3,
47 | Colnames: []pgproto3.FieldDescription{
48 | {Name: []byte("key")}, {Name: []byte("value")}, {Name: []byte("changeable")},
49 | },
50 | Rows: [][]sql.NullString{
51 | {{String: "listen_addr", Valid: true}, {String: "127.0.0.1", Valid: true}, {String: "no", Valid: true}},
52 | {{String: "max_client_conn", Valid: true}, {String: "1000", Valid: true}, {String: "yes", Valid: true}},
53 | },
54 | },
55 | want: map[string]string{
56 | "listen_addr": "127.0.0.1",
57 | "max_client_conn": "1000",
58 | },
59 | },
60 | }
61 |
62 | for _, tc := range testCases {
63 | t.Run(tc.name, func(t *testing.T) {
64 | got := parsePgbouncerSettings(tc.res)
65 | assert.EqualValues(t, tc.want, got)
66 | })
67 | }
68 | }
69 |
70 | func Test_getPerDatabaseSettings(t *testing.T) {
71 | defaults := map[string]string{
72 | "pool_mode": "transaction",
73 | "default_pool_size": "35",
74 | }
75 |
76 | want := []dbSettings{
77 | {name: "test1", mode: "transaction", size: "30"},
78 | {name: "test2", mode: "transaction", size: "30"},
79 | {name: "test3", mode: "transaction", size: "20"},
80 | {name: "test4", mode: "session", size: "10"},
81 | {name: "*", mode: "transaction", size: "10"},
82 | }
83 |
84 | got, err := getPerDatabaseSettings("./testdata/pgbouncer/pgbouncer.ini.golden", defaults)
85 | assert.NoError(t, err)
86 | assert.Equal(t, want, got)
87 |
88 | _, err = getPerDatabaseSettings("./testdata/pgbouncer/unknown.file", defaults)
89 | assert.Error(t, err)
90 | }
91 |
92 | func Test_parsePoolSettingsLine(t *testing.T) {
93 | testcases := []struct {
94 | in string
95 | want dbSettings
96 | valid bool
97 | }{
98 | {in: "test = host=1.2.3.4 pool_size=50", want: dbSettings{name: "test", mode: "", size: "50"}, valid: true},
99 | {in: "test = host=1.2.3.4 pool_size=50 pool_mode=transaction", want: dbSettings{name: "test", mode: "transaction", size: "50"}, valid: true},
100 | {in: "test =", want: dbSettings{name: "test", mode: "", size: ""}, valid: true},
101 | {in: "invalid", valid: false},
102 | }
103 |
104 | for _, tc := range testcases {
105 | got, err := parseDatabaseSettingsLine(tc.in)
106 | if tc.valid {
107 | assert.NoError(t, err)
108 | assert.Equal(t, tc.want, got)
109 | } else {
110 | assert.Error(t, err)
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/internal/service/config_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_ParsePostgresDSNEnv(t *testing.T) {
10 | gotID, gotCS, err := ParsePostgresDSNEnv("POSTGRES_DSN", "conninfo")
11 | assert.NoError(t, err)
12 | assert.Equal(t, "postgres", gotID)
13 | assert.Equal(t, ConnSetting{ServiceType: "postgres", Conninfo: "conninfo"}, gotCS)
14 |
15 | gotID, gotCS, err = ParsePostgresDSNEnv("DATABASE_DSN", "conninfo")
16 | assert.NoError(t, err)
17 | assert.Equal(t, "postgres", gotID)
18 | assert.Equal(t, ConnSetting{ServiceType: "postgres", Conninfo: "conninfo"}, gotCS)
19 |
20 | _, _, err = ParsePostgresDSNEnv("INVALID", "conninfo")
21 | assert.Error(t, err)
22 | }
23 |
24 | func Test_ParsePgbouncerDSNEnv(t *testing.T) {
25 | gotID, gotCS, err := ParsePgbouncerDSNEnv("PGBOUNCER_DSN", "conninfo")
26 | assert.NoError(t, err)
27 | assert.Equal(t, "pgbouncer", gotID)
28 | assert.Equal(t, ConnSetting{ServiceType: "pgbouncer", Conninfo: "conninfo"}, gotCS)
29 |
30 | _, _, err = ParsePgbouncerDSNEnv("INVALID", "conninfo")
31 | assert.Error(t, err)
32 | }
33 |
34 | func Test_parseDSNEnv(t *testing.T) {
35 | testcases := []struct {
36 | valid bool
37 | prefix string
38 | key string
39 | wantID string
40 | wantType string
41 | }{
42 | {valid: true, prefix: "POSTGRES_DSN", key: "POSTGRES_DSN", wantID: "postgres", wantType: "postgres"},
43 | {valid: true, prefix: "POSTGRES_DSN", key: "POSTGRES_DSN_POSTGRES_123", wantID: "POSTGRES_123", wantType: "postgres"},
44 | {valid: true, prefix: "POSTGRES_DSN", key: "POSTGRES_DSN1", wantID: "1", wantType: "postgres"},
45 | {valid: true, prefix: "POSTGRES_DSN", key: "POSTGRES_DSN_POSTGRES_5432", wantID: "POSTGRES_5432", wantType: "postgres"},
46 | {valid: true, prefix: "PGBOUNCER_DSN", key: "PGBOUNCER_DSN", wantID: "pgbouncer", wantType: "pgbouncer"},
47 | {valid: true, prefix: "PGBOUNCER_DSN", key: "PGBOUNCER_DSN_PGBOUNCER_123", wantID: "PGBOUNCER_123", wantType: "pgbouncer"},
48 | {valid: true, prefix: "PGBOUNCER_DSN", key: "PGBOUNCER_DSN1", wantID: "1", wantType: "pgbouncer"},
49 | {valid: true, prefix: "PGBOUNCER_DSN", key: "PGBOUNCER_DSN_PGBOUNCER_6432", wantID: "PGBOUNCER_6432", wantType: "pgbouncer"},
50 | {valid: false, prefix: "POSTGRES_DSN", key: "POSTGRES_DSN_"},
51 | {valid: false, prefix: "POSTGRES_DSN", key: "INVALID"},
52 | {valid: false, prefix: "INVALID", key: "INVALID"},
53 | }
54 |
55 | for _, tc := range testcases {
56 | gotID, gotCS, err := parseDSNEnv(tc.prefix, tc.key, "conninfo")
57 | if tc.valid {
58 | assert.NoError(t, err)
59 | assert.Equal(t, tc.wantID, gotID)
60 | assert.Equal(t, ConnSetting{ServiceType: tc.wantType, Conninfo: "conninfo"}, gotCS)
61 | } else {
62 | assert.Error(t, err)
63 | }
64 | }
65 | }
66 |
67 | func Test_parseURLEnv(t *testing.T) {
68 | testcases := []struct {
69 | valid bool
70 | prefix string
71 | key string
72 | wantID string
73 | wantType string
74 | }{
75 | {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL", wantID: "patroni", wantType: "patroni"},
76 | {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL1", wantID: "1", wantType: "patroni"},
77 | {valid: true, prefix: "PATRONI_URL", key: "PATRONI_URL_PATRONI_123", wantID: "PATRONI_123", wantType: "patroni"},
78 | //
79 | {valid: false, prefix: "PATRONI_URL", key: "PATRONI_URL_"},
80 | {valid: false, prefix: "PATRONI_URL", key: "INVALID"},
81 | {valid: false, prefix: "INVALID", key: "INVALID"},
82 | }
83 |
84 | for _, tc := range testcases {
85 | gotID, gotCS, err := parseURLEnv(tc.prefix, tc.key, "baseurl")
86 | if tc.valid {
87 | assert.NoError(t, err)
88 | assert.Equal(t, tc.wantID, gotID)
89 | assert.Equal(t, ConnSetting{ServiceType: tc.wantType, BaseURL: "baseurl"}, gotCS)
90 | } else {
91 | assert.Error(t, err)
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------