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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +142 -5
  3. data/lib/active_postgres/cli.rb +7 -0
  4. data/lib/active_postgres/components/core.rb +2 -7
  5. data/lib/active_postgres/components/extensions.rb +40 -35
  6. data/lib/active_postgres/components/monitoring.rb +256 -4
  7. data/lib/active_postgres/components/pgbackrest.rb +91 -5
  8. data/lib/active_postgres/components/pgbouncer.rb +58 -7
  9. data/lib/active_postgres/components/repmgr.rb +448 -62
  10. data/lib/active_postgres/configuration.rb +66 -1
  11. data/lib/active_postgres/connection_pooler.rb +3 -12
  12. data/lib/active_postgres/credentials.rb +3 -3
  13. data/lib/active_postgres/direct_executor.rb +1 -2
  14. data/lib/active_postgres/generators/active_postgres/install_generator.rb +2 -0
  15. data/lib/active_postgres/generators/active_postgres/templates/postgres.yml.erb +28 -1
  16. data/lib/active_postgres/health_checker.rb +29 -7
  17. data/lib/active_postgres/installer.rb +9 -0
  18. data/lib/active_postgres/overview.rb +351 -0
  19. data/lib/active_postgres/rollback_manager.rb +4 -8
  20. data/lib/active_postgres/secrets.rb +23 -5
  21. data/lib/active_postgres/ssh_executor.rb +44 -19
  22. data/lib/active_postgres/version.rb +1 -1
  23. data/lib/active_postgres.rb +1 -0
  24. data/lib/tasks/postgres.rake +77 -4
  25. data/lib/tasks/rotate_credentials.rake +4 -16
  26. data/templates/pg_hba.conf.erb +4 -1
  27. data/templates/pgbackrest.conf.erb +28 -0
  28. data/templates/pgbouncer-follow-primary.service.erb +8 -0
  29. data/templates/pgbouncer-follow-primary.timer.erb +11 -0
  30. data/templates/pgbouncer.ini.erb +2 -0
  31. data/templates/pgbouncer_follow_primary.sh.erb +34 -0
  32. data/templates/postgresql.conf.erb +4 -0
  33. data/templates/repmgr.conf.erb +10 -3
  34. data/templates/repmgr_dns_failover.sh.erb +59 -0
  35. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0266b3f7dabc2d844b30aa6410cfd7c59131a7fb77a2dea85f01f9a66c2a2443
4
- data.tar.gz: fd720306229f1ddea1d4e01a9dc3a5df3b7a60a620c38e86779e82f9698ebcbc
3
+ metadata.gz: c89909c556964abfe7dbede0550c7fa1fbd60fc332e667fce5de858114ba2146
4
+ data.tar.gz: e715719391d53f89cb394203018a5e2951f2602018ef97a417ee369d443e410c
5
5
  SHA512:
6
- metadata.gz: bb3bc17966f9ebe98a55f09f678fe2bc2e57b92615c45848c347a7568f6e3bb61d15788d6bb16008e4498a9b944a3f53241f2e2579b204d28679341b041f4978
7
- data.tar.gz: 3863cace22d1ba3aea901d312d26152de067c7ca898ddff5b654a75702a9e6b4c01f6ffbac820fed338fc5db8ce011ad39676143f81f785f66f7fec72cd72ae5
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 | Enabled by default |
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: $(rails runner "puts Rails.application.credentials.dig(:postgres, :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
@@ -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.execute_on_host(host) do
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
- puts " ✓ Created app user '#{app_user}' and database '#{app_database}'"
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
- private
36
+ def restart
37
+ puts 'Extensions do not require restart (loaded at database connection time)'
38
+ end
37
39
 
38
- def install_on_primary(extensions)
39
- host = config.primary_host
40
- version = config.version
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
- puts " Installing extensions on primary (#{host})..."
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
- unless packages_to_install.empty?
57
- execute :sudo, 'apt-get', 'update', '-qq'
58
- execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq',
59
- *packages_to_install
60
- end
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
- rescue StandardError
67
- nil
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 extensions on standby (#{host})..."
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', 'postgres_exporter'
18
- execute :sudo, 'systemctl', 'disable', 'postgres_exporter'
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', 'postgres_exporter'
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', 'start', 'prometheus-postgres-exporter'
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