active_postgres 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +142 -5
- data/lib/active_postgres/cli.rb +7 -0
- data/lib/active_postgres/components/core.rb +2 -7
- data/lib/active_postgres/components/extensions.rb +40 -35
- data/lib/active_postgres/components/monitoring.rb +256 -4
- data/lib/active_postgres/components/pgbackrest.rb +91 -5
- data/lib/active_postgres/components/pgbouncer.rb +58 -7
- data/lib/active_postgres/components/repmgr.rb +448 -62
- data/lib/active_postgres/configuration.rb +66 -1
- data/lib/active_postgres/connection_pooler.rb +3 -12
- data/lib/active_postgres/credentials.rb +3 -3
- data/lib/active_postgres/direct_executor.rb +1 -2
- data/lib/active_postgres/generators/active_postgres/install_generator.rb +2 -0
- data/lib/active_postgres/generators/active_postgres/templates/postgres.yml.erb +28 -1
- data/lib/active_postgres/health_checker.rb +29 -7
- data/lib/active_postgres/installer.rb +9 -0
- data/lib/active_postgres/overview.rb +351 -0
- data/lib/active_postgres/rollback_manager.rb +4 -8
- data/lib/active_postgres/secrets.rb +23 -5
- data/lib/active_postgres/ssh_executor.rb +44 -19
- data/lib/active_postgres/version.rb +1 -1
- data/lib/active_postgres.rb +1 -0
- data/lib/tasks/postgres.rake +77 -4
- data/lib/tasks/rotate_credentials.rake +4 -16
- data/templates/pg_hba.conf.erb +4 -1
- data/templates/pgbackrest.conf.erb +28 -0
- data/templates/pgbouncer-follow-primary.service.erb +8 -0
- data/templates/pgbouncer-follow-primary.timer.erb +11 -0
- data/templates/pgbouncer.ini.erb +2 -0
- data/templates/pgbouncer_follow_primary.sh.erb +34 -0
- data/templates/postgresql.conf.erb +4 -0
- data/templates/repmgr.conf.erb +10 -3
- data/templates/repmgr_dns_failover.sh.erb +59 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c89909c556964abfe7dbede0550c7fa1fbd60fc332e667fce5de858114ba2146
|
|
4
|
+
data.tar.gz: e715719391d53f89cb394203018a5e2951f2602018ef97a417ee369d443e410c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 603d5b28ef5730d595b24bf36b517148a882c5889d339675545fc98ffda547d8c1ead15ff8ee6a048fd6b117b5e257309460e7b5e25a9ee5eb4e55c730050ba4
|
|
7
|
+
data.tar.gz: 50f632e74185dc90d7da5e6ece125fc625b04665cc4859ca7fd3d136e82df6e295b5fde10d8a2239675ce67a1a527f24ef8ec00cfeff79c57d84601a11168105
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Production-grade PostgreSQL HA for Rails.
|
|
|
7
7
|
- **High Availability**: Primary/standby replication with automatic failover (repmgr)
|
|
8
8
|
- **Connection Pooling**: PgBouncer integration
|
|
9
9
|
- **Rails Integration**: Automatic database.yml config, migration guard, read replica routing
|
|
10
|
-
- **Modular Components**: Core, Performance Tuning, repmgr, PgBouncer, pgBackRest, Monitoring, SSL, Extensions
|
|
10
|
+
- **Modular Components**: Core, Performance Tuning (opt-in), repmgr, PgBouncer, pgBackRest, Monitoring, SSL, Extensions
|
|
11
11
|
|
|
12
12
|
## Quick Start
|
|
13
13
|
|
|
@@ -48,6 +48,7 @@ production:
|
|
|
48
48
|
superuser_password: $POSTGRES_SUPERUSER_PASSWORD
|
|
49
49
|
replication_password: $POSTGRES_REPLICATION_PASSWORD
|
|
50
50
|
repmgr_password: $POSTGRES_REPMGR_PASSWORD
|
|
51
|
+
monitoring_password: $POSTGRES_MONITORING_PASSWORD
|
|
51
52
|
app_password: $POSTGRES_APP_PASSWORD
|
|
52
53
|
```
|
|
53
54
|
|
|
@@ -68,6 +69,8 @@ postgres:
|
|
|
68
69
|
```bash
|
|
69
70
|
rake postgres:setup # Deploy cluster
|
|
70
71
|
rake postgres:status # Check health
|
|
72
|
+
rake postgres:overview # Control tower overview
|
|
73
|
+
rake postgres:help # Task summary
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
## Common Operations
|
|
@@ -82,8 +85,10 @@ rake postgres:promote[host] # Promote standby to primary
|
|
|
82
85
|
|
|
83
86
|
# Backups (requires pgBackRest)
|
|
84
87
|
rake postgres:backup:full
|
|
88
|
+
rake postgres:backup:incremental
|
|
85
89
|
rake postgres:backup:list
|
|
86
90
|
rake postgres:backup:restore[backup_id]
|
|
91
|
+
rake postgres:backup:restore_at["2026-01-29 01:15:00",promote]
|
|
87
92
|
|
|
88
93
|
# Credential rotation (zero downtime)
|
|
89
94
|
rake postgres:credentials:rotate_random
|
|
@@ -100,6 +105,7 @@ rake postgres:update:patch # Security patches
|
|
|
100
105
|
active_postgres setup --environment=production
|
|
101
106
|
active_postgres setup-standby HOST
|
|
102
107
|
active_postgres status
|
|
108
|
+
active_postgres overview
|
|
103
109
|
active_postgres promote HOST
|
|
104
110
|
active_postgres backup --type=full
|
|
105
111
|
active_postgres cache-secrets
|
|
@@ -110,14 +116,134 @@ active_postgres cache-secrets
|
|
|
110
116
|
| Component | Description | Config |
|
|
111
117
|
|-----------|-------------|--------|
|
|
112
118
|
| **Core** | PostgreSQL installation | Always enabled |
|
|
113
|
-
| **Performance Tuning** | Auto-optimization |
|
|
119
|
+
| **Performance Tuning** | Auto-optimization | Disabled by default |
|
|
114
120
|
| **repmgr** | HA & automatic failover | `repmgr: {enabled: true}` |
|
|
115
121
|
| **PgBouncer** | Connection pooling | `pgbouncer: {enabled: true}` |
|
|
116
122
|
| **pgBackRest** | Backup & restore | `pgbackrest: {enabled: true}` |
|
|
117
|
-
| **Monitoring** | postgres_exporter | `monitoring: {enabled: true}` |
|
|
123
|
+
| **Monitoring** | postgres_exporter + optional node_exporter/Grafana | `monitoring: {enabled: true}` |
|
|
118
124
|
| **SSL** | Encrypted connections | `ssl: {enabled: true}` |
|
|
119
125
|
| **Extensions** | pgvector, PostGIS, etc. | `extensions: {enabled: true, list: [pgvector]}` |
|
|
120
126
|
|
|
127
|
+
### Monitoring credentials
|
|
128
|
+
|
|
129
|
+
`postgres_exporter` uses a dedicated `pg_monitor` user. Provide a password in secrets and optionally set the username.
|
|
130
|
+
Enable node_exporter for host-level metrics, and Grafana if you want a local UI:
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
components:
|
|
134
|
+
monitoring:
|
|
135
|
+
enabled: true
|
|
136
|
+
user: postgres_exporter
|
|
137
|
+
node_exporter: true
|
|
138
|
+
node_exporter_port: 9100
|
|
139
|
+
# node_exporter_listen_address: 10.8.0.10
|
|
140
|
+
# grafana:
|
|
141
|
+
# enabled: true
|
|
142
|
+
# host: grafana.example.com
|
|
143
|
+
# listen_address: 10.8.0.10
|
|
144
|
+
# port: 3000
|
|
145
|
+
# prometheus_url: http://prometheus.example.com:9090
|
|
146
|
+
secrets:
|
|
147
|
+
monitoring_password: $POSTGRES_MONITORING_PASSWORD
|
|
148
|
+
# grafana_admin_password: $GRAFANA_ADMIN_PASSWORD
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
When Grafana is enabled, `grafana_admin_password` is required and Grafana will be installed
|
|
152
|
+
on the configured host. If `prometheus_url` is provided, a Prometheus datasource is provisioned.
|
|
153
|
+
|
|
154
|
+
### pgBackRest S3-compatible endpoints
|
|
155
|
+
|
|
156
|
+
For Tigris/MinIO/Wasabi/DO Spaces, set a custom endpoint and use path-style URLs:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
components:
|
|
160
|
+
pgbackrest:
|
|
161
|
+
enabled: true
|
|
162
|
+
repo_type: s3
|
|
163
|
+
s3_bucket: myapp-backups
|
|
164
|
+
s3_region: auto
|
|
165
|
+
s3_endpoint: t3.storage.dev
|
|
166
|
+
s3_uri_style: path
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Point-in-time recovery (PITR)
|
|
170
|
+
|
|
171
|
+
Ensure WAL retention is long enough for your recovery window:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
components:
|
|
175
|
+
pgbackrest:
|
|
176
|
+
retention_full: 7
|
|
177
|
+
retention_archive: 14
|
|
178
|
+
# You can schedule full + incremental separately:
|
|
179
|
+
# schedule_full: "0 2 * * *"
|
|
180
|
+
# schedule_incremental: "0 * * * *"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Run a PITR restore:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
rake postgres:backup:restore_at["2026-01-29 01:15:00",promote]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If you want the old primary to rejoin without a full re-clone after failover,
|
|
190
|
+
enable pg_rewind support in repmgr:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
components:
|
|
194
|
+
repmgr:
|
|
195
|
+
use_rewind: true
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Stable app endpoint with PgBouncer
|
|
199
|
+
|
|
200
|
+
If you run PgBouncer on each PostgreSQL node and want a fixed app URL, enable `follow_primary`.
|
|
201
|
+
Each PgBouncer instance periodically repoints to the current primary using repmgr metadata.
|
|
202
|
+
Put a TCP load balancer or DNS record in front of the PgBouncer nodes.
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
components:
|
|
206
|
+
pgbouncer:
|
|
207
|
+
enabled: true
|
|
208
|
+
follow_primary: true
|
|
209
|
+
follow_primary_interval: 5
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Automatic DNS updates on failover (dnsmasq)
|
|
213
|
+
|
|
214
|
+
If you use Messhy’s mesh DNS (dnsmasq), repmgr can update writer/reader DNS records on failover.
|
|
215
|
+
Enable `dns_failover` under `repmgr` and provide DNS server IPs or hostnames.
|
|
216
|
+
If you run setup from outside the mesh, use objects with both the public SSH host
|
|
217
|
+
and the private WireGuard IP so setup can SSH in and failover updates stay on the mesh.
|
|
218
|
+
If you run setup from inside the mesh, you can use a simple list of private IPs.
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
components:
|
|
222
|
+
repmgr:
|
|
223
|
+
enabled: true
|
|
224
|
+
dns_failover:
|
|
225
|
+
enabled: true
|
|
226
|
+
provider: dnsmasq
|
|
227
|
+
domain: mesh.internal
|
|
228
|
+
# Or use multiple domains during cutover:
|
|
229
|
+
# domains: [mesh.internal, mesh.v2.internal]
|
|
230
|
+
dns_servers:
|
|
231
|
+
- host: 18.170.173.14
|
|
232
|
+
private_ip: 10.8.0.10
|
|
233
|
+
- host: 98.85.183.175
|
|
234
|
+
private_ip: 10.8.0.110
|
|
235
|
+
primary_record: db-primary.mesh.internal
|
|
236
|
+
replica_record: db-replica.mesh.internal
|
|
237
|
+
# Or multiple records explicitly:
|
|
238
|
+
# primary_records: [db-primary.mesh.internal, db-primary.mesh.v2.internal]
|
|
239
|
+
# replica_records: [db-replica.mesh.internal, db-replica.mesh.v2.internal]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
This installs an event hook so repmgr updates `/etc/dnsmasq.d/active_postgres.conf`
|
|
243
|
+
on the DNS servers whenever the primary changes.
|
|
244
|
+
DNS servers must be reachable over the private network and allow SSH from the
|
|
245
|
+
database nodes (active_postgres installs SSH keys on first setup).
|
|
246
|
+
|
|
121
247
|
## Secrets Management
|
|
122
248
|
|
|
123
249
|
```yaml
|
|
@@ -127,7 +253,8 @@ secrets:
|
|
|
127
253
|
|
|
128
254
|
# Command execution
|
|
129
255
|
password: $(op read "op://vault/item/field")
|
|
130
|
-
password:
|
|
256
|
+
password: rails_credentials:postgres.password
|
|
257
|
+
password: credentials:postgres.password
|
|
131
258
|
|
|
132
259
|
# AWS Secrets Manager
|
|
133
260
|
password: $(aws secretsmanager get-secret-value --secret-id myapp/postgres --query SecretString)
|
|
@@ -150,9 +277,19 @@ User.all # → Replica
|
|
|
150
277
|
- Ruby 3.0+
|
|
151
278
|
- PostgreSQL 12+
|
|
152
279
|
- Ubuntu 20.04+ / Debian 11+ with systemd
|
|
153
|
-
- SSH key-based authentication
|
|
280
|
+
- SSH key-based authentication (hosts must be in `~/.ssh/known_hosts`)
|
|
154
281
|
- Rails 6.0+ (optional)
|
|
155
282
|
|
|
283
|
+
### SSH host key verification
|
|
284
|
+
|
|
285
|
+
By default, host keys are verified strictly (`always`). For first-time provisioning you can set:
|
|
286
|
+
|
|
287
|
+
```yaml
|
|
288
|
+
ssh_host_key_verification: accept_new
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
This trusts a host key the first time and then pins it; prefer `always` once hosts are known.
|
|
292
|
+
|
|
156
293
|
## License
|
|
157
294
|
|
|
158
295
|
MIT
|
data/lib/active_postgres/cli.rb
CHANGED
|
@@ -49,6 +49,13 @@ module ActivePostgres
|
|
|
49
49
|
health_checker.show_status
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
desc 'overview', 'Show control tower overview'
|
|
53
|
+
def overview
|
|
54
|
+
config = load_config
|
|
55
|
+
overview = Overview.new(config)
|
|
56
|
+
overview.show
|
|
57
|
+
end
|
|
58
|
+
|
|
52
59
|
desc 'health', 'Run health checks'
|
|
53
60
|
def health
|
|
54
61
|
config = load_config
|
|
@@ -189,14 +189,9 @@ module ActivePostgres
|
|
|
189
189
|
app_password = resolve_app_password
|
|
190
190
|
sql = build_app_user_sql(app_user, app_database, app_password)
|
|
191
191
|
|
|
192
|
-
ssh_executor.
|
|
193
|
-
upload! StringIO.new(sql), '/tmp/create_app_user.sql'
|
|
194
|
-
execute :chmod, '644', '/tmp/create_app_user.sql'
|
|
195
|
-
execute :sudo, '-u', 'postgres', 'psql', '-f', '/tmp/create_app_user.sql'
|
|
196
|
-
execute :rm, '-f', '/tmp/create_app_user.sql'
|
|
192
|
+
ssh_executor.run_sql(host, sql, postgres_user: 'postgres', tuples_only: false, capture: false)
|
|
197
193
|
|
|
198
|
-
|
|
199
|
-
end
|
|
194
|
+
puts " ✓ Created app user '#{app_user}' and database '#{app_database}'"
|
|
200
195
|
rescue StandardError => e
|
|
201
196
|
warn " Warning: Could not create app user: #{e.message}"
|
|
202
197
|
warn ' You may need to create the user manually'
|
|
@@ -33,17 +33,27 @@ module ActivePostgres
|
|
|
33
33
|
puts 'Extensions uninstall not implemented (extensions remain in database)'
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
def restart
|
|
37
|
+
puts 'Extensions do not require restart (loaded at database connection time)'
|
|
38
|
+
end
|
|
37
39
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
db_name = config.secrets_config['database_name'] || 'postgres'
|
|
42
|
-
postgres_user = config.postgres_user
|
|
40
|
+
def install_on_standby(standby_host)
|
|
41
|
+
extensions_config = config.component_config(:extensions)
|
|
42
|
+
return unless extensions_config[:enabled]
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
extensions = extensions_config[:list] || []
|
|
45
|
+
return if extensions.empty?
|
|
46
|
+
|
|
47
|
+
puts "Installing extension packages on standby #{standby_host}..."
|
|
48
|
+
install_packages_on_host(standby_host, extensions)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
45
52
|
|
|
53
|
+
def install_packages_on_host(host, extensions)
|
|
54
|
+
version = config.version
|
|
46
55
|
packages_to_install = []
|
|
56
|
+
|
|
47
57
|
extensions.each do |ext_name|
|
|
48
58
|
package = EXTENSION_PACKAGES[ext_name]
|
|
49
59
|
next unless package
|
|
@@ -52,46 +62,41 @@ module ActivePostgres
|
|
|
52
62
|
packages_to_install << package
|
|
53
63
|
end
|
|
54
64
|
|
|
65
|
+
return if packages_to_install.empty?
|
|
66
|
+
|
|
55
67
|
ssh_executor.execute_on_host(host) do
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
execute :sudo, 'apt-get', 'update', '-qq'
|
|
69
|
+
execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq',
|
|
70
|
+
*packages_to_install
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def install_on_primary(extensions)
|
|
75
|
+
host = config.primary_host
|
|
76
|
+
db_name = config.secrets_config['database_name'] || 'postgres'
|
|
77
|
+
postgres_user = config.postgres_user
|
|
78
|
+
|
|
79
|
+
puts " Installing extensions on primary (#{host})..."
|
|
80
|
+
|
|
81
|
+
install_packages_on_host(host, extensions)
|
|
61
82
|
|
|
83
|
+
ssh_executor.execute_on_host(host) do
|
|
62
84
|
extensions.each do |ext_name|
|
|
63
|
-
sql = "CREATE EXTENSION IF NOT EXISTS #{ext_name};"
|
|
85
|
+
sql = "CREATE EXTENSION IF NOT EXISTS \"#{ext_name}\";"
|
|
64
86
|
begin
|
|
65
87
|
execute :sudo, '-u', postgres_user, 'psql', '-d', db_name, '-c', sql
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
info "✓ Extension #{ext_name} created/verified"
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
warn "⚠ Could not create extension #{ext_name}: #{e.message}"
|
|
68
91
|
end
|
|
69
92
|
end
|
|
70
93
|
end
|
|
71
94
|
end
|
|
72
95
|
|
|
73
96
|
def install_on_standbys(extensions)
|
|
74
|
-
version = config.version
|
|
75
|
-
|
|
76
97
|
config.standby_hosts.each do |host|
|
|
77
|
-
puts " Installing
|
|
78
|
-
|
|
79
|
-
packages_to_install = []
|
|
80
|
-
extensions.each do |ext_name|
|
|
81
|
-
package = EXTENSION_PACKAGES[ext_name]
|
|
82
|
-
next unless package
|
|
83
|
-
|
|
84
|
-
package = package.gsub('{version}', version.to_s)
|
|
85
|
-
packages_to_install << package
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
ssh_executor.execute_on_host(host) do
|
|
89
|
-
unless packages_to_install.empty?
|
|
90
|
-
execute :sudo, 'apt-get', 'update', '-qq'
|
|
91
|
-
execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq',
|
|
92
|
-
*packages_to_install
|
|
93
|
-
end
|
|
94
|
-
end
|
|
98
|
+
puts " Installing extension packages on standby (#{host})..."
|
|
99
|
+
install_packages_on_host(host, extensions)
|
|
95
100
|
end
|
|
96
101
|
end
|
|
97
102
|
end
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
1
3
|
module ActivePostgres
|
|
2
4
|
module Components
|
|
3
5
|
class Monitoring < Base
|
|
4
6
|
def install
|
|
5
7
|
puts 'Installing postgres_exporter for monitoring...'
|
|
6
8
|
|
|
9
|
+
ensure_monitoring_user
|
|
10
|
+
|
|
7
11
|
config.all_hosts.each do |host|
|
|
8
12
|
install_on_host(host)
|
|
9
13
|
end
|
|
14
|
+
|
|
15
|
+
install_grafana_if_enabled
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
def uninstall
|
|
@@ -14,10 +20,19 @@ module ActivePostgres
|
|
|
14
20
|
|
|
15
21
|
config.all_hosts.each do |host|
|
|
16
22
|
ssh_executor.execute_on_host(host) do
|
|
17
|
-
execute :sudo, 'systemctl', 'stop', '
|
|
18
|
-
execute :sudo, 'systemctl', 'disable', '
|
|
23
|
+
execute :sudo, 'systemctl', 'stop', 'prometheus-postgres-exporter'
|
|
24
|
+
execute :sudo, 'systemctl', 'disable', 'prometheus-postgres-exporter'
|
|
25
|
+
execute :sudo, 'apt-get', 'remove', '-y', 'prometheus-postgres-exporter'
|
|
26
|
+
|
|
27
|
+
if node_exporter_enabled?
|
|
28
|
+
execute :sudo, 'systemctl', 'stop', 'prometheus-node-exporter'
|
|
29
|
+
execute :sudo, 'systemctl', 'disable', 'prometheus-node-exporter'
|
|
30
|
+
execute :sudo, 'apt-get', 'remove', '-y', 'prometheus-node-exporter'
|
|
31
|
+
end
|
|
19
32
|
end
|
|
20
33
|
end
|
|
34
|
+
|
|
35
|
+
uninstall_grafana_if_enabled
|
|
21
36
|
end
|
|
22
37
|
|
|
23
38
|
def restart
|
|
@@ -25,11 +40,17 @@ module ActivePostgres
|
|
|
25
40
|
|
|
26
41
|
config.all_hosts.each do |host|
|
|
27
42
|
ssh_executor.execute_on_host(host) do
|
|
28
|
-
execute :sudo, 'systemctl', 'restart', '
|
|
43
|
+
execute :sudo, 'systemctl', 'restart', 'prometheus-postgres-exporter'
|
|
44
|
+
execute :sudo, 'systemctl', 'restart', 'prometheus-node-exporter' if node_exporter_enabled?
|
|
29
45
|
end
|
|
30
46
|
end
|
|
31
47
|
end
|
|
32
48
|
|
|
49
|
+
def install_on_standby(standby_host)
|
|
50
|
+
puts "Installing postgres_exporter on standby #{standby_host}..."
|
|
51
|
+
install_on_host(standby_host)
|
|
52
|
+
end
|
|
53
|
+
|
|
33
54
|
private
|
|
34
55
|
|
|
35
56
|
def install_on_host(host)
|
|
@@ -42,13 +63,244 @@ module ActivePostgres
|
|
|
42
63
|
ssh_executor.execute_on_host(host) do
|
|
43
64
|
# Install via package manager or download binary
|
|
44
65
|
execute :sudo, 'apt-get', 'install', '-y', '-qq', 'prometheus-postgres-exporter'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
configure_exporter_service(host, monitoring_config, exporter_port)
|
|
45
69
|
|
|
70
|
+
ssh_executor.execute_on_host(host) do
|
|
46
71
|
# Enable and start
|
|
47
72
|
execute :sudo, 'systemctl', 'enable', 'prometheus-postgres-exporter'
|
|
48
|
-
execute :sudo, 'systemctl', '
|
|
73
|
+
execute :sudo, 'systemctl', 'restart', 'prometheus-postgres-exporter'
|
|
49
74
|
end
|
|
50
75
|
|
|
51
76
|
puts " Metrics available at: http://#{host}:#{exporter_port}/metrics"
|
|
77
|
+
|
|
78
|
+
install_node_exporter(host, monitoring_config) if node_exporter_enabled?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_monitoring_user
|
|
82
|
+
monitoring_config = config.component_config(:monitoring)
|
|
83
|
+
monitoring_user = monitoring_config[:user] || 'postgres_exporter'
|
|
84
|
+
monitoring_password = normalize_monitoring_password(secrets.resolve('monitoring_password'))
|
|
85
|
+
|
|
86
|
+
sql = build_monitoring_user_sql(monitoring_user, monitoring_password)
|
|
87
|
+
|
|
88
|
+
ssh_executor.run_sql(config.primary_host, sql, postgres_user: 'postgres', port: 5432, tuples_only: false,
|
|
89
|
+
capture: false)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_monitoring_user_sql(user, password)
|
|
93
|
+
escaped_password = password.gsub("'", "''")
|
|
94
|
+
|
|
95
|
+
[
|
|
96
|
+
'DO $$',
|
|
97
|
+
'BEGIN',
|
|
98
|
+
" IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '#{user}') THEN",
|
|
99
|
+
" CREATE USER #{user} WITH LOGIN PASSWORD '#{escaped_password}';",
|
|
100
|
+
' ELSE',
|
|
101
|
+
" ALTER USER #{user} WITH LOGIN PASSWORD '#{escaped_password}';",
|
|
102
|
+
' END IF;',
|
|
103
|
+
'END $$;',
|
|
104
|
+
'',
|
|
105
|
+
"GRANT pg_monitor TO #{user};",
|
|
106
|
+
''
|
|
107
|
+
].join("\n")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def configure_exporter_service(host, monitoring_config, exporter_port)
|
|
111
|
+
monitoring_user = monitoring_config[:user] || 'postgres_exporter'
|
|
112
|
+
monitoring_password = normalize_monitoring_password(secrets.resolve('monitoring_password'))
|
|
113
|
+
|
|
114
|
+
dsn = build_exporter_dsn(monitoring_config, monitoring_user, monitoring_password)
|
|
115
|
+
override = <<~CONF
|
|
116
|
+
[Service]
|
|
117
|
+
Environment="DATA_SOURCE_NAME=#{escape_systemd_env(dsn)}"
|
|
118
|
+
Environment="PG_EXPORTER_WEB_LISTEN_ADDRESS=:#{exporter_port}"
|
|
119
|
+
CONF
|
|
120
|
+
|
|
121
|
+
ssh_executor.execute_on_host(host) do
|
|
122
|
+
execute :sudo, 'mkdir', '-p', '/etc/systemd/system/prometheus-postgres-exporter.service.d'
|
|
123
|
+
upload! StringIO.new(override), '/tmp/prometheus-postgres-exporter.override'
|
|
124
|
+
execute :sudo, 'mv', '/tmp/prometheus-postgres-exporter.override',
|
|
125
|
+
'/etc/systemd/system/prometheus-postgres-exporter.service.d/override.conf'
|
|
126
|
+
execute :sudo, 'chown', 'root:root', '/etc/systemd/system/prometheus-postgres-exporter.service.d/override.conf'
|
|
127
|
+
execute :sudo, 'chmod', '600', '/etc/systemd/system/prometheus-postgres-exporter.service.d/override.conf'
|
|
128
|
+
execute :sudo, 'systemctl', 'daemon-reload'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_exporter_dsn(monitoring_config, monitoring_user, monitoring_password)
|
|
133
|
+
host = monitoring_config[:database_host] || 'localhost'
|
|
134
|
+
port = monitoring_config[:database_port] || 5432
|
|
135
|
+
database = monitoring_config[:database] || 'postgres'
|
|
136
|
+
sslmode = config.component_enabled?(:ssl) ? 'require' : 'prefer'
|
|
137
|
+
|
|
138
|
+
encoded_password = CGI.escape(monitoring_password.to_s)
|
|
139
|
+
"postgresql://#{monitoring_user}:#{encoded_password}@#{host}:#{port}/#{database}?sslmode=#{sslmode}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def normalize_monitoring_password(raw_password)
|
|
143
|
+
password = raw_password.to_s.rstrip
|
|
144
|
+
|
|
145
|
+
raise Error, 'monitoring_password secret is missing (required for monitoring)' if password.empty?
|
|
146
|
+
|
|
147
|
+
password
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def node_exporter_enabled?
|
|
151
|
+
monitoring_config = config.component_config(:monitoring)
|
|
152
|
+
monitoring_config.fetch(:node_exporter, false) == true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def install_node_exporter(host, monitoring_config)
|
|
156
|
+
port = monitoring_config[:node_exporter_port] || 9100
|
|
157
|
+
listen_address = monitoring_config[:node_exporter_listen_address].to_s.strip
|
|
158
|
+
puts " Installing node_exporter on #{host}..."
|
|
159
|
+
|
|
160
|
+
ssh_executor.execute_on_host(host) do
|
|
161
|
+
execute :sudo, 'apt-get', 'install', '-y', '-qq', 'prometheus-node-exporter'
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
configure_node_exporter_service(host, port, listen_address)
|
|
165
|
+
|
|
166
|
+
ssh_executor.execute_on_host(host) do
|
|
167
|
+
execute :sudo, 'systemctl', 'enable', 'prometheus-node-exporter'
|
|
168
|
+
execute :sudo, 'systemctl', 'restart', 'prometheus-node-exporter'
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
puts " Node metrics available at: http://#{host}:#{port}/metrics"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def configure_node_exporter_service(host, port, listen_address)
|
|
175
|
+
listen = if listen_address.empty?
|
|
176
|
+
":#{port}"
|
|
177
|
+
else
|
|
178
|
+
"#{listen_address}:#{port}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return if listen == ':9100'
|
|
182
|
+
|
|
183
|
+
override = <<~CONF
|
|
184
|
+
[Service]
|
|
185
|
+
ExecStart=
|
|
186
|
+
ExecStart=/usr/bin/prometheus-node-exporter --web.listen-address=#{listen}
|
|
187
|
+
CONF
|
|
188
|
+
|
|
189
|
+
ssh_executor.execute_on_host(host) do
|
|
190
|
+
execute :sudo, 'mkdir', '-p', '/etc/systemd/system/prometheus-node-exporter.service.d'
|
|
191
|
+
upload! StringIO.new(override), '/tmp/prometheus-node-exporter.override'
|
|
192
|
+
execute :sudo, 'mv', '/tmp/prometheus-node-exporter.override',
|
|
193
|
+
'/etc/systemd/system/prometheus-node-exporter.service.d/override.conf'
|
|
194
|
+
execute :sudo, 'chown', 'root:root', '/etc/systemd/system/prometheus-node-exporter.service.d/override.conf'
|
|
195
|
+
execute :sudo, 'chmod', '644', '/etc/systemd/system/prometheus-node-exporter.service.d/override.conf'
|
|
196
|
+
execute :sudo, 'systemctl', 'daemon-reload'
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def install_grafana_if_enabled
|
|
201
|
+
monitoring_config = config.component_config(:monitoring)
|
|
202
|
+
grafana_config = monitoring_config[:grafana] || {}
|
|
203
|
+
return unless grafana_config[:enabled]
|
|
204
|
+
|
|
205
|
+
host = grafana_config[:host].to_s.strip
|
|
206
|
+
raise Error, 'monitoring.grafana.host is required when grafana is enabled' if host.empty?
|
|
207
|
+
|
|
208
|
+
admin_password = normalize_grafana_password(secrets.resolve('grafana_admin_password'))
|
|
209
|
+
prometheus_url = grafana_config[:prometheus_url]
|
|
210
|
+
listen_address = grafana_config[:listen_address].to_s.strip
|
|
211
|
+
port = (grafana_config[:port] || 3000).to_i
|
|
212
|
+
|
|
213
|
+
puts "Installing Grafana on #{host}..."
|
|
214
|
+
|
|
215
|
+
ssh_executor.execute_on_host(host) do
|
|
216
|
+
execute :sudo, 'apt-get', 'install', '-y', '-qq', 'apt-transport-https', 'software-properties-common', 'wget'
|
|
217
|
+
execute :sudo, 'wget', '-q', '-O', '/usr/share/keyrings/grafana.gpg', 'https://apt.grafana.com/gpg.key'
|
|
218
|
+
execute :sudo, 'sh', '-c',
|
|
219
|
+
'echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" > /etc/apt/sources.list.d/grafana.list'
|
|
220
|
+
execute :sudo, 'apt-get', 'update', '-qq'
|
|
221
|
+
execute :sudo, 'apt-get', 'install', '-y', '-qq', 'grafana'
|
|
222
|
+
execute :sudo, 'systemctl', 'enable', '--now', 'grafana-server'
|
|
223
|
+
execute :sudo, 'grafana-cli', 'admin', 'reset-admin-password', admin_password
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
configure_grafana_service(host, listen_address, port)
|
|
227
|
+
configure_grafana_datasource(host, prometheus_url) if prometheus_url
|
|
228
|
+
|
|
229
|
+
puts " Grafana available at: http://#{host}:#{port}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def configure_grafana_service(host, listen_address, port)
|
|
233
|
+
env_lines = []
|
|
234
|
+
env_lines << "Environment=\"GF_SERVER_HTTP_ADDR=#{listen_address}\"" unless listen_address.empty?
|
|
235
|
+
env_lines << "Environment=\"GF_SERVER_HTTP_PORT=#{port}\"" if port && port != 3000
|
|
236
|
+
return if env_lines.empty?
|
|
237
|
+
|
|
238
|
+
override = <<~CONF
|
|
239
|
+
[Service]
|
|
240
|
+
#{env_lines.join("\n ")}
|
|
241
|
+
CONF
|
|
242
|
+
|
|
243
|
+
ssh_executor.execute_on_host(host) do
|
|
244
|
+
execute :sudo, 'mkdir', '-p', '/etc/systemd/system/grafana-server.service.d'
|
|
245
|
+
upload! StringIO.new(override), '/tmp/active_postgres_grafana.override'
|
|
246
|
+
execute :sudo, 'mv', '/tmp/active_postgres_grafana.override',
|
|
247
|
+
'/etc/systemd/system/grafana-server.service.d/override.conf'
|
|
248
|
+
execute :sudo, 'chown', 'root:root', '/etc/systemd/system/grafana-server.service.d/override.conf'
|
|
249
|
+
execute :sudo, 'chmod', '644', '/etc/systemd/system/grafana-server.service.d/override.conf'
|
|
250
|
+
execute :sudo, 'systemctl', 'daemon-reload'
|
|
251
|
+
execute :sudo, 'systemctl', 'restart', 'grafana-server'
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def uninstall_grafana_if_enabled
|
|
256
|
+
monitoring_config = config.component_config(:monitoring)
|
|
257
|
+
grafana_config = monitoring_config[:grafana] || {}
|
|
258
|
+
return unless grafana_config[:enabled]
|
|
259
|
+
|
|
260
|
+
host = grafana_config[:host].to_s.strip
|
|
261
|
+
return if host.empty?
|
|
262
|
+
|
|
263
|
+
ssh_executor.execute_on_host(host) do
|
|
264
|
+
execute :sudo, 'systemctl', 'stop', 'grafana-server'
|
|
265
|
+
execute :sudo, 'systemctl', 'disable', 'grafana-server'
|
|
266
|
+
execute :sudo, 'apt-get', 'remove', '-y', 'grafana'
|
|
267
|
+
execute :sudo, 'rm', '-f', '/etc/apt/sources.list.d/grafana.list'
|
|
268
|
+
execute :sudo, 'rm', '-f', '/usr/share/keyrings/grafana.gpg'
|
|
269
|
+
execute :sudo, 'rm', '-rf', '/etc/grafana/provisioning/datasources/active_postgres.yml'
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def configure_grafana_datasource(host, prometheus_url)
|
|
274
|
+
datasource = <<~YAML
|
|
275
|
+
apiVersion: 1
|
|
276
|
+
datasources:
|
|
277
|
+
- name: Prometheus
|
|
278
|
+
type: prometheus
|
|
279
|
+
access: proxy
|
|
280
|
+
url: #{prometheus_url}
|
|
281
|
+
isDefault: true
|
|
282
|
+
YAML
|
|
283
|
+
|
|
284
|
+
ssh_executor.execute_on_host(host) do
|
|
285
|
+
execute :sudo, 'mkdir', '-p', '/etc/grafana/provisioning/datasources'
|
|
286
|
+
upload! StringIO.new(datasource), '/tmp/active_postgres_grafana_ds.yml'
|
|
287
|
+
execute :sudo, 'mv', '/tmp/active_postgres_grafana_ds.yml', '/etc/grafana/provisioning/datasources/active_postgres.yml'
|
|
288
|
+
execute :sudo, 'chown', 'root:root', '/etc/grafana/provisioning/datasources/active_postgres.yml'
|
|
289
|
+
execute :sudo, 'chmod', '644', '/etc/grafana/provisioning/datasources/active_postgres.yml'
|
|
290
|
+
execute :sudo, 'systemctl', 'restart', 'grafana-server'
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def normalize_grafana_password(raw_password)
|
|
295
|
+
password = raw_password.to_s.rstrip
|
|
296
|
+
|
|
297
|
+
raise Error, 'grafana_admin_password secret is missing (required for grafana)' if password.empty?
|
|
298
|
+
|
|
299
|
+
password
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def escape_systemd_env(value)
|
|
303
|
+
value.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
|
|
52
304
|
end
|
|
53
305
|
end
|
|
54
306
|
end
|