active_postgres 0.7.0 → 0.9.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 +4 -4
- data/README.md +87 -5
- data/lib/active_postgres/components/core.rb +2 -7
- data/lib/active_postgres/components/extensions.rb +40 -35
- data/lib/active_postgres/components/monitoring.rb +91 -4
- data/lib/active_postgres/components/pgbackrest.rb +38 -2
- data/lib/active_postgres/components/pgbouncer.rb +43 -4
- data/lib/active_postgres/components/repmgr.rb +431 -62
- data/lib/active_postgres/configuration.rb +59 -2
- data/lib/active_postgres/connection_pooler.rb +3 -12
- data/lib/active_postgres/credentials.rb +3 -3
- data/lib/active_postgres/direct_executor.rb +91 -0
- data/lib/active_postgres/generators/active_postgres/install_generator.rb +1 -0
- data/lib/active_postgres/generators/active_postgres/templates/postgres.yml.erb +18 -1
- data/lib/active_postgres/health_checker.rb +24 -17
- data/lib/active_postgres/rollback_manager.rb +4 -8
- data/lib/active_postgres/secrets.rb +23 -5
- data/lib/active_postgres/ssh_executor.rb +36 -14
- data/lib/active_postgres/version.rb +1 -1
- data/lib/active_postgres.rb +1 -0
- data/lib/tasks/postgres.rake +10 -4
- data/lib/tasks/rotate_credentials.rake +4 -16
- data/templates/pg_hba.conf.erb +4 -1
- data/templates/pgbackrest.conf.erb +28 -0
- data/templates/pgbouncer-follow-primary.service.erb +8 -0
- data/templates/pgbouncer-follow-primary.timer.erb +11 -0
- data/templates/pgbouncer.ini.erb +2 -0
- data/templates/pgbouncer_follow_primary.sh.erb +34 -0
- data/templates/postgresql.conf.erb +4 -0
- data/templates/repmgr.conf.erb +7 -3
- data/templates/repmgr_dns_failover.sh.erb +49 -0
- metadata +7 -2
|
@@ -2,18 +2,21 @@ require 'yaml'
|
|
|
2
2
|
|
|
3
3
|
module ActivePostgres
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_reader :environment, :version, :user, :ssh_key, :primary, :standbys, :components, :secrets_config,
|
|
5
|
+
attr_reader :environment, :version, :user, :ssh_key, :ssh_host_key_verification, :primary, :standbys, :components, :secrets_config,
|
|
6
|
+
:database_config
|
|
6
7
|
|
|
7
8
|
def initialize(config_hash, environment = 'development')
|
|
8
9
|
@environment = environment
|
|
9
10
|
env_config = config_hash[environment] || {}
|
|
10
11
|
|
|
11
|
-
# Check if deployment should be skipped (e.g., for development)
|
|
12
12
|
@skip_deployment = env_config['skip_deployment'] == true
|
|
13
13
|
|
|
14
14
|
@version = env_config['version'] || 18
|
|
15
15
|
@user = env_config['user'] || 'ubuntu'
|
|
16
16
|
@ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
|
|
17
|
+
@ssh_host_key_verification = normalize_ssh_host_key_verification(
|
|
18
|
+
env_config['ssh_host_key_verification'] || env_config['ssh_verify_host_key']
|
|
19
|
+
)
|
|
17
20
|
|
|
18
21
|
@primary = env_config['primary'] || {}
|
|
19
22
|
@standbys = env_config['standby'] || []
|
|
@@ -65,6 +68,16 @@ module ActivePostgres
|
|
|
65
68
|
private_ip_for(node) || host
|
|
66
69
|
end
|
|
67
70
|
|
|
71
|
+
# Returns the host to use for direct PostgreSQL connections (private_ip preferred)
|
|
72
|
+
def connection_host_for(host)
|
|
73
|
+
node = node_config_for(host)
|
|
74
|
+
private_ip_for(node) || host
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def primary_connection_host
|
|
78
|
+
connection_host_for(primary_host)
|
|
79
|
+
end
|
|
80
|
+
|
|
68
81
|
def standby_config_for(host)
|
|
69
82
|
@standbys.find { |s| s['host'] == host }
|
|
70
83
|
end
|
|
@@ -82,6 +95,29 @@ module ActivePostgres
|
|
|
82
95
|
|
|
83
96
|
# Validate required secrets if components are enabled
|
|
84
97
|
raise Error, 'Missing replication_password secret' if component_enabled?(:repmgr) && !secrets_config['replication_password']
|
|
98
|
+
raise Error, 'Missing monitoring_password secret' if component_enabled?(:monitoring) && !secrets_config['monitoring_password']
|
|
99
|
+
|
|
100
|
+
if component_enabled?(:repmgr)
|
|
101
|
+
dns_failover = component_config(:repmgr)[:dns_failover]
|
|
102
|
+
if dns_failover && dns_failover[:enabled]
|
|
103
|
+
domain = dns_failover[:domain].to_s.strip
|
|
104
|
+
servers = Array(dns_failover[:dns_servers])
|
|
105
|
+
provider = (dns_failover[:provider] || 'dnsmasq').to_s.strip
|
|
106
|
+
|
|
107
|
+
raise Error, 'dns_failover.domain is required when enabled' if domain.empty?
|
|
108
|
+
raise Error, 'dns_failover.dns_servers is required when enabled' if servers.empty?
|
|
109
|
+
raise Error, "Unsupported dns_failover provider '#{provider}'" unless provider == 'dnsmasq'
|
|
110
|
+
|
|
111
|
+
servers.each do |server|
|
|
112
|
+
next unless server.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
ssh_host = server['ssh_host'] || server[:ssh_host] || server['host'] || server[:host]
|
|
115
|
+
private_ip = server['private_ip'] || server[:private_ip] || server['ip'] || server[:ip]
|
|
116
|
+
raise Error, 'dns_failover.dns_servers entries must include host/ssh_host or private_ip' if
|
|
117
|
+
(ssh_host.nil? || ssh_host.to_s.strip.empty?) && (private_ip.nil? || private_ip.to_s.strip.empty?)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
85
121
|
|
|
86
122
|
true
|
|
87
123
|
end
|
|
@@ -99,6 +135,10 @@ module ActivePostgres
|
|
|
99
135
|
component_config(:repmgr)[:database] || 'repmgr'
|
|
100
136
|
end
|
|
101
137
|
|
|
138
|
+
def replication_user
|
|
139
|
+
component_config(:repmgr)[:replication_user] || 'replication'
|
|
140
|
+
end
|
|
141
|
+
|
|
102
142
|
def pgbouncer_user
|
|
103
143
|
component_config(:pgbouncer)[:user] || 'pgbouncer'
|
|
104
144
|
end
|
|
@@ -186,5 +226,22 @@ module ActivePostgres
|
|
|
186
226
|
value
|
|
187
227
|
end
|
|
188
228
|
end
|
|
229
|
+
|
|
230
|
+
def normalize_ssh_host_key_verification(value)
|
|
231
|
+
return :always if value.nil?
|
|
232
|
+
|
|
233
|
+
normalized = case value
|
|
234
|
+
when Symbol
|
|
235
|
+
value
|
|
236
|
+
else
|
|
237
|
+
value.to_s.strip.downcase.tr('-', '_').to_sym
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
return :always if normalized == :always
|
|
241
|
+
return :accept_new if %i[accept_new acceptnew new].include?(normalized)
|
|
242
|
+
|
|
243
|
+
raise Error,
|
|
244
|
+
"Invalid ssh_host_key_verification '#{value}'. Use 'always' or 'accept_new'."
|
|
245
|
+
end
|
|
189
246
|
end
|
|
190
247
|
end
|
|
@@ -321,10 +321,7 @@ module ActivePostgres
|
|
|
321
321
|
WHERE passwd IS NOT NULL
|
|
322
322
|
SQL
|
|
323
323
|
|
|
324
|
-
|
|
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'
|
|
324
|
+
userlist = @ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user).to_s.strip
|
|
328
325
|
|
|
329
326
|
unless userlist.include?(pgbouncer_user)
|
|
330
327
|
pgbouncer_pass = SecureRandom.hex(16)
|
|
@@ -335,10 +332,7 @@ module ActivePostgres
|
|
|
335
332
|
"GRANT CONNECT ON DATABASE postgres TO #{pgbouncer_user};"
|
|
336
333
|
].join("\n")
|
|
337
334
|
|
|
338
|
-
|
|
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'
|
|
335
|
+
@ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user, tuples_only: false, capture: false)
|
|
342
336
|
|
|
343
337
|
sql = <<~SQL.strip
|
|
344
338
|
SELECT passwd
|
|
@@ -346,10 +340,7 @@ module ActivePostgres
|
|
|
346
340
|
WHERE usename = '#{pgbouncer_user}'
|
|
347
341
|
SQL
|
|
348
342
|
|
|
349
|
-
|
|
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'
|
|
343
|
+
encrypted = @ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user).to_s.strip
|
|
353
344
|
|
|
354
345
|
userlist += "\n\"#{pgbouncer_user}\" \"#{encrypted}\""
|
|
355
346
|
end
|
|
@@ -2,8 +2,8 @@ module ActivePostgres
|
|
|
2
2
|
class Credentials
|
|
3
3
|
def self.get(key_path)
|
|
4
4
|
# Try to get from Rails credentials if Rails is available
|
|
5
|
-
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
6
|
-
value = Rails.application.credentials.dig(*key_path.split('.').map(&:to_sym))
|
|
5
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
6
|
+
value = ::Rails.application.credentials.dig(*key_path.split('.').map(&:to_sym))
|
|
7
7
|
return value if value
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ module ActivePostgres
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def self.available?
|
|
14
|
-
defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials
|
|
14
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application&.credentials
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require 'pg'
|
|
2
|
+
|
|
3
|
+
module ActivePostgres
|
|
4
|
+
class DirectExecutor
|
|
5
|
+
attr_reader :config
|
|
6
|
+
|
|
7
|
+
def initialize(config, quiet: false)
|
|
8
|
+
@config = config
|
|
9
|
+
@quiet = quiet
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def quiet?
|
|
13
|
+
@quiet
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def postgres_running?(host)
|
|
17
|
+
with_connection(host) do |conn|
|
|
18
|
+
conn.exec('SELECT 1')
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
rescue PG::Error
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_sql(host, sql)
|
|
26
|
+
with_connection(host) do |conn|
|
|
27
|
+
result = conn.exec(sql)
|
|
28
|
+
result.values.flatten.join("\n")
|
|
29
|
+
end
|
|
30
|
+
rescue PG::Error => e
|
|
31
|
+
raise Error, "Failed to execute SQL on #{host}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_postgres_status(host)
|
|
35
|
+
run_sql(host, 'SELECT version();')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def with_connection(host)
|
|
41
|
+
connection_host = config.connection_host_for(host)
|
|
42
|
+
superuser_password = resolve_secret(config.secrets_config['superuser_password'])
|
|
43
|
+
|
|
44
|
+
conn = PG.connect(
|
|
45
|
+
host: connection_host,
|
|
46
|
+
port: 5432,
|
|
47
|
+
dbname: 'postgres',
|
|
48
|
+
user: config.postgres_user,
|
|
49
|
+
password: superuser_password,
|
|
50
|
+
connect_timeout: 10,
|
|
51
|
+
sslmode: config.component_enabled?(:ssl) ? 'require' : 'prefer'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
yield conn
|
|
56
|
+
ensure
|
|
57
|
+
conn.close
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_secret(value)
|
|
62
|
+
return nil if value.nil?
|
|
63
|
+
|
|
64
|
+
# Handle Rails credentials
|
|
65
|
+
if value.start_with?('rails_credentials:')
|
|
66
|
+
return resolve_rails_credentials(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Handle environment variables
|
|
70
|
+
if value.start_with?('$')
|
|
71
|
+
return ENV[value[1..]]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Handle shell commands
|
|
75
|
+
if value.start_with?('$(') && value.end_with?(')')
|
|
76
|
+
return `#{value[2..-2]}`.strip
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_rails_credentials(value)
|
|
83
|
+
path = value.sub('rails_credentials:', '')
|
|
84
|
+
keys = path.split('.').map(&:to_sym)
|
|
85
|
+
|
|
86
|
+
Rails.application.credentials.dig(*keys)
|
|
87
|
+
rescue NameError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -112,6 +112,7 @@ module ActivePostgres
|
|
|
112
112
|
puts " superuser_password: \"#{generate_secure_password}\""
|
|
113
113
|
puts " replication_password: \"#{generate_secure_password}\""
|
|
114
114
|
puts " repmgr_password: \"#{generate_secure_password}\""
|
|
115
|
+
puts " monitoring_password: \"#{generate_secure_password}\""
|
|
115
116
|
puts
|
|
116
117
|
puts '🔐 Secure passwords have been auto-generated above.'
|
|
117
118
|
puts '📌 Update primary_host and replica_host with your actual IPs after provisioning.'
|
|
@@ -11,6 +11,8 @@ shared: &shared
|
|
|
11
11
|
version: 18
|
|
12
12
|
user: ubuntu
|
|
13
13
|
ssh_key: ~/.ssh/id_rsa
|
|
14
|
+
# SSH host key verification: always (strict) or accept_new (first connection)
|
|
15
|
+
ssh_host_key_verification: always
|
|
14
16
|
|
|
15
17
|
components:
|
|
16
18
|
core:
|
|
@@ -31,17 +33,32 @@ shared: &shared
|
|
|
31
33
|
# Optional: Override default repmgr user/database
|
|
32
34
|
# user: repmgr
|
|
33
35
|
# database: repmgr
|
|
36
|
+
# replication_user: replication
|
|
37
|
+
# dns_failover:
|
|
38
|
+
# enabled: true
|
|
39
|
+
# provider: dnsmasq
|
|
40
|
+
# domain: mesh.internal
|
|
41
|
+
# dns_servers:
|
|
42
|
+
# - host: 18.170.173.14
|
|
43
|
+
# private_ip: 10.8.0.10
|
|
44
|
+
# - host: 98.85.183.175
|
|
45
|
+
# private_ip: 10.8.0.110
|
|
46
|
+
# primary_record: db-primary.mesh.internal
|
|
47
|
+
# replica_record: db-replica.mesh.internal
|
|
34
48
|
|
|
35
49
|
pgbouncer:
|
|
36
50
|
enabled: false
|
|
37
51
|
# Optional: Override default pgbouncer user
|
|
38
52
|
# user: pgbouncer
|
|
53
|
+
# follow_primary: true
|
|
54
|
+
# follow_primary_interval: 5
|
|
39
55
|
|
|
40
56
|
pgbackrest:
|
|
41
57
|
enabled: false
|
|
42
58
|
|
|
43
59
|
monitoring:
|
|
44
60
|
enabled: false
|
|
61
|
+
# user: postgres_exporter
|
|
45
62
|
|
|
46
63
|
ssl:
|
|
47
64
|
enabled: false
|
|
@@ -80,6 +97,6 @@ production:
|
|
|
80
97
|
superuser_password: rails_credentials:postgres.superuser_password
|
|
81
98
|
replication_password: rails_credentials:postgres.replication_password
|
|
82
99
|
repmgr_password: rails_credentials:postgres.repmgr_password
|
|
100
|
+
monitoring_password: rails_credentials:postgres.monitoring_password
|
|
83
101
|
pgbouncer_password: rails_credentials:postgres.password
|
|
84
102
|
app_password: rails_credentials:postgres.password
|
|
85
|
-
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
module ActivePostgres
|
|
2
2
|
class HealthChecker
|
|
3
|
-
attr_reader :config, :
|
|
3
|
+
attr_reader :config, :executor
|
|
4
4
|
|
|
5
5
|
def initialize(config)
|
|
6
6
|
@config = config
|
|
7
|
-
@
|
|
7
|
+
@executor = create_executor
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Backwards compatibility alias
|
|
11
|
+
def ssh_executor
|
|
12
|
+
@executor
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private def create_executor
|
|
16
|
+
DirectExecutor.new(config, quiet: true)
|
|
8
17
|
end
|
|
9
18
|
|
|
10
19
|
def show_status
|
|
@@ -287,31 +296,29 @@ module ActivePostgres
|
|
|
287
296
|
def check_pgbouncer_status(host)
|
|
288
297
|
return '-' unless config.component_enabled?(:pgbouncer)
|
|
289
298
|
|
|
290
|
-
|
|
291
|
-
result = capture(:sudo, 'systemctl', 'is-active', 'pgbouncer').strip
|
|
292
|
-
result == 'active' ? '✓ running' : '✗ down'
|
|
293
|
-
end
|
|
299
|
+
check_pgbouncer_direct(host) ? '✓ running' : '✗ down'
|
|
294
300
|
rescue StandardError
|
|
295
301
|
'✗ down'
|
|
296
302
|
end
|
|
297
303
|
|
|
298
304
|
def check_pgbouncer_running(host)
|
|
299
|
-
|
|
300
|
-
result = capture(:sudo, 'systemctl', 'is-active', 'pgbouncer').strip
|
|
301
|
-
result == 'active'
|
|
302
|
-
end
|
|
305
|
+
check_pgbouncer_direct(host)
|
|
303
306
|
rescue StandardError
|
|
304
307
|
false
|
|
305
308
|
end
|
|
306
309
|
|
|
307
|
-
def check_pgbouncer_userlist(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
+
def check_pgbouncer_userlist(_host)
|
|
311
|
+
true
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def check_pgbouncer_direct(host)
|
|
315
|
+
require 'socket'
|
|
316
|
+
connection_host = config.connection_host_for(host)
|
|
317
|
+
pgbouncer_port = config.component_config(:pgbouncer)[:listen_port] || 6432
|
|
310
318
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
end
|
|
319
|
+
socket = TCPSocket.new(connection_host, pgbouncer_port)
|
|
320
|
+
socket.close
|
|
321
|
+
true
|
|
315
322
|
rescue StandardError
|
|
316
323
|
false
|
|
317
324
|
end
|
|
@@ -113,12 +113,10 @@ module ActivePostgres
|
|
|
113
113
|
|
|
114
114
|
def register_database_removal(host, database_name)
|
|
115
115
|
postgres_user = config.postgres_user
|
|
116
|
+
executor = ssh_executor
|
|
116
117
|
register("Drop database #{database_name} on #{host}", host: host) do
|
|
117
118
|
sql = "DROP DATABASE IF EXISTS #{database_name};"
|
|
118
|
-
|
|
119
|
-
execute :chmod, '644', '/tmp/drop_database.sql'
|
|
120
|
-
execute :sudo, '-u', postgres_user, 'psql', '-f', '/tmp/drop_database.sql'
|
|
121
|
-
execute :rm, '-f', '/tmp/drop_database.sql'
|
|
119
|
+
executor.run_sql_on_backend(self, sql, postgres_user: postgres_user, tuples_only: false, capture: false)
|
|
122
120
|
rescue StandardError
|
|
123
121
|
nil
|
|
124
122
|
end
|
|
@@ -126,12 +124,10 @@ module ActivePostgres
|
|
|
126
124
|
|
|
127
125
|
def register_postgres_user_removal(host, username)
|
|
128
126
|
postgres_user = config.postgres_user
|
|
127
|
+
executor = ssh_executor
|
|
129
128
|
register("Drop PostgreSQL user #{username} on #{host}", host: host) do
|
|
130
129
|
sql = "DROP USER IF EXISTS #{username};"
|
|
131
|
-
|
|
132
|
-
execute :chmod, '644', '/tmp/drop_user.sql'
|
|
133
|
-
execute :sudo, '-u', postgres_user, 'psql', '-f', '/tmp/drop_user.sql'
|
|
134
|
-
execute :rm, '-f', '/tmp/drop_user.sql'
|
|
130
|
+
executor.run_sql_on_backend(self, sql, postgres_user: postgres_user, tuples_only: false, capture: false)
|
|
135
131
|
rescue StandardError
|
|
136
132
|
nil
|
|
137
133
|
end
|
|
@@ -15,7 +15,7 @@ module ActivePostgres
|
|
|
15
15
|
return nil unless secret_value
|
|
16
16
|
|
|
17
17
|
resolved = resolve_secret_value(secret_value)
|
|
18
|
-
@cache[secret_key] = resolved
|
|
18
|
+
@cache[secret_key] = resolved unless resolved.nil?
|
|
19
19
|
resolved
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -25,6 +25,21 @@ module ActivePostgres
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def resolve_value(value)
|
|
29
|
+
case value
|
|
30
|
+
when Hash
|
|
31
|
+
value.each_with_object({}) do |(k, v), result|
|
|
32
|
+
result[k] = resolve_value(v)
|
|
33
|
+
end
|
|
34
|
+
when Array
|
|
35
|
+
value.map { |v| resolve_value(v) }
|
|
36
|
+
when String
|
|
37
|
+
resolve_secret_value(value)
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
28
43
|
def cache_to_files(directory = '.secrets')
|
|
29
44
|
require 'fileutils'
|
|
30
45
|
|
|
@@ -45,9 +60,10 @@ module ActivePostgres
|
|
|
45
60
|
|
|
46
61
|
def resolve_secret_value(value)
|
|
47
62
|
case value
|
|
48
|
-
when /^rails_credentials:(.+)$/
|
|
63
|
+
when /^(rails_credentials|credentials):(.+)$/
|
|
49
64
|
# Rails credentials: rails_credentials:postgres.superuser_password
|
|
50
|
-
|
|
65
|
+
# Alias: credentials:postgres.superuser_password
|
|
66
|
+
key_path = ::Regexp.last_match(2).to_s.strip
|
|
51
67
|
fetch_from_rails_credentials(key_path)
|
|
52
68
|
when /^\$\((.+)\)$/
|
|
53
69
|
# Command execution: $(op read "op://...")
|
|
@@ -65,10 +81,12 @@ module ActivePostgres
|
|
|
65
81
|
end
|
|
66
82
|
|
|
67
83
|
def fetch_from_rails_credentials(key_path)
|
|
68
|
-
return nil unless
|
|
84
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
69
85
|
|
|
70
86
|
keys = key_path.split('.').map(&:to_sym)
|
|
71
|
-
Rails.application.credentials.dig(*keys)
|
|
87
|
+
::Rails.application.credentials.dig(*keys)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
72
90
|
end
|
|
73
91
|
|
|
74
92
|
def execute_command(command)
|
|
@@ -22,6 +22,10 @@ module ActivePostgres
|
|
|
22
22
|
on("#{config.user}@#{host}", &)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def execute_on_host_as(host, user, &)
|
|
26
|
+
on("#{user}@#{host}", &)
|
|
27
|
+
end
|
|
28
|
+
|
|
25
29
|
def execute_on_primary(&)
|
|
26
30
|
execute_on_host(config.primary_host, &)
|
|
27
31
|
end
|
|
@@ -94,7 +98,7 @@ module ActivePostgres
|
|
|
94
98
|
|
|
95
99
|
info 'Configuring PostgreSQL apt repository...'
|
|
96
100
|
pgdg_repo = "'echo \"deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] " \
|
|
97
|
-
'
|
|
101
|
+
'https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > ' \
|
|
98
102
|
"/etc/apt/sources.list.d/pgdg.list'"
|
|
99
103
|
execute :sudo, 'sh', '-c', pgdg_repo
|
|
100
104
|
|
|
@@ -200,24 +204,42 @@ module ActivePostgres
|
|
|
200
204
|
result
|
|
201
205
|
end
|
|
202
206
|
|
|
203
|
-
def run_sql(host, sql
|
|
207
|
+
def run_sql(host, sql, postgres_user: config.postgres_user, port: nil, database: nil, tuples_only: true,
|
|
208
|
+
capture: true)
|
|
204
209
|
result = nil
|
|
205
|
-
|
|
210
|
+
executor = self
|
|
206
211
|
execute_on_host(host) do
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
ensure
|
|
215
|
-
execute :rm, '-f', temp_file
|
|
216
|
-
end
|
|
212
|
+
backend = self
|
|
213
|
+
result = executor.run_sql_on_backend(backend, sql,
|
|
214
|
+
postgres_user: postgres_user,
|
|
215
|
+
port: port,
|
|
216
|
+
database: database,
|
|
217
|
+
tuples_only: tuples_only,
|
|
218
|
+
capture: capture)
|
|
217
219
|
end
|
|
218
220
|
result
|
|
219
221
|
end
|
|
220
222
|
|
|
223
|
+
def run_sql_on_backend(backend, sql, postgres_user: config.postgres_user, port: nil, database: nil,
|
|
224
|
+
tuples_only: true, capture: true)
|
|
225
|
+
# Use a temporary file to avoid shell escaping issues with special characters
|
|
226
|
+
temp_file = "/tmp/active_postgres_#{SecureRandom.hex(8)}.sql"
|
|
227
|
+
backend.upload! StringIO.new(sql), temp_file
|
|
228
|
+
backend.execute :chmod, '600', temp_file
|
|
229
|
+
backend.execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", temp_file
|
|
230
|
+
|
|
231
|
+
cmd = [:sudo, '-u', postgres_user, 'psql']
|
|
232
|
+
cmd << '-t' if tuples_only
|
|
233
|
+
cmd += ['-p', port.to_s] if port
|
|
234
|
+
cmd += ['-d', database.to_s] if database
|
|
235
|
+
cmd += ['-f', temp_file]
|
|
236
|
+
|
|
237
|
+
result = capture ? backend.capture(*cmd) : backend.execute(*cmd)
|
|
238
|
+
result
|
|
239
|
+
ensure
|
|
240
|
+
backend.execute :sudo, 'rm', '-f', temp_file
|
|
241
|
+
end
|
|
242
|
+
|
|
221
243
|
def ensure_cluster_exists(host, version)
|
|
222
244
|
execute_on_host(host) do
|
|
223
245
|
data_dir = "/var/lib/postgresql/#{version}/main"
|
|
@@ -280,7 +302,7 @@ module ActivePostgres
|
|
|
280
302
|
keys_only: true,
|
|
281
303
|
forward_agent: false,
|
|
282
304
|
auth_methods: ['publickey'],
|
|
283
|
-
verify_host_key: :
|
|
305
|
+
verify_host_key: config.ssh_host_key_verification || :always,
|
|
284
306
|
timeout: 10,
|
|
285
307
|
number_of_password_prompts: 0
|
|
286
308
|
}
|
data/lib/active_postgres.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative 'active_postgres/standby_deployment_flow'
|
|
|
21
21
|
require_relative 'active_postgres/cli'
|
|
22
22
|
require_relative 'active_postgres/installer'
|
|
23
23
|
require_relative 'active_postgres/ssh_executor'
|
|
24
|
+
require_relative 'active_postgres/direct_executor'
|
|
24
25
|
require_relative 'active_postgres/health_checker'
|
|
25
26
|
require_relative 'active_postgres/failover'
|
|
26
27
|
require_relative 'active_postgres/performance_tuner'
|
data/lib/tasks/postgres.rake
CHANGED
|
@@ -372,6 +372,15 @@ namespace :postgres do
|
|
|
372
372
|
installer.setup_component('monitoring')
|
|
373
373
|
end
|
|
374
374
|
|
|
375
|
+
desc 'Setup only pgBackRest backups'
|
|
376
|
+
task pgbackrest: :environment do
|
|
377
|
+
require 'active_postgres'
|
|
378
|
+
|
|
379
|
+
config = ActivePostgres::Configuration.load
|
|
380
|
+
installer = ActivePostgres::Installer.new(config)
|
|
381
|
+
installer.setup_component('pgbackrest')
|
|
382
|
+
end
|
|
383
|
+
|
|
375
384
|
desc 'Setup only repmgr'
|
|
376
385
|
task repmgr: :environment do
|
|
377
386
|
require 'active_postgres'
|
|
@@ -412,10 +421,7 @@ namespace :postgres do
|
|
|
412
421
|
WHERE rolname = '#{user}'
|
|
413
422
|
SQL
|
|
414
423
|
|
|
415
|
-
|
|
416
|
-
execute :chmod, '644', '/tmp/get_user_hash.sql'
|
|
417
|
-
user_hash = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_user_hash.sql').strip
|
|
418
|
-
execute :rm, '-f', '/tmp/get_user_hash.sql'
|
|
424
|
+
user_hash = ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user).to_s.strip
|
|
419
425
|
|
|
420
426
|
if user_hash && !user_hash.empty?
|
|
421
427
|
userlist_entries << user_hash
|
|
@@ -26,10 +26,7 @@ namespace :postgres do
|
|
|
26
26
|
escaped_password = new_password.gsub("'", "''")
|
|
27
27
|
|
|
28
28
|
sql = "ALTER USER #{app_user} WITH PASSWORD '#{escaped_password}';"
|
|
29
|
-
|
|
30
|
-
execute :chmod, '644', '/tmp/rotate_password.sql'
|
|
31
|
-
execute :sudo, '-u', 'postgres', 'psql', '-f', '/tmp/rotate_password.sql'
|
|
32
|
-
execute :rm, '-f', '/tmp/rotate_password.sql'
|
|
29
|
+
ssh_executor.run_sql_on_backend(self, sql, postgres_user: 'postgres', tuples_only: false, capture: false)
|
|
33
30
|
|
|
34
31
|
puts "✓ Updated PostgreSQL password for #{app_user}"
|
|
35
32
|
end
|
|
@@ -48,10 +45,7 @@ namespace :postgres do
|
|
|
48
45
|
WHERE rolname = '#{user}'
|
|
49
46
|
SQL
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
execute :chmod, '644', '/tmp/get_user_hash.sql'
|
|
53
|
-
user_hash = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_user_hash.sql').strip
|
|
54
|
-
execute :rm, '-f', '/tmp/get_user_hash.sql'
|
|
48
|
+
user_hash = ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user).to_s.strip
|
|
55
49
|
|
|
56
50
|
userlist_entries << user_hash if user_hash && !user_hash.empty?
|
|
57
51
|
end
|
|
@@ -128,10 +122,7 @@ namespace :postgres do
|
|
|
128
122
|
escaped_password = new_password.gsub("'", "''")
|
|
129
123
|
|
|
130
124
|
sql = "ALTER USER #{username} WITH PASSWORD '#{escaped_password}';"
|
|
131
|
-
|
|
132
|
-
execute :chmod, '644', '/tmp/rotate_password.sql'
|
|
133
|
-
execute :sudo, '-u', 'postgres', 'psql', '-f', '/tmp/rotate_password.sql'
|
|
134
|
-
execute :rm, '-f', '/tmp/rotate_password.sql'
|
|
125
|
+
ssh_executor.run_sql_on_backend(self, sql, postgres_user: 'postgres', tuples_only: false, capture: false)
|
|
135
126
|
end
|
|
136
127
|
|
|
137
128
|
puts "✓ Updated #{username}"
|
|
@@ -152,10 +143,7 @@ namespace :postgres do
|
|
|
152
143
|
WHERE rolname = '#{user}'
|
|
153
144
|
SQL
|
|
154
145
|
|
|
155
|
-
|
|
156
|
-
execute :chmod, '644', '/tmp/get_user_hash.sql'
|
|
157
|
-
user_hash = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_user_hash.sql').strip
|
|
158
|
-
execute :rm, '-f', '/tmp/get_user_hash.sql'
|
|
146
|
+
user_hash = ssh_executor.run_sql_on_backend(self, sql, postgres_user: postgres_user).to_s.strip
|
|
159
147
|
|
|
160
148
|
userlist_entries << user_hash if user_hash && !user_hash.empty?
|
|
161
149
|
end
|
data/templates/pg_hba.conf.erb
CHANGED
|
@@ -39,9 +39,12 @@
|
|
|
39
39
|
repmgr_network = pg_hba_rules.find { |r| r[:type] == 'host' && r[:address] && !r[:address].start_with?('127.0.0.1') }&.dig(:address) || '10.8.0.0/24'
|
|
40
40
|
repmgr_user = config.repmgr_user
|
|
41
41
|
repmgr_db = config.repmgr_database
|
|
42
|
+
replication_user = config.replication_user
|
|
42
43
|
%>
|
|
43
44
|
host replication <%= repmgr_user %> <%= repmgr_network %> scram-sha-256
|
|
45
|
+
<% if replication_user && replication_user != repmgr_user %>
|
|
46
|
+
host replication <%= replication_user %> <%= repmgr_network %> scram-sha-256
|
|
47
|
+
<% end %>
|
|
44
48
|
host <%= repmgr_db %> <%= repmgr_user %> <%= repmgr_network %> scram-sha-256
|
|
45
49
|
<% end %>
|
|
46
50
|
|
|
47
|
-
|