active_postgres 0.4.0

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +23 -0
  3. data/README.md +158 -0
  4. data/exe/activepostgres +5 -0
  5. data/lib/active_postgres/cli.rb +157 -0
  6. data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
  7. data/lib/active_postgres/component_resolver.rb +24 -0
  8. data/lib/active_postgres/components/base.rb +38 -0
  9. data/lib/active_postgres/components/core.rb +158 -0
  10. data/lib/active_postgres/components/extensions.rb +99 -0
  11. data/lib/active_postgres/components/monitoring.rb +55 -0
  12. data/lib/active_postgres/components/pgbackrest.rb +94 -0
  13. data/lib/active_postgres/components/pgbouncer.rb +137 -0
  14. data/lib/active_postgres/components/repmgr.rb +651 -0
  15. data/lib/active_postgres/components/ssl.rb +86 -0
  16. data/lib/active_postgres/configuration.rb +190 -0
  17. data/lib/active_postgres/connection_pooler.rb +429 -0
  18. data/lib/active_postgres/credentials.rb +17 -0
  19. data/lib/active_postgres/deployment_flow.rb +154 -0
  20. data/lib/active_postgres/error_handler.rb +185 -0
  21. data/lib/active_postgres/failover.rb +83 -0
  22. data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
  23. data/lib/active_postgres/health_checker.rb +244 -0
  24. data/lib/active_postgres/installer.rb +114 -0
  25. data/lib/active_postgres/log_sanitizer.rb +67 -0
  26. data/lib/active_postgres/logger.rb +125 -0
  27. data/lib/active_postgres/performance_tuner.rb +246 -0
  28. data/lib/active_postgres/rails/database_config.rb +174 -0
  29. data/lib/active_postgres/rails/migration_guard.rb +25 -0
  30. data/lib/active_postgres/railtie.rb +28 -0
  31. data/lib/active_postgres/retry_helper.rb +80 -0
  32. data/lib/active_postgres/rollback_manager.rb +140 -0
  33. data/lib/active_postgres/secrets.rb +86 -0
  34. data/lib/active_postgres/ssh_executor.rb +288 -0
  35. data/lib/active_postgres/standby_deployment_flow.rb +122 -0
  36. data/lib/active_postgres/validator.rb +143 -0
  37. data/lib/active_postgres/version.rb +3 -0
  38. data/lib/active_postgres.rb +67 -0
  39. data/lib/tasks/postgres.rake +855 -0
  40. data/lib/tasks/rolling_update.rake +258 -0
  41. data/lib/tasks/rotate_credentials.rake +193 -0
  42. data/templates/pg_hba.conf.erb +47 -0
  43. data/templates/pgbackrest.conf.erb +43 -0
  44. data/templates/pgbouncer.ini.erb +55 -0
  45. data/templates/postgresql.conf.erb +157 -0
  46. data/templates/repmgr.conf.erb +40 -0
  47. metadata +224 -0
@@ -0,0 +1,86 @@
1
+ module ActivePostgres
2
+ module Components
3
+ class SSL < Base
4
+ def install
5
+ puts 'Installing SSL/TLS encryption...'
6
+
7
+ config.all_hosts.each do |host|
8
+ install_on_host(host)
9
+ end
10
+ end
11
+
12
+ def uninstall
13
+ puts 'SSL certificates remain (harmless to leave configured)'
14
+ end
15
+
16
+ def restart
17
+ # SSL doesn't have its own service, restart PostgreSQL
18
+ ssh_executor.restart_postgres(config.primary_host)
19
+
20
+ config.standby_hosts.each do |host|
21
+ ssh_executor.restart_postgres(host)
22
+ end
23
+ end
24
+
25
+ def install_on_standby(standby_host)
26
+ puts "Installing SSL on standby #{standby_host}..."
27
+ install_on_host(standby_host)
28
+ end
29
+
30
+ private
31
+
32
+ def install_on_host(host)
33
+ puts " Installing SSL on #{host}..."
34
+
35
+ version = config.version
36
+ ssl_config = config.component_config(:ssl)
37
+
38
+ ssh_executor.ensure_postgres_user(host)
39
+
40
+ # Ensure the PostgreSQL config directory exists
41
+ ssh_executor.execute_on_host(host) do
42
+ execute :sudo, 'mkdir', '-p', "/etc/postgresql/#{version}/main"
43
+ execute :sudo, 'chown', 'postgres:postgres', "/etc/postgresql/#{version}/main"
44
+ end
45
+
46
+ ssl_cert = secrets.resolve('ssl_cert')
47
+ ssl_key = secrets.resolve('ssl_key')
48
+
49
+ if ssl_cert && ssl_key
50
+ puts ' Using SSL certificates from secrets...'
51
+ ssh_executor.upload_file(host, ssl_cert, "/etc/postgresql/#{version}/main/server.crt", mode: '644',
52
+ owner: 'postgres:postgres')
53
+ ssh_executor.upload_file(host, ssl_key, "/etc/postgresql/#{version}/main/server.key", mode: '600',
54
+ owner: 'postgres:postgres')
55
+ else
56
+ puts ' Generating self-signed SSL certificates...'
57
+ generate_self_signed_cert(host, ssl_config)
58
+ end
59
+ end
60
+
61
+ def generate_self_signed_cert(host, ssl_config)
62
+ version = config.version
63
+ cert_path = "/etc/postgresql/#{version}/main/server.crt"
64
+ key_path = "/etc/postgresql/#{version}/main/server.key"
65
+
66
+ ssh_executor.execute_on_host(host) do
67
+ days = ssl_config[:cert_days] || 3650
68
+ cn = ssl_config[:common_name] || host
69
+
70
+ info "Generating self-signed certificate (CN=#{cn}, valid for #{days} days)..."
71
+
72
+ execute :sudo, 'openssl', 'req', '-new', '-x509', '-days', days.to_s,
73
+ '-nodes', '-text',
74
+ '-out', cert_path,
75
+ '-keyout', key_path,
76
+ '-subj', "/CN=#{cn}"
77
+
78
+ execute :sudo, 'chown', 'postgres:postgres', cert_path
79
+ execute :sudo, 'chown', 'postgres:postgres', key_path
80
+ execute :sudo, 'chmod', '644', cert_path
81
+ execute :sudo, 'chmod', '600', key_path
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,190 @@
1
+ require 'yaml'
2
+
3
+ module ActivePostgres
4
+ class Configuration
5
+ attr_reader :environment, :version, :user, :ssh_key, :primary, :standbys, :components, :secrets_config, :database_config
6
+
7
+ def initialize(config_hash, environment = 'development')
8
+ @environment = environment
9
+ env_config = config_hash[environment] || {}
10
+
11
+ # Check if deployment should be skipped (e.g., for development)
12
+ @skip_deployment = env_config['skip_deployment'] == true
13
+
14
+ @version = env_config['version'] || 18
15
+ @user = env_config['user'] || 'ubuntu'
16
+ @ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
17
+
18
+ @primary = env_config['primary'] || {}
19
+ @standbys = env_config['standby'] || []
20
+ @standbys = [@standbys] unless @standbys.is_a?(Array)
21
+
22
+ @components = parse_components(env_config['components'] || {})
23
+ @secrets_config = env_config['secrets'] || {}
24
+ end
25
+
26
+ def self.load(config_path = 'config/postgres.yml', environment = nil)
27
+ environment ||= ENV['BORING_ENVIRONMENT'] || ENV['RAILS_ENV'] || 'development'
28
+
29
+ raise Error, "Config file not found: #{config_path}" unless File.exist?(config_path)
30
+
31
+ config_hash = YAML.load_file(config_path, aliases: true)
32
+ new(config_hash, environment)
33
+ end
34
+
35
+ def all_hosts
36
+ [primary_host] + standby_hosts
37
+ end
38
+
39
+ def primary_host
40
+ @primary['host']
41
+ end
42
+
43
+ def standby_hosts
44
+ @standbys.map { |s| s['host'] }
45
+ end
46
+
47
+ def component_enabled?(name)
48
+ @components[name]&.[](:enabled) == true
49
+ end
50
+
51
+ def component_config(name)
52
+ @components[name] || {}
53
+ end
54
+
55
+ def skip_deployment?
56
+ @skip_deployment
57
+ end
58
+
59
+ def primary_replication_host
60
+ replication_host_for(primary_host)
61
+ end
62
+
63
+ def replication_host_for(host)
64
+ node = node_config_for(host)
65
+ private_ip_for(node) || host
66
+ end
67
+
68
+ def standby_config_for(host)
69
+ @standbys.find { |s| s['host'] == host }
70
+ end
71
+
72
+ def node_label_for(host)
73
+ if host == primary_host
74
+ @primary['label']
75
+ else
76
+ standby_config_for(host)&.dig('label')
77
+ end
78
+ end
79
+
80
+ def validate!
81
+ raise Error, 'No primary host defined' unless primary_host
82
+
83
+ # Validate required secrets if components are enabled
84
+ raise Error, 'Missing replication_password secret' if component_enabled?(:repmgr) && !secrets_config['replication_password']
85
+
86
+ true
87
+ end
88
+
89
+ # Database and user configuration helpers from components
90
+ def postgres_user
91
+ component_config(:core)[:postgres_user] || 'postgres'
92
+ end
93
+
94
+ def repmgr_user
95
+ component_config(:repmgr)[:user] || 'repmgr'
96
+ end
97
+
98
+ def repmgr_database
99
+ component_config(:repmgr)[:database] || 'repmgr'
100
+ end
101
+
102
+ def pgbouncer_user
103
+ component_config(:pgbouncer)[:user] || 'pgbouncer'
104
+ end
105
+
106
+ def app_user
107
+ value = component_config(:core)[:app_user]
108
+ value.nil? || value.to_s.strip.empty? ? 'app' : value
109
+ end
110
+
111
+ def app_database
112
+ value = component_config(:core)[:app_database]
113
+ value.nil? || value.to_s.strip.empty? ? "app_#{environment}" : value
114
+ end
115
+
116
+ private
117
+
118
+ def parse_components(components_config)
119
+ result = {}
120
+
121
+ # Core is always enabled - include ALL core config, not just specific fields
122
+ core_config = components_config['core'] || {}
123
+ result[:core] = {
124
+ enabled: true,
125
+ version: @version,
126
+ locale: core_config['locale'] || 'en_US.UTF-8',
127
+ encoding: core_config['encoding'] || 'UTF8',
128
+ data_checksums: core_config['data_checksums'] != false,
129
+ app_user: core_config['app_user'],
130
+ app_database: core_config['app_database']
131
+ }
132
+
133
+ # Include pg_hba and postgresql config if present
134
+ result[:core][:pg_hba] = symbolize_keys_deep(core_config['pg_hba']) if core_config['pg_hba']
135
+ result[:core][:postgresql] = symbolize_keys(core_config['postgresql']) if core_config['postgresql']
136
+
137
+ # Parse each component
138
+ %i[repmgr pgbouncer pgbackrest monitoring ssl extensions].each do |component|
139
+ component_str = component.to_s
140
+ result[component] = if components_config[component_str]
141
+ symbolize_keys(components_config[component_str])
142
+ else
143
+ { enabled: false }
144
+ end
145
+ end
146
+
147
+ # Performance tuning must be explicitly enabled
148
+ result[:performance_tuning] = if components_config['performance_tuning']
149
+ symbolize_keys(components_config['performance_tuning'])
150
+ else
151
+ { enabled: false }
152
+ end
153
+
154
+ result
155
+ end
156
+
157
+ def node_config_for(host)
158
+ return @primary if host == primary_host
159
+
160
+ standby_config_for(host)
161
+ end
162
+
163
+ def private_ip_for(node_config)
164
+ return unless node_config
165
+
166
+ node_config['private_ip'] || node_config['host']
167
+ end
168
+
169
+ def symbolize_keys(hash)
170
+ hash.each_with_object({}) do |(key, value), result|
171
+ new_key = key.to_sym
172
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
173
+ result[new_key] = new_value
174
+ end
175
+ end
176
+
177
+ def symbolize_keys_deep(value)
178
+ case value
179
+ when Hash
180
+ value.each_with_object({}) do |(k, v), result|
181
+ result[k.to_sym] = symbolize_keys_deep(v)
182
+ end
183
+ when Array
184
+ value.map { |v| symbolize_keys_deep(v) }
185
+ else
186
+ value
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,429 @@
1
+ module ActivePostgres
2
+ # Production-ready connection pooling configuration for PgBouncer
3
+ class ConnectionPooler
4
+ attr_reader :config, :ssh_executor, :logger
5
+
6
+ def initialize(config, ssh_executor, logger = Logger.new)
7
+ @config = config
8
+ @ssh_executor = ssh_executor
9
+ @logger = logger
10
+ end
11
+
12
+ def setup_on_host(host)
13
+ @logger.info "Configuring optimized connection pooling on #{host}..."
14
+
15
+ # Analyze PostgreSQL configuration to determine optimal pool settings
16
+ pg_settings = get_postgresql_settings(host)
17
+ pool_config = calculate_pool_settings(pg_settings)
18
+
19
+ # Install PgBouncer
20
+ install_pgbouncer(host)
21
+
22
+ # Deploy optimized configuration
23
+ deploy_pgbouncer_config(host, pool_config)
24
+
25
+ # Setup authentication
26
+ setup_authentication(host)
27
+
28
+ # Enable and start PgBouncer
29
+ enable_pgbouncer(host)
30
+
31
+ # Verify the setup
32
+ verify_pgbouncer(host)
33
+ end
34
+
35
+ # Calculate optimal pool settings based on PostgreSQL max_connections
36
+ # This is a simpler method used by the PgBouncer component for template integration
37
+ def self.calculate_optimal_pool_sizes(max_connections)
38
+ {
39
+ default_pool_size: calculate_default_pool_size_static(max_connections),
40
+ min_pool_size: 5,
41
+ reserve_pool_size: 5,
42
+ max_client_conn: max_connections * 10,
43
+ max_db_connections: [max_connections - 10, 10].max,
44
+ max_user_connections: [max_connections - 10, 10].max
45
+ }
46
+ end
47
+
48
+ def self.calculate_default_pool_size_static(max_connections)
49
+ # Formula: Reserve 80% of PostgreSQL connections for the pool
50
+ # Split among expected number of databases/users
51
+ pool_per_db = (max_connections * 0.8 / 4).to_i # Assume 4 databases
52
+
53
+ # Reasonable bounds: 20-100 per pool
54
+ pool_per_db.clamp(20, 100)
55
+ end
56
+
57
+ private
58
+
59
+ def get_postgresql_settings(host)
60
+ settings = {}
61
+ postgres_user = config.postgres_user
62
+
63
+ @ssh_executor.execute_on_host(host) do
64
+ # Get max_connections from PostgreSQL
65
+ max_conn = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-c',
66
+ "'SHOW max_connections;'").strip.to_i
67
+ settings[:max_connections] = max_conn
68
+
69
+ # Get default pool mode preference
70
+ settings[:default_pool_mode] = 'transaction' # Best for web apps
71
+
72
+ # Get work_mem
73
+ work_mem = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-c',
74
+ "'SHOW work_mem;'").strip
75
+ settings[:work_mem] = work_mem
76
+ end
77
+
78
+ settings
79
+ end
80
+
81
+ def calculate_pool_settings(pg_settings)
82
+ max_connections = pg_settings[:max_connections]
83
+ postgres_user = config.postgres_user
84
+ pgbouncer_user = config.pgbouncer_user
85
+
86
+ # Production-optimized PgBouncer settings
87
+ {
88
+ # Connection pool settings
89
+ default_pool_size: calculate_default_pool_size(max_connections),
90
+ min_pool_size: 5,
91
+ reserve_pool_size: 5,
92
+ reserve_pool_timeout: 5,
93
+ max_client_conn: max_connections * 10, # Allow many client connections
94
+ max_db_connections: max_connections - 10, # Leave some for superuser
95
+ max_user_connections: max_connections - 10,
96
+
97
+ # Performance settings
98
+ pool_mode: pg_settings[:default_pool_mode],
99
+ server_reset_query: 'DISCARD ALL',
100
+ server_reset_query_always: 0,
101
+ ignore_startup_parameters: 'extra_float_digits,options',
102
+ disable_pqexec: 0,
103
+ application_name_add_host: 1,
104
+ conffile: '/etc/pgbouncer/pgbouncer.ini',
105
+ pidfile: '/var/run/pgbouncer/pgbouncer.pid',
106
+
107
+ # Connection behavior
108
+ server_lifetime: 3600,
109
+ server_idle_timeout: 600,
110
+ server_connect_timeout: 15,
111
+ server_login_retry: 15,
112
+ query_timeout: 0,
113
+ query_wait_timeout: 120,
114
+ client_idle_timeout: 0,
115
+ client_login_timeout: 60,
116
+ idle_transaction_timeout: 0,
117
+
118
+ # TLS/SSL settings
119
+ server_tls_sslmode: 'prefer',
120
+ server_tls_ca_file: '',
121
+ server_tls_key_file: '',
122
+ server_tls_cert_file: '',
123
+ server_tls_protocols: 'all',
124
+ server_tls_ciphers: 'fast',
125
+ client_tls_sslmode: 'prefer',
126
+ client_tls_ca_file: '',
127
+ client_tls_key_file: '',
128
+ client_tls_cert_file: '',
129
+ client_tls_protocols: 'all',
130
+ client_tls_ciphers: 'fast',
131
+ client_tls_ecdhcurve: 'auto',
132
+ client_tls_dheparams: 'auto',
133
+
134
+ # Authentication
135
+ auth_type: 'scram-sha-256',
136
+ auth_file: '/etc/pgbouncer/userlist.txt',
137
+ auth_hba_file: '',
138
+ auth_query: 'SELECT usename, passwd FROM pg_shadow WHERE usename=$1',
139
+ auth_user: pgbouncer_user,
140
+
141
+ # Connection sanity checks
142
+ server_check_delay: 30,
143
+ server_check_query: 'select 1',
144
+ server_fast_close: 0,
145
+ server_round_robin: 0,
146
+
147
+ # Logging
148
+ log_connections: 1,
149
+ log_disconnections: 1,
150
+ log_pooler_errors: 1,
151
+ log_stats: 1,
152
+ stats_period: 60,
153
+ verbose: 0,
154
+ admin_users: "#{postgres_user},#{pgbouncer_user}",
155
+ stats_users: "#{postgres_user},#{pgbouncer_user},stats_collector",
156
+
157
+ # Network settings
158
+ listen_addr: '*',
159
+ listen_port: 6432,
160
+ unix_socket_dir: '/var/run/pgbouncer',
161
+ unix_socket_mode: '0777',
162
+ unix_socket_group: '',
163
+
164
+ # Limits
165
+ tcp_keepalive: 1,
166
+ tcp_keepcnt: 9,
167
+ tcp_keepidle: 900,
168
+ tcp_keepintvl: 75,
169
+ tcp_user_timeout: 0,
170
+
171
+ # DNS
172
+ dns_max_ttl: 15,
173
+ dns_nxdomain_ttl: 15,
174
+ dns_zone_check_period: 0,
175
+ resolv_conf: '',
176
+
177
+ # Timeouts and limits
178
+ sbuf_loopcnt: 5,
179
+ max_packet_size: 2_147_483_647,
180
+ listen_backlog: 128,
181
+ so_reuseport: 0,
182
+ tcp_defer_accept: 0,
183
+ tcp_socket_buffer: 0,
184
+
185
+ # Process management
186
+ logfile: '/var/log/pgbouncer/pgbouncer.log',
187
+ syslog: 0,
188
+ syslog_ident: 'pgbouncer',
189
+ syslog_facility: 'daemon',
190
+ user: postgres_user,
191
+
192
+ # Track prepared statements per database
193
+ track_extra_parameters: 'IntervalStyle',
194
+
195
+ # Connection limits per user/database
196
+ databases: generate_database_config
197
+ }
198
+ end
199
+
200
+ def calculate_default_pool_size(max_connections)
201
+ # Formula: Reserve 80% of PostgreSQL connections for the pool
202
+ # Split among expected number of databases/users
203
+ pool_per_db = (max_connections * 0.8 / 4).to_i # Assume 4 databases
204
+
205
+ # Reasonable bounds: 20-100 per pool
206
+ pool_per_db.clamp(20, 100)
207
+ end
208
+
209
+ def generate_database_config
210
+ databases = {}
211
+
212
+ # Generate database connection strings
213
+ if @config.primary_host
214
+ databases['*'] = {
215
+ host: @config.primary_host,
216
+ port: 5432,
217
+ auth_user: 'pgbouncer'
218
+ }
219
+ end
220
+
221
+ # Add specific database configurations if needed
222
+ app_databases = @config.component_config(:pgbouncer)[:databases] || []
223
+ app_databases.each do |db_config|
224
+ databases[db_config[:name]] = {
225
+ host: db_config[:host] || @config.primary_host,
226
+ port: db_config[:port] || 5432,
227
+ dbname: db_config[:dbname] || db_config[:name],
228
+ auth_user: db_config[:auth_user] || 'pgbouncer',
229
+ pool_size: db_config[:pool_size] || 25,
230
+ reserve_pool: db_config[:reserve_pool] || 5,
231
+ pool_mode: db_config[:pool_mode] || 'transaction'
232
+ }
233
+ end
234
+
235
+ databases
236
+ end
237
+
238
+ def install_pgbouncer(host)
239
+ postgres_user = config.postgres_user
240
+
241
+ @ssh_executor.execute_on_host(host) do
242
+ # Install PgBouncer package
243
+ execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install',
244
+ '-y', '-qq', 'pgbouncer'
245
+
246
+ # Create required directories
247
+ execute :sudo, 'mkdir', '-p', '/var/log/pgbouncer'
248
+ execute :sudo, 'mkdir', '-p', '/var/run/pgbouncer'
249
+ execute :sudo, 'chown', '-R', "#{postgres_user}:#{postgres_user}", '/var/log/pgbouncer'
250
+ execute :sudo, 'chown', '-R', "#{postgres_user}:#{postgres_user}", '/var/run/pgbouncer'
251
+
252
+ # Stop default PgBouncer if running
253
+ begin
254
+ execute :sudo, 'systemctl', 'stop', 'pgbouncer'
255
+ rescue StandardError
256
+ nil
257
+ end
258
+ end
259
+ end
260
+
261
+ def deploy_pgbouncer_config(host, pool_config)
262
+ # Generate PgBouncer configuration
263
+ ini_content = generate_pgbouncer_ini(pool_config)
264
+ postgres_user = config.postgres_user
265
+
266
+ @ssh_executor.execute_on_host(host) do
267
+ # Upload configuration
268
+ upload! StringIO.new(ini_content), '/tmp/pgbouncer.ini'
269
+ execute :sudo, 'mv', '/tmp/pgbouncer.ini', '/etc/pgbouncer/pgbouncer.ini'
270
+ execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/etc/pgbouncer/pgbouncer.ini'
271
+ execute :sudo, 'chmod', '640', '/etc/pgbouncer/pgbouncer.ini'
272
+ end
273
+ end
274
+
275
+ def generate_pgbouncer_ini(settings)
276
+ ini = "[databases]\n"
277
+
278
+ # Add database configurations
279
+ settings[:databases].each do |db_name, db_config|
280
+ if db_name == '*'
281
+ # Wildcard database
282
+ ini += "* = host=#{db_config[:host]} port=#{db_config[:port]}"
283
+ ini += " auth_user=#{db_config[:auth_user]}" if db_config[:auth_user]
284
+ else
285
+ ini += "#{db_name} = "
286
+ ini += "host=#{db_config[:host]} "
287
+ ini += "port=#{db_config[:port]} "
288
+ ini += "dbname=#{db_config[:dbname]} " if db_config[:dbname]
289
+ ini += "auth_user=#{db_config[:auth_user]} " if db_config[:auth_user]
290
+ ini += "pool_size=#{db_config[:pool_size]} " if db_config[:pool_size]
291
+ ini += "reserve_pool=#{db_config[:reserve_pool]} " if db_config[:reserve_pool]
292
+ ini += "pool_mode=#{db_config[:pool_mode]} " if db_config[:pool_mode]
293
+ end
294
+ ini += "\n"
295
+ end
296
+
297
+ ini += "\n[pgbouncer]\n"
298
+
299
+ # Add PgBouncer settings
300
+ settings.each do |key, value|
301
+ next if key == :databases # Already handled above
302
+
303
+ if value.is_a?(String) && !value.empty?
304
+ ini += "#{key} = #{value}\n"
305
+ elsif value.is_a?(Integer) || value.is_a?(Float)
306
+ ini += "#{key} = #{value}\n"
307
+ end
308
+ end
309
+
310
+ ini
311
+ end
312
+
313
+ def setup_authentication(host)
314
+ postgres_user = config.postgres_user
315
+ pgbouncer_user = config.pgbouncer_user
316
+
317
+ @ssh_executor.execute_on_host(host) do
318
+ sql = <<~SQL.strip
319
+ SELECT concat('"', usename, '" "', passwd, '"')
320
+ FROM pg_shadow
321
+ WHERE passwd IS NOT NULL
322
+ SQL
323
+
324
+ upload! StringIO.new(sql), '/tmp/get_all_users.sql'
325
+ execute :chmod, '644', '/tmp/get_all_users.sql'
326
+ userlist = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_all_users.sql').strip
327
+ execute :rm, '-f', '/tmp/get_all_users.sql'
328
+
329
+ unless userlist.include?(pgbouncer_user)
330
+ pgbouncer_pass = SecureRandom.hex(16)
331
+ escaped_pass = pgbouncer_pass.gsub("'", "''")
332
+
333
+ sql = [
334
+ "CREATE USER #{pgbouncer_user} WITH PASSWORD '#{escaped_pass}';",
335
+ "GRANT CONNECT ON DATABASE postgres TO #{pgbouncer_user};"
336
+ ].join("\n")
337
+
338
+ upload! StringIO.new(sql), '/tmp/create_pgbouncer_user.sql'
339
+ execute :chmod, '644', '/tmp/create_pgbouncer_user.sql'
340
+ execute :sudo, '-u', postgres_user, 'psql', '-f', '/tmp/create_pgbouncer_user.sql'
341
+ execute :rm, '-f', '/tmp/create_pgbouncer_user.sql'
342
+
343
+ sql = <<~SQL.strip
344
+ SELECT passwd
345
+ FROM pg_shadow
346
+ WHERE usename = '#{pgbouncer_user}'
347
+ SQL
348
+
349
+ upload! StringIO.new(sql), '/tmp/get_pgbouncer_pass.sql'
350
+ execute :chmod, '644', '/tmp/get_pgbouncer_pass.sql'
351
+ encrypted = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_pgbouncer_pass.sql').strip
352
+ execute :rm, '-f', '/tmp/get_pgbouncer_pass.sql'
353
+
354
+ userlist += "\n\"#{pgbouncer_user}\" \"#{encrypted}\""
355
+ end
356
+
357
+ # Write userlist.txt
358
+ upload! StringIO.new(userlist), '/tmp/userlist.txt'
359
+ execute :sudo, 'mv', '/tmp/userlist.txt', '/etc/pgbouncer/userlist.txt'
360
+ execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/etc/pgbouncer/userlist.txt'
361
+ execute :sudo, 'chmod', '640', '/etc/pgbouncer/userlist.txt'
362
+ end
363
+ end
364
+
365
+ def enable_pgbouncer(host)
366
+ @ssh_executor.execute_on_host(host) do
367
+ # Create systemd override for running as postgres user
368
+ systemd_override = <<~CONF
369
+ [Service]
370
+ User=postgres
371
+ Group=postgres
372
+ RuntimeDirectory=pgbouncer
373
+ RuntimeDirectoryMode=0755
374
+
375
+ # Resource limits
376
+ LimitNOFILE=65536
377
+ LimitNPROC=32768
378
+
379
+ # Restart policy
380
+ Restart=always
381
+ RestartSec=10
382
+ CONF
383
+
384
+ upload! StringIO.new(systemd_override), '/tmp/pgbouncer_override.conf'
385
+ execute :sudo, 'mkdir', '-p', '/etc/systemd/system/pgbouncer.service.d/'
386
+ execute :sudo, 'mv', '/tmp/pgbouncer_override.conf',
387
+ '/etc/systemd/system/pgbouncer.service.d/override.conf'
388
+
389
+ # Reload systemd and start PgBouncer
390
+ execute :sudo, 'systemctl', 'daemon-reload'
391
+ execute :sudo, 'systemctl', 'enable', 'pgbouncer'
392
+ execute :sudo, 'systemctl', 'restart', 'pgbouncer'
393
+ end
394
+ end
395
+
396
+ def verify_pgbouncer(host)
397
+ postgres_user = config.postgres_user
398
+ pgbouncer_user = config.pgbouncer_user
399
+
400
+ @ssh_executor.execute_on_host(host) do
401
+ # Check if PgBouncer is running
402
+ raise "PgBouncer is not running on #{host}" unless test('systemctl is-active pgbouncer')
403
+
404
+ # Test connection through PgBouncer
405
+ test_conn = test('env', 'PGPASSWORD=test', 'psql', '-h', 'localhost', '-p', '6432',
406
+ '-U', postgres_user, '-d', 'postgres', '-c', 'SELECT 1', '2>/dev/null')
407
+
408
+ if test_conn
409
+ @logger.success "✓ PgBouncer is working correctly on #{host}"
410
+ else
411
+ @logger.warn "⚠ PgBouncer is running but connection test failed on #{host}"
412
+ end
413
+
414
+ # Show PgBouncer stats
415
+ stats = begin
416
+ capture(:sudo, '-u', postgres_user, 'psql', '-h', 'localhost', '-p', '6432',
417
+ '-U', pgbouncer_user, 'pgbouncer', '-c', 'SHOW STATS', '2>/dev/null')
418
+ rescue StandardError
419
+ nil
420
+ end
421
+
422
+ if stats
423
+ @logger.info 'PgBouncer statistics:'
424
+ @logger.info stats
425
+ end
426
+ end
427
+ end
428
+ end
429
+ end