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.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +158 -0
- data/exe/activepostgres +5 -0
- data/lib/active_postgres/cli.rb +157 -0
- data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
- data/lib/active_postgres/component_resolver.rb +24 -0
- data/lib/active_postgres/components/base.rb +38 -0
- data/lib/active_postgres/components/core.rb +158 -0
- data/lib/active_postgres/components/extensions.rb +99 -0
- data/lib/active_postgres/components/monitoring.rb +55 -0
- data/lib/active_postgres/components/pgbackrest.rb +94 -0
- data/lib/active_postgres/components/pgbouncer.rb +137 -0
- data/lib/active_postgres/components/repmgr.rb +651 -0
- data/lib/active_postgres/components/ssl.rb +86 -0
- data/lib/active_postgres/configuration.rb +190 -0
- data/lib/active_postgres/connection_pooler.rb +429 -0
- data/lib/active_postgres/credentials.rb +17 -0
- data/lib/active_postgres/deployment_flow.rb +154 -0
- data/lib/active_postgres/error_handler.rb +185 -0
- data/lib/active_postgres/failover.rb +83 -0
- data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
- data/lib/active_postgres/health_checker.rb +244 -0
- data/lib/active_postgres/installer.rb +114 -0
- data/lib/active_postgres/log_sanitizer.rb +67 -0
- data/lib/active_postgres/logger.rb +125 -0
- data/lib/active_postgres/performance_tuner.rb +246 -0
- data/lib/active_postgres/rails/database_config.rb +174 -0
- data/lib/active_postgres/rails/migration_guard.rb +25 -0
- data/lib/active_postgres/railtie.rb +28 -0
- data/lib/active_postgres/retry_helper.rb +80 -0
- data/lib/active_postgres/rollback_manager.rb +140 -0
- data/lib/active_postgres/secrets.rb +86 -0
- data/lib/active_postgres/ssh_executor.rb +288 -0
- data/lib/active_postgres/standby_deployment_flow.rb +122 -0
- data/lib/active_postgres/validator.rb +143 -0
- data/lib/active_postgres/version.rb +3 -0
- data/lib/active_postgres.rb +67 -0
- data/lib/tasks/postgres.rake +855 -0
- data/lib/tasks/rolling_update.rake +258 -0
- data/lib/tasks/rotate_credentials.rake +193 -0
- data/templates/pg_hba.conf.erb +47 -0
- data/templates/pgbackrest.conf.erb +43 -0
- data/templates/pgbouncer.ini.erb +55 -0
- data/templates/postgresql.conf.erb +157 -0
- data/templates/repmgr.conf.erb +40 -0
- 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
|