active_postgres 0.9.4 → 0.9.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0966aed96fa6f906e7b048ef7804a0b9994c54f841f8dc9f00425f57f565a71
4
- data.tar.gz: 86582950fc4afa733cba7052682dbaf0f3e76275c9397559f2d3398c8f92da86
3
+ metadata.gz: 5d06925c9a64ca3f5b96402257fda9ad13f32bbd0e3ac5377032d737c71fb2ea
4
+ data.tar.gz: 19cde3ee7e86d325378661aa72350d51d1bfebf33a2e6f03a1c90d34c987a997
5
5
  SHA512:
6
- metadata.gz: 18644b19453727a1b14918f9bc7d055fdf2c8cbaab962d94acdb058be4fa6b969e72e569ea5e1a2f0848cc45433e35c47dfa69af699370cb267b64c0a61114d5
7
- data.tar.gz: '08b112f2121747a9b970809bf0c662dfbed99166edf6e3b18e88daab4fff0a65fcae13821c37fa69af218497b9ac05978d2bf6cd157c197d0f719f968088f407'
6
+ metadata.gz: 2dbcc33b6ad2ffadeeea4145bc5bc36910203002af908030893298df2005089e87fa8e5d465a425ffae11c60ee01b66f301e1ae594e3bb662d747c0c342b20cb
7
+ data.tar.gz: 2772e4a192f87c60d54ccaaeb5ea2952f7a98ce34f3daec5839e77758179f75f3f9ac09a76ab74bc929311b8b417e074e5ae156f286bbba55423c3a923b47d77
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to Active Postgres will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project follows [Semantic Versioning](https://semver.org/).
7
+
8
+ ## Unreleased
9
+
10
+ ## 0.9.6 - 2026-08-20
11
+
12
+ ### Changed
13
+
14
+ - Require Ruby 4.0 or newer and Rails 8.1 or newer for Rails integration.
15
+ - Align runtime and development dependencies with the maintained toolchain.
16
+ - Add blocking test, lint, dependency-audit, and package-build CI checks.
17
+ - Add automated Bundler and GitHub Actions dependency updates.
18
+ - Document network, credential, failover, and recovery responsibilities.
19
+
20
+ ## 0.9.5
21
+
22
+ - Current published release. See the repository history for changes included in
23
+ earlier releases.
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # active_postgres
2
2
 
3
- Production-grade PostgreSQL HA for Rails.
3
+ PostgreSQL high-availability automation for Ruby and Rails applications.
4
+
5
+ Active Postgres configures hosts you control over SSH. It is an operations tool,
6
+ not a managed database service: you remain responsible for network isolation,
7
+ capacity planning, monitoring, backups, recovery drills, and failover decisions.
4
8
 
5
9
  ## Features
6
10
 
@@ -272,11 +276,23 @@ User.all # → Replica
272
276
 
273
277
  ## Requirements
274
278
 
275
- - Ruby 3.0+
276
- - PostgreSQL 12+
279
+ - Ruby 4.0+
280
+ - A PostgreSQL version supported by the target Linux distribution
277
281
  - Ubuntu 20.04+ / Debian 11+ with systemd
278
282
  - SSH key-based authentication (hosts must be in `~/.ssh/known_hosts`)
279
- - Rails 6.0+ (optional)
283
+ - Rails 8.1+ (optional)
284
+
285
+ ## Operational safety
286
+
287
+ - Keep PostgreSQL, PgBouncer, exporters, and backup endpoints behind a private
288
+ network or narrowly scoped firewall rules.
289
+ - Test promotion, application reconnection, backup restore, and point-in-time
290
+ recovery in a disposable environment before using them in production.
291
+ - Review generated configuration and package changes before each rollout;
292
+ automatic failover cannot determine whether an application is safe to write
293
+ after a network partition.
294
+ - Store database, replication, monitoring, and backup credentials in a secret
295
+ manager. Do not commit resolved values or generated configuration.
280
296
 
281
297
  ### SSH host key verification
282
298
 
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Please do not open a public issue for a potential vulnerability. Email
6
+ [security@boringcache.com](mailto:security@boringcache.com) or use
7
+ [GitHub private vulnerability reporting](https://github.com/boringcache/active_postgres/security/advisories/new).
8
+
9
+ Include the affected version, expected impact, and enough reproduction detail
10
+ for us to investigate. Do not include database credentials, private keys,
11
+ backups, customer data, or production configuration.
12
+
13
+ ## Supported Versions
14
+
15
+ Only the latest published release is supported. Security fixes are normally
16
+ shipped in a new release rather than backported.
17
+
18
+ ## Scope
19
+
20
+ Reports about PostgreSQL authentication, replication, backup/restore, SSH host
21
+ verification, secret handling, generated SQL, or release integrity are welcome.
@@ -1,3 +1,5 @@
1
+ require 'erb'
2
+
1
3
  module ActivePostgres
2
4
  module Components
3
5
  class Base
@@ -136,7 +136,7 @@ module ActivePostgres
136
136
  end
137
137
 
138
138
  def ensure_standby_compatible(pg_config)
139
- primary_settings = get_primary_replication_settings
139
+ primary_settings = primary_replication_settings
140
140
  return pg_config if primary_settings.empty?
141
141
 
142
142
  adjustments = []
@@ -154,17 +154,24 @@ module ActivePostgres
154
154
  end
155
155
 
156
156
  if adjustments.any?
157
- puts " ⚠️ Adjusting standby settings to match primary minimum:"
157
+ puts ' ⚠️ Adjusting standby settings to match primary minimum:'
158
158
  adjustments.each { |adj| puts " #{adj}" }
159
159
  end
160
160
 
161
161
  pg_config
162
162
  end
163
163
 
164
- def get_primary_replication_settings
164
+ def primary_replication_settings
165
165
  @primary_replication_settings ||= begin
166
166
  settings = {}
167
- sql = "SELECT name || '=' || setting FROM pg_settings WHERE name IN ('max_connections', 'max_worker_processes', 'max_wal_senders', 'max_prepared_transactions', 'max_locks_per_transaction')"
167
+ setting_names = %w[
168
+ max_connections
169
+ max_worker_processes
170
+ max_wal_senders
171
+ max_prepared_transactions
172
+ max_locks_per_transaction
173
+ ]
174
+ sql = "SELECT name || '=' || setting FROM pg_settings WHERE name IN (#{setting_names.map { |name| "'#{name}'" }.join(', ')})"
168
175
  result = ssh_executor.run_sql(config.primary_host, sql)
169
176
  result.strip.split("\n").each do |line|
170
177
  name, val = line.split('=')
@@ -1,4 +1,4 @@
1
- require 'cgi'
1
+ require 'cgi/escape'
2
2
 
3
3
  module ActivePostgres
4
4
  module Components
@@ -83,7 +83,7 @@ module ActivePostgres
83
83
  sql = build_monitoring_user_sql(monitoring_user, monitoring_password)
84
84
 
85
85
  ssh_executor.run_sql(config.primary_host, sql, postgres_user: 'postgres', port: 5432, tuples_only: false,
86
- capture: false)
86
+ capture: false)
87
87
  end
88
88
 
89
89
  def build_monitoring_user_sql(user, password)
@@ -3,9 +3,9 @@ require 'shellwords'
3
3
  module ActivePostgres
4
4
  module Components
5
5
  class PgBackRest < Base
6
- LOG_ARCHIVE_CRON_FILE = '/etc/cron.d/postgres-log-archive'
7
- LOG_ARCHIVE_ENV_FILE = '/etc/active-postgres-log-archive.env'
8
- LOG_ARCHIVE_SCRIPT = '/usr/local/bin/active-postgres-archive-logs'
6
+ LOG_ARCHIVE_CRON_FILE = '/etc/cron.d/postgres-log-archive'.freeze
7
+ LOG_ARCHIVE_ENV_FILE = '/etc/active-postgres-log-archive.env'.freeze
8
+ LOG_ARCHIVE_SCRIPT = '/usr/local/bin/active-postgres-archive-logs'.freeze
9
9
 
10
10
  def install
11
11
  puts 'Installing pgBackRest for backups...'
@@ -135,9 +135,7 @@ module ActivePostgres
135
135
  execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/var/spool/pgbackrest'
136
136
 
137
137
  # Only create stanza on primary - standbys share the same backup repo
138
- if create_stanza
139
- execute :sudo, '-u', postgres_user, 'pgbackrest', '--stanza=main', 'stanza-create'
140
- end
138
+ execute :sudo, '-u', postgres_user, 'pgbackrest', '--stanza=main', 'stanza-create' if create_stanza
141
139
  end
142
140
 
143
141
  if log_archive_enabled?(pgbackrest_config)
@@ -167,12 +165,8 @@ module ActivePostgres
167
165
  schedule_incremental = pgbackrest_config[:schedule_incremental]
168
166
 
169
167
  schedules = []
170
- if schedule_full
171
- schedules << { type: 'full', schedule: schedule_full, file: '/etc/cron.d/pgbackrest-backup' }
172
- end
173
- if schedule_incremental
174
- schedules << { type: 'incremental', schedule: schedule_incremental, file: '/etc/cron.d/pgbackrest-backup-incremental' }
175
- end
168
+ schedules << { type: 'full', schedule: schedule_full, file: '/etc/cron.d/pgbackrest-backup' } if schedule_full
169
+ schedules << { type: 'incr', schedule: schedule_incremental, file: '/etc/cron.d/pgbackrest-backup-incremental' } if schedule_incremental
176
170
 
177
171
  schedules
178
172
  end
@@ -204,15 +198,13 @@ module ActivePostgres
204
198
 
205
199
  def setup_log_archive(host, pgbackrest_config)
206
200
  log_archive_config = pgbackrest_config[:log_archive] || {}
207
- unless pgbackrest_config[:repo_type] == 's3'
208
- raise Error, 'pgbackrest.log_archive requires pgbackrest.repo_type: s3'
209
- end
201
+ raise Error, 'pgbackrest.log_archive requires pgbackrest.repo_type: s3' unless pgbackrest_config[:repo_type] == 's3'
210
202
 
211
203
  postgres_user = config.postgres_user
212
204
  env_content = log_archive_env(pgbackrest_config, log_archive_config, host)
213
205
  cron_content = log_archive_cron(log_archive_config)
214
206
 
215
- install_apt_packages(host, 'awscli')
207
+ install_apt_packages(host, 's3cmd')
216
208
 
217
209
  ssh_executor.upload_file(host, env_content, LOG_ARCHIVE_ENV_FILE, mode: '640', owner: "root:#{postgres_user}")
218
210
 
@@ -260,13 +252,13 @@ module ActivePostgres
260
252
  'POSTGRES_LOG_ARCHIVE_NODE' => config.node_label_for(host) || host
261
253
  }.compact
262
254
 
263
- env.map { |key, value| "#{key}=#{Shellwords.escape(value.to_s)}" }.join("\n") + "\n"
255
+ "#{env.map { |key, value| "#{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")}\n"
264
256
  end
265
257
 
266
258
  def s3_endpoint_url(pgbackrest_config)
267
259
  endpoint = pgbackrest_config[:s3_endpoint]
268
260
  return nil if endpoint.to_s.empty?
269
- return endpoint if endpoint.match?(/\Ahttps?:\/\//)
261
+ return endpoint if endpoint.match?(%r{\Ahttps?://})
270
262
 
271
263
  "https://#{endpoint}"
272
264
  end
@@ -8,12 +8,16 @@ module ActivePostgres
8
8
  is_standby = config.standby_hosts.include?(host)
9
9
  install_on_host(host, is_standby: is_standby)
10
10
  end
11
+
12
+ pgbouncer_app_hosts.each do |app_host|
13
+ install_on_app_host(app_host)
14
+ end
11
15
  end
12
16
 
13
17
  def uninstall
14
18
  puts 'Uninstalling PgBouncer...'
15
19
 
16
- config.all_hosts.each do |host|
20
+ (config.all_hosts + pgbouncer_app_hosts.map { |app_host| app_host.fetch('host') }).each do |host|
17
21
  ssh_executor.execute_on_host(host) do
18
22
  execute :sudo, 'systemctl', 'disable', '--now', 'pgbouncer-follow-primary.timer', '||', 'true'
19
23
  execute :sudo, 'rm', '-f', '/usr/local/bin/pgbouncer-follow-primary',
@@ -29,7 +33,7 @@ module ActivePostgres
29
33
  def restart
30
34
  puts 'Restarting PgBouncer...'
31
35
 
32
- config.all_hosts.each do |host|
36
+ (config.all_hosts + pgbouncer_app_hosts.map { |app_host| app_host.fetch('host') }).each do |host|
33
37
  ssh_executor.execute_on_host(host) do
34
38
  execute :sudo, 'systemctl', 'restart', 'pgbouncer'
35
39
  end
@@ -46,6 +50,15 @@ module ActivePostgres
46
50
  execute :sudo, 'systemctl', 'reload', 'pgbouncer'
47
51
  end
48
52
  end
53
+
54
+ pgbouncer_app_hosts.each do |app_host|
55
+ host = app_host.fetch('host')
56
+ create_userlist_from_primary(host)
57
+
58
+ ssh_executor.execute_on_host(host) do
59
+ execute :sudo, 'systemctl', 'reload', 'pgbouncer'
60
+ end
61
+ end
49
62
  end
50
63
 
51
64
  def install_on_standby(standby_host)
@@ -55,6 +68,12 @@ module ActivePostgres
55
68
 
56
69
  private
57
70
 
71
+ def pgbouncer_app_hosts
72
+ return config.pgbouncer_app_hosts if config.respond_to?(:pgbouncer_app_hosts)
73
+
74
+ []
75
+ end
76
+
58
77
  def install_on_host(host, is_standby: false)
59
78
  puts " Installing PgBouncer on #{host}..."
60
79
 
@@ -62,9 +81,7 @@ module ActivePostgres
62
81
 
63
82
  follow_primary = follow_primary_for?(host, is_standby: is_standby, user_config: user_config)
64
83
 
65
- if follow_primary && !config.component_enabled?(:repmgr)
66
- raise Error, 'PgBouncer follow_primary requires repmgr to be enabled'
67
- end
84
+ raise Error, 'PgBouncer follow_primary requires repmgr to be enabled' if follow_primary && !config.component_enabled?(:repmgr)
68
85
 
69
86
  max_connections = get_postgres_max_connections(host)
70
87
  optimal_pool = ConnectionPooler.calculate_optimal_pool_sizes(max_connections)
@@ -106,6 +123,45 @@ module ActivePostgres
106
123
  install_follow_primary(host, pgbouncer_config) if follow_primary
107
124
  end
108
125
 
126
+ def install_on_app_host(app_host)
127
+ host = app_host.fetch('host')
128
+ puts " Installing PgBouncer on app host #{host}..."
129
+
130
+ user_config = config.component_config(:pgbouncer)
131
+ max_connections = get_postgres_max_connections(config.primary_host)
132
+ optimal_pool = ConnectionPooler.calculate_optimal_pool_sizes(max_connections)
133
+
134
+ pgbouncer_config = optimal_pool.merge(user_config)
135
+ pgbouncer_config[:database_host] = app_host_value(app_host, :database_host) || pgbouncer_primary_record
136
+ pgbouncer_config[:database_port] = app_host_value(app_host, :database_port) || pgbouncer_config[:database_port] || 5432
137
+ pgbouncer_config[:listen_addr] = app_host_value(app_host, :listen_addr) || pgbouncer_config[:app_listen_addr] || '127.0.0.1'
138
+ pgbouncer_config[:listen_port] = app_host_value(app_host, :listen_port) || pgbouncer_config[:app_listen_port] ||
139
+ pgbouncer_config[:listen_port] || 6432
140
+ ssl_enabled = config.component_enabled?(:ssl)
141
+ has_ca_cert = ssl_enabled && secrets.resolve('ssl_chain')
142
+ secrets_obj = secrets
143
+ _ = pgbouncer_config
144
+ _ = has_ca_cert
145
+ _ = secrets_obj
146
+
147
+ puts " Calculated app pool settings for max_connections=#{max_connections}"
148
+
149
+ install_apt_packages(host, 'pgbouncer', 'postgresql-client')
150
+
151
+ upload_template(host, 'pgbouncer.ini.erb', '/etc/pgbouncer/pgbouncer.ini', binding, mode: '644')
152
+
153
+ setup_app_ssl_certs(host) if ssl_enabled
154
+
155
+ create_userlist_from_primary(host)
156
+
157
+ ssh_executor.execute_on_host(host) do
158
+ execute :sudo, 'systemctl', 'enable', 'pgbouncer'
159
+ execute :sudo, 'systemctl', 'restart', 'pgbouncer'
160
+ end
161
+
162
+ install_follow_dns_primary(host, pgbouncer_config)
163
+ end
164
+
109
165
  def follow_primary_for?(host, is_standby:, user_config:)
110
166
  if is_standby
111
167
  standby_config = config.standby_config_for(host) || {}
@@ -130,9 +186,33 @@ module ActivePostgres
130
186
  end
131
187
 
132
188
  ssl_chain = secrets.resolve('ssl_chain')
133
- if ssl_chain
134
- ssh_executor.upload_file(host, ssl_chain, '/etc/pgbouncer/ca.crt', mode: '644', owner: 'postgres:postgres')
189
+ return unless ssl_chain
190
+
191
+ ssh_executor.upload_file(host, ssl_chain, '/etc/pgbouncer/ca.crt', mode: '644', owner: 'postgres:postgres')
192
+ end
193
+
194
+ def setup_app_ssl_certs(host)
195
+ puts ' Setting up SSL certificates for app PgBouncer...'
196
+
197
+ ssl_cert = secrets.resolve('ssl_cert')
198
+ ssl_key = secrets.resolve('ssl_key')
199
+ ssl_chain = secrets.resolve('ssl_chain')
200
+
201
+ raise Error, 'PgBouncer app_hosts require ssl_cert and ssl_key secrets when ssl is enabled' unless ssl_cert && ssl_key
202
+
203
+ full_cert = if ssl_chain
204
+ "#{ssl_cert.strip}\n#{ssl_chain.strip}\n"
205
+ else
206
+ ssl_cert
207
+ end
208
+
209
+ ssh_executor.execute_on_host(host) do
210
+ execute :sudo, 'mkdir', '-p', '/etc/pgbouncer'
135
211
  end
212
+
213
+ ssh_executor.upload_file(host, full_cert, '/etc/pgbouncer/server.crt', mode: '644', owner: 'postgres:postgres')
214
+ ssh_executor.upload_file(host, ssl_key, '/etc/pgbouncer/server.key', mode: '600', owner: 'postgres:postgres')
215
+ ssh_executor.upload_file(host, ssl_chain, '/etc/pgbouncer/ca.crt', mode: '644', owner: 'postgres:postgres') if ssl_chain
136
216
  end
137
217
 
138
218
  def get_postgres_max_connections(host)
@@ -154,18 +234,43 @@ module ActivePostgres
154
234
  def create_userlist(host)
155
235
  puts ' Creating PgBouncer userlist with database users...'
156
236
 
157
- postgres_user = config.postgres_user
158
- app_user = config.app_user
159
- users_to_add = [postgres_user, (app_user if app_user != postgres_user)].compact
160
- pgbouncer = self
237
+ component = self
161
238
 
162
239
  ssh_executor.execute_on_host(host) do
163
- backend = self
164
- userlist_entries = users_to_add.filter_map { |user| pgbouncer.send(:fetch_user_hash, backend, user, postgres_user) }
165
- pgbouncer.send(:write_userlist_file, backend, userlist_entries)
240
+ userlist_entries = component.send(:userlist_entries_from_backend, self)
241
+ component.send(:write_userlist_file, self, userlist_entries)
166
242
  end
167
243
  end
168
244
 
245
+ def create_userlist_from_primary(host)
246
+ puts ' Creating app PgBouncer userlist from primary database users...'
247
+
248
+ userlist_entries = nil
249
+ component = self
250
+
251
+ ssh_executor.execute_on_host(config.primary_host) do
252
+ userlist_entries = component.send(:userlist_entries_from_backend, self)
253
+ end
254
+
255
+ ssh_executor.execute_on_host(host) do
256
+ component.send(:write_userlist_file, self, userlist_entries || [])
257
+ end
258
+ end
259
+
260
+ def userlist_entries_from_backend(backend)
261
+ postgres_user = config.postgres_user
262
+ users_to_add = pgbouncer_database_users
263
+
264
+ users_to_add.filter_map { |user| fetch_user_hash(backend, user, postgres_user) }
265
+ end
266
+
267
+ def pgbouncer_database_users
268
+ postgres_user = config.postgres_user
269
+ app_user = config.app_user
270
+
271
+ [postgres_user, (app_user if app_user != postgres_user)].compact
272
+ end
273
+
169
274
  def fetch_user_hash(backend, user, postgres_user)
170
275
  sql = build_user_hash_sql(user)
171
276
 
@@ -241,6 +346,40 @@ module ActivePostgres
241
346
  execute :sudo, 'systemctl', 'start', 'pgbouncer-follow-primary.service'
242
347
  end
243
348
  end
349
+
350
+ def install_follow_dns_primary(host, pgbouncer_config)
351
+ interval = pgbouncer_config[:follow_primary_interval] || 5
352
+ interval = interval.to_i
353
+ interval = 5 if interval <= 0
354
+ primary_record = pgbouncer_config[:database_host]
355
+ _ = interval
356
+ _ = primary_record
357
+ _ = pgbouncer_config
358
+
359
+ upload_template(host, 'pgbouncer_follow_dns_primary.sh.erb', '/usr/local/bin/pgbouncer-follow-primary',
360
+ binding, mode: '755', owner: 'root:root')
361
+ upload_template(host, 'pgbouncer-follow-primary.service.erb',
362
+ '/etc/systemd/system/pgbouncer-follow-primary.service', binding, mode: '644', owner: 'root:root')
363
+ upload_template(host, 'pgbouncer-follow-primary.timer.erb',
364
+ '/etc/systemd/system/pgbouncer-follow-primary.timer', binding, mode: '644', owner: 'root:root')
365
+
366
+ ssh_executor.execute_on_host(host) do
367
+ execute :sudo, 'systemctl', 'daemon-reload'
368
+ execute :sudo, 'systemctl', 'enable', '--now', 'pgbouncer-follow-primary.timer'
369
+ execute :sudo, 'systemctl', 'start', 'pgbouncer-follow-primary.service'
370
+ end
371
+ end
372
+
373
+ def pgbouncer_primary_record
374
+ return config.pgbouncer_primary_record if config.respond_to?(:pgbouncer_primary_record)
375
+
376
+ dns_failover = config.component_config(:repmgr)[:dns_failover] || {}
377
+ dns_failover[:primary_record] || config.primary_replication_host
378
+ end
379
+
380
+ def app_host_value(app_host, key)
381
+ app_host[key.to_s] || app_host[key]
382
+ end
244
383
  end
245
384
  end
246
385
  end