├── 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 | --------------------------------------------------------------------------------