active_postgres 0.9.6 → 0.9.7
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/CHANGELOG.md +17 -0
- data/README.md +27 -2
- data/lib/active_postgres/components/pgbackrest.rb +4 -0
- data/lib/active_postgres/components/repmgr.rb +90 -66
- data/lib/active_postgres/configuration/pgbackrest_policy.rb +19 -0
- data/lib/active_postgres/configuration/standby_policy.rb +24 -0
- data/lib/active_postgres/configuration.rb +43 -16
- data/lib/active_postgres/deployment_flow.rb +5 -1
- data/lib/active_postgres/generators/active_postgres/templates/postgres.yml.erb +7 -0
- data/lib/active_postgres/installer.rb +2 -2
- data/lib/active_postgres/ssh_executor.rb +61 -4
- data/lib/active_postgres/standby_deployment_flow.rb +46 -8
- data/lib/active_postgres/version.rb +1 -1
- data/templates/pgbackrest.conf.erb +9 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f7bf86efa4e32bcb1543e317bd6219983f9f42eb0ed0bf8787ff75b6a573a48
|
|
4
|
+
data.tar.gz: 8a2b2f0ec44bdfd94552dfa0fd84145b64ded472b4f421f216c86af71e2d859e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d4a26f7284e14c84bcaa4d1e982da9162a25a098b15f9423b4923e7b7a9ab007112620761e5184a1da514f40495edd00647e8c2a269bbbb0511e2611e44118f
|
|
7
|
+
data.tar.gz: 4dfb7b6e19345e3324d38a798a82bdfa111001cb94736348aa88ba6146f9c3a6bc47fed8977039faefc271d412529f61995ad36f42be93b96a8789437187423e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.9.7 - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Support strict operator SSH through declared jump hosts.
|
|
15
|
+
- Allow a standby to override repmgr failover and priority policy.
|
|
16
|
+
- Allow a standby to be seeded from the latest valid pgBackRest backup.
|
|
17
|
+
- Make deployment-flow `--dry-run` stop before confirmation or mutation.
|
|
18
|
+
- Fail standby setup before mutation when required credentials do not resolve.
|
|
19
|
+
- Scope standby rollback to the requested host and keep existing cluster nodes intact.
|
|
20
|
+
- Preserve pgBackRest recovery options as one shell-safe argument during remote restore.
|
|
21
|
+
- Preserve the complete pgBackRest command as the single `bash -lc` command string.
|
|
22
|
+
- Run component rollback in local Ruby context instead of an SSHKit backend scope.
|
|
23
|
+
- Explicitly stop SysV-backed repmgrd before disabling it on manual-failover standbys.
|
|
24
|
+
- Support opt-in bundled block backups for large, frequently changed PostgreSQL relations.
|
|
25
|
+
- Support independent full, differential, and incremental pgBackRest schedules.
|
|
26
|
+
|
|
10
27
|
## 0.9.6 - 2026-08-20
|
|
11
28
|
|
|
12
29
|
### Changed
|
data/README.md
CHANGED
|
@@ -35,6 +35,11 @@ production:
|
|
|
35
35
|
version: 18
|
|
36
36
|
user: ubuntu
|
|
37
37
|
ssh_key: ~/.ssh/id_rsa
|
|
38
|
+
ssh_known_hosts_file: config/ssh_known_hosts
|
|
39
|
+
|
|
40
|
+
jump_hosts:
|
|
41
|
+
app-oci:
|
|
42
|
+
host: 129.213.194.40
|
|
38
43
|
|
|
39
44
|
primary:
|
|
40
45
|
host: 34.12.234.81 # Public IP for SSH
|
|
@@ -43,6 +48,13 @@ production:
|
|
|
43
48
|
standby:
|
|
44
49
|
- host: 52.23.45.67
|
|
45
50
|
private_ip: 10.8.0.11
|
|
51
|
+
- host: 100.63.4.227
|
|
52
|
+
private_ip: 10.8.0.12
|
|
53
|
+
jump_host: app-oci
|
|
54
|
+
seed_method: pgbackrest
|
|
55
|
+
repmgr:
|
|
56
|
+
auto_failover: false
|
|
57
|
+
priority: 0
|
|
46
58
|
|
|
47
59
|
components:
|
|
48
60
|
repmgr: {enabled: true}
|
|
@@ -56,6 +68,14 @@ production:
|
|
|
56
68
|
app_password: $POSTGRES_APP_PASSWORD
|
|
57
69
|
```
|
|
58
70
|
|
|
71
|
+
`jump_host` keeps the operator SSH key on the workstation and proxies the
|
|
72
|
+
connection through a declared host. A standby-level `repmgr` block overrides
|
|
73
|
+
the cluster defaults, so a cross-provider disaster-recovery replica can remain
|
|
74
|
+
manual-promotion while the ordinary standby retains automatic failover.
|
|
75
|
+
`seed_method: pgbackrest` restores the latest valid backup with standby recovery
|
|
76
|
+
settings before repmgr registration, avoiding a full base-backup stream from
|
|
77
|
+
the primary. The `pgbackrest` component must be enabled.
|
|
78
|
+
|
|
59
79
|
Add credentials (`rails credentials:edit`):
|
|
60
80
|
|
|
61
81
|
```yaml
|
|
@@ -179,8 +199,13 @@ components:
|
|
|
179
199
|
pgbackrest:
|
|
180
200
|
retention_full: 7
|
|
181
201
|
retention_archive: 14
|
|
182
|
-
#
|
|
183
|
-
#
|
|
202
|
+
# Reduce incremental backup churn for large, frequently changed relations.
|
|
203
|
+
# Block backups require repository bundling.
|
|
204
|
+
repo_bundle: true
|
|
205
|
+
repo_block: true
|
|
206
|
+
# You can schedule full, differential, and incremental backups separately:
|
|
207
|
+
# schedule_full: "0 2 * * 0"
|
|
208
|
+
# schedule_differential: "35 2 * * 1-6"
|
|
184
209
|
# schedule_incremental: "0 * * * *"
|
|
185
210
|
```
|
|
186
211
|
|
|
@@ -26,6 +26,7 @@ module ActivePostgres
|
|
|
26
26
|
ssh_executor.execute_on_host(host) do
|
|
27
27
|
execute :sudo, 'apt-get', 'remove', '-y', 'pgbackrest'
|
|
28
28
|
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup'
|
|
29
|
+
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup-differential'
|
|
29
30
|
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup-incremental'
|
|
30
31
|
execute :sudo, 'rm', '-f', LOG_ARCHIVE_CRON_FILE
|
|
31
32
|
execute :sudo, 'rm', '-f', LOG_ARCHIVE_ENV_FILE
|
|
@@ -162,10 +163,12 @@ module ActivePostgres
|
|
|
162
163
|
|
|
163
164
|
def backup_schedules(pgbackrest_config)
|
|
164
165
|
schedule_full = pgbackrest_config[:schedule_full] || pgbackrest_config[:schedule]
|
|
166
|
+
schedule_differential = pgbackrest_config[:schedule_differential]
|
|
165
167
|
schedule_incremental = pgbackrest_config[:schedule_incremental]
|
|
166
168
|
|
|
167
169
|
schedules = []
|
|
168
170
|
schedules << { type: 'full', schedule: schedule_full, file: '/etc/cron.d/pgbackrest-backup' } if schedule_full
|
|
171
|
+
schedules << { type: 'diff', schedule: schedule_differential, file: '/etc/cron.d/pgbackrest-backup-differential' } if schedule_differential
|
|
169
172
|
schedules << { type: 'incr', schedule: schedule_incremental, file: '/etc/cron.d/pgbackrest-backup-incremental' } if schedule_incremental
|
|
170
173
|
|
|
171
174
|
schedules
|
|
@@ -192,6 +195,7 @@ module ActivePostgres
|
|
|
192
195
|
def remove_backup_schedule(host)
|
|
193
196
|
ssh_executor.execute_on_host(host) do
|
|
194
197
|
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup'
|
|
198
|
+
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup-differential'
|
|
195
199
|
execute :sudo, 'rm', '-f', '/etc/cron.d/pgbackrest-backup-incremental'
|
|
196
200
|
end
|
|
197
201
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
|
|
1
3
|
module ActivePostgres
|
|
2
4
|
module Components
|
|
3
5
|
class Repmgr < Base
|
|
@@ -22,31 +24,24 @@ module ActivePostgres
|
|
|
22
24
|
def uninstall
|
|
23
25
|
puts 'Uninstalling repmgr...'
|
|
24
26
|
|
|
25
|
-
config.all_hosts.each
|
|
26
|
-
|
|
27
|
-
node_id = if is_primary
|
|
28
|
-
1
|
|
29
|
-
else
|
|
30
|
-
begin
|
|
31
|
-
config.standby_hosts.index(host) + 2
|
|
32
|
-
rescue StandardError
|
|
33
|
-
999
|
|
34
|
-
end
|
|
35
|
-
end
|
|
27
|
+
config.all_hosts.each { |host| uninstall_from(host) }
|
|
28
|
+
end
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
capture(:sudo, '-u', 'postgres', 'repmgr', 'cluster', 'show')
|
|
40
|
-
rescue StandardError
|
|
41
|
-
''
|
|
42
|
-
end
|
|
30
|
+
def uninstall_from(host)
|
|
31
|
+
node_id = host == config.primary_host ? 1 : (config.standby_hosts.index(host) || 997) + 2
|
|
43
32
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
ssh_executor.execute_on_host(host) do
|
|
34
|
+
cluster_output = begin
|
|
35
|
+
capture(:sudo, '-u', 'postgres', 'repmgr', 'cluster', 'show')
|
|
36
|
+
rescue StandardError
|
|
37
|
+
''
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if cluster_output.include?("| #{node_id}") && cluster_output.include?('running')
|
|
41
|
+
info "✓ Skipping repmgr removal from #{host} - node #{node_id} is running"
|
|
42
|
+
else
|
|
43
|
+
info "Removing repmgr from #{host}"
|
|
44
|
+
execute :sudo, 'apt-get', 'remove', '-y', '-q', 'postgresql-*-repmgr', '||', 'true'
|
|
50
45
|
end
|
|
51
46
|
end
|
|
52
47
|
end
|
|
@@ -62,7 +57,7 @@ module ActivePostgres
|
|
|
62
57
|
end
|
|
63
58
|
|
|
64
59
|
def setup_standby_only(standby_host)
|
|
65
|
-
puts "Setting up standby #{standby_host} (primary
|
|
60
|
+
puts "Setting up standby #{standby_host} (primary stays online)..."
|
|
66
61
|
|
|
67
62
|
version = config.version
|
|
68
63
|
install_apt_packages(standby_host, "postgresql-#{version}-repmgr")
|
|
@@ -103,7 +98,7 @@ module ActivePostgres
|
|
|
103
98
|
puts ' Setting up primary with repmgr...'
|
|
104
99
|
|
|
105
100
|
host = config.primary_host
|
|
106
|
-
repmgr_config = config.
|
|
101
|
+
repmgr_config = config.repmgr_config_for(host)
|
|
107
102
|
version = config.version
|
|
108
103
|
repmgr_password = normalize_repmgr_password(secrets.resolve('repmgr_password'))
|
|
109
104
|
replication_password = normalize_replication_password(secrets.resolve('replication_password'))
|
|
@@ -236,7 +231,7 @@ module ActivePostgres
|
|
|
236
231
|
puts " Setting up standby: #{standby_host}..."
|
|
237
232
|
|
|
238
233
|
host = standby_host
|
|
239
|
-
repmgr_config = config.
|
|
234
|
+
repmgr_config = config.repmgr_config_for(standby_host)
|
|
240
235
|
primary_replication_host = config.primary_replication_host
|
|
241
236
|
version = config.version
|
|
242
237
|
secrets_obj = secrets
|
|
@@ -244,6 +239,11 @@ module ActivePostgres
|
|
|
244
239
|
repmgr_db = config.repmgr_database
|
|
245
240
|
postgres_user = config.postgres_user
|
|
246
241
|
replication_user = config.replication_user
|
|
242
|
+
seed_method = config.standby_seed_method_for(standby_host)
|
|
243
|
+
restore_arguments = if seed_method == :pgbackrest
|
|
244
|
+
standby_label = config.node_label_for(standby_host) || "standby-#{standby_host.split('.').first}"
|
|
245
|
+
pgbackrest_restore_arguments(build_primary_conninfo(standby_label))
|
|
246
|
+
end
|
|
247
247
|
|
|
248
248
|
# Variables used in ERB templates via binding
|
|
249
249
|
_ = host
|
|
@@ -300,58 +300,61 @@ module ActivePostgres
|
|
|
300
300
|
nil
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
303
|
+
if seed_method == :repmgr
|
|
304
|
+
info 'Cloning from primary over private network...'
|
|
305
|
+
# Create a temporary repmgr config for cloning that points to the primary.
|
|
306
|
+
# The regular config points to localhost, which does not work during the initial clone.
|
|
307
|
+
temp_repmgr_conf = <<~CONF
|
|
308
|
+
node_id=#{node_id}
|
|
309
|
+
node_name='#{standby_host}'
|
|
310
|
+
conninfo='host=#{primary_replication_host} user=#{repmgr_user} dbname=#{repmgr_db} connect_timeout=10'
|
|
311
|
+
data_directory='/var/lib/postgresql/#{version}/main'
|
|
312
|
+
CONF
|
|
313
|
+
|
|
314
|
+
info 'Creating temporary repmgr config for cloning...'
|
|
315
|
+
upload! StringIO.new(temp_repmgr_conf), '/tmp/repmgr_clone.conf'
|
|
316
|
+
execute :sudo, 'mv', '/tmp/repmgr_clone.conf', '/etc/repmgr_clone.conf'
|
|
317
|
+
execute :sudo, 'chown', 'postgres:postgres', '/etc/repmgr_clone.conf'
|
|
318
|
+
execute :sudo, 'chmod', '600', '/etc/repmgr_clone.conf'
|
|
319
|
+
info 'Running: repmgr standby clone (this may take a few minutes to copy the database)...'
|
|
320
|
+
else
|
|
321
|
+
info 'Restoring the latest valid pgBackRest backup as a standby...'
|
|
322
|
+
end
|
|
320
323
|
|
|
321
324
|
clone_success = false
|
|
322
325
|
begin
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
326
|
+
if seed_method == :pgbackrest
|
|
327
|
+
execute(*restore_arguments)
|
|
328
|
+
info 'pgBackRest standby restore completed'
|
|
329
|
+
else
|
|
330
|
+
execute :sudo, '-u', postgres_user, 'env',
|
|
331
|
+
'HOME=/var/lib/postgresql',
|
|
332
|
+
'repmgr', 'standby', 'clone',
|
|
333
|
+
'-h', primary_replication_host,
|
|
334
|
+
'-U', repmgr_user,
|
|
335
|
+
'-d', repmgr_db,
|
|
336
|
+
'-f', '/etc/repmgr_clone.conf',
|
|
337
|
+
'--force',
|
|
338
|
+
'--verbose'
|
|
339
|
+
info 'Clone command completed'
|
|
340
|
+
end
|
|
334
341
|
|
|
335
|
-
# Check if clone actually succeeded
|
|
336
342
|
if test(:sudo, 'test', '-d', "/var/lib/postgresql/#{version}/main")
|
|
337
343
|
clone_success = true
|
|
338
|
-
info '
|
|
339
|
-
|
|
340
|
-
# Clean up temporary clone config
|
|
341
|
-
execute :sudo, 'rm', '-f', '/etc/repmgr_clone.conf'
|
|
344
|
+
info 'Standby data directory successfully seeded!'
|
|
345
|
+
execute :sudo, 'rm', '-f', '/etc/repmgr_clone.conf' if seed_method == :repmgr
|
|
342
346
|
else
|
|
343
|
-
error "Data directory /var/lib/postgresql/#{version}/main was not created even though
|
|
347
|
+
error "Data directory /var/lib/postgresql/#{version}/main was not created even though seeding succeeded!"
|
|
344
348
|
end
|
|
345
349
|
rescue SSHKit::Command::Failed => e
|
|
346
|
-
error "
|
|
350
|
+
error "Standby seed command failed with exit code #{e.message}"
|
|
347
351
|
rescue StandardError => e
|
|
348
|
-
error "
|
|
352
|
+
error "Standby seed command raised exception: #{e.class}: #{e.message}"
|
|
349
353
|
end
|
|
350
354
|
|
|
351
|
-
# If clone failed, raise error
|
|
352
355
|
unless clone_success
|
|
353
|
-
error '✗
|
|
354
|
-
raise '
|
|
356
|
+
error '✗ Standby data seeding failed'
|
|
357
|
+
raise 'standby data seeding failed to create data directory'
|
|
355
358
|
end
|
|
356
359
|
end
|
|
357
360
|
|
|
@@ -907,6 +910,21 @@ module ActivePostgres
|
|
|
907
910
|
"host=#{primary_host} user=#{user} dbname=#{dbname} application_name=#{standby_label}"
|
|
908
911
|
end
|
|
909
912
|
|
|
913
|
+
def pgbackrest_restore_arguments(primary_conninfo)
|
|
914
|
+
command = Shellwords.join(
|
|
915
|
+
[
|
|
916
|
+
'pgbackrest', '--stanza=main', '--type=standby',
|
|
917
|
+
"--recovery-option=primary_conninfo=#{primary_conninfo}",
|
|
918
|
+
'restore'
|
|
919
|
+
]
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
[
|
|
923
|
+
:sudo, '-u', config.postgres_user,
|
|
924
|
+
:bash, '-lc', Shellwords.escape(command)
|
|
925
|
+
]
|
|
926
|
+
end
|
|
927
|
+
|
|
910
928
|
def normalize_repmgr_password(raw_password)
|
|
911
929
|
password = raw_password.to_s.rstrip
|
|
912
930
|
|
|
@@ -1101,7 +1119,13 @@ module ActivePostgres
|
|
|
1101
1119
|
end
|
|
1102
1120
|
|
|
1103
1121
|
def enable_repmgrd_if_configured(host, repmgr_config)
|
|
1104
|
-
|
|
1122
|
+
if repmgr_config[:auto_failover] == false
|
|
1123
|
+
ssh_executor.execute_on_host(host) do
|
|
1124
|
+
execute :sudo, 'systemctl', 'stop', 'repmgrd', raise_on_non_zero_exit: false
|
|
1125
|
+
execute :sudo, 'systemctl', 'disable', 'repmgrd', raise_on_non_zero_exit: false
|
|
1126
|
+
end
|
|
1127
|
+
return
|
|
1128
|
+
end
|
|
1105
1129
|
|
|
1106
1130
|
default_config = <<~CONF
|
|
1107
1131
|
REPMGRD_ENABLED=yes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class Configuration
|
|
3
|
+
module PgBackRestPolicy
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def validate_pgbackrest!
|
|
7
|
+
return unless component_enabled?(:pgbackrest)
|
|
8
|
+
|
|
9
|
+
pg_config = component_config(:pgbackrest)
|
|
10
|
+
retention_full = pg_config[:retention_full]
|
|
11
|
+
retention_archive = pg_config[:retention_archive]
|
|
12
|
+
if retention_full && retention_archive && retention_archive.to_i < retention_full.to_i
|
|
13
|
+
raise Error, 'pgbackrest.retention_archive must be >= retention_full for PITR safety'
|
|
14
|
+
end
|
|
15
|
+
raise Error, 'pgbackrest.repo_block requires repo_bundle' if pg_config[:repo_block] && !pg_config[:repo_bundle]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class Configuration
|
|
3
|
+
module StandbyPolicy
|
|
4
|
+
def standby_seed_method_for(host)
|
|
5
|
+
(standby_config_for(host)&.fetch('seed_method', nil) || 'repmgr').to_s.to_sym
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def validate_standby_seed_methods!
|
|
11
|
+
@standbys.each do |standby|
|
|
12
|
+
host = standby.fetch('host')
|
|
13
|
+
seed_method = standby_seed_method_for(host)
|
|
14
|
+
unless %i[repmgr pgbackrest].include?(seed_method)
|
|
15
|
+
raise Error, "Invalid seed_method '#{seed_method}' for standby #{host}. Use 'repmgr' or 'pgbackrest'."
|
|
16
|
+
end
|
|
17
|
+
if seed_method == :pgbackrest && !component_enabled?(:pgbackrest)
|
|
18
|
+
raise Error, "Standby #{host} requires the pgbackrest component for seed_method 'pgbackrest'"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
|
+
require_relative 'configuration/pgbackrest_policy'
|
|
3
|
+
require_relative 'configuration/standby_policy'
|
|
2
4
|
|
|
3
5
|
module ActivePostgres
|
|
4
6
|
class Configuration
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
include PgBackRestPolicy
|
|
8
|
+
include StandbyPolicy
|
|
9
|
+
|
|
10
|
+
attr_reader :environment, :version, :user, :ssh_key, :ssh_known_hosts_file, :ssh_host_key_verification,
|
|
11
|
+
:primary, :standbys, :jump_hosts, :components, :secrets_config, :database_config
|
|
7
12
|
|
|
8
13
|
def initialize(config_hash, environment = 'development')
|
|
9
14
|
@environment = environment
|
|
@@ -14,6 +19,7 @@ module ActivePostgres
|
|
|
14
19
|
@version = env_config['version'] || 18
|
|
15
20
|
@user = env_config['user'] || 'ubuntu'
|
|
16
21
|
@ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
|
|
22
|
+
@ssh_known_hosts_file = optional_path(env_config['ssh_known_hosts_file'])
|
|
17
23
|
@ssh_host_key_verification = normalize_ssh_host_key_verification(
|
|
18
24
|
env_config['ssh_host_key_verification'] || env_config['ssh_verify_host_key']
|
|
19
25
|
)
|
|
@@ -21,6 +27,7 @@ module ActivePostgres
|
|
|
21
27
|
@primary = env_config['primary'] || {}
|
|
22
28
|
@standbys = env_config['standby'] || []
|
|
23
29
|
@standbys = [@standbys] unless @standbys.is_a?(Array)
|
|
30
|
+
@jump_hosts = env_config['jump_hosts'] || {}
|
|
24
31
|
|
|
25
32
|
@components = parse_components(env_config['components'] || {})
|
|
26
33
|
@secrets_config = env_config['secrets'] || {}
|
|
@@ -111,6 +118,24 @@ module ActivePostgres
|
|
|
111
118
|
@standbys.find { |s| s['host'] == host }
|
|
112
119
|
end
|
|
113
120
|
|
|
121
|
+
def node_config_for(host)
|
|
122
|
+
return @primary if host == primary_host
|
|
123
|
+
|
|
124
|
+
standby_config_for(host)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def jump_host_config_for(host)
|
|
128
|
+
jump_host_name = node_config_for(host)&.dig('jump_host')
|
|
129
|
+
return unless jump_host_name
|
|
130
|
+
|
|
131
|
+
@jump_hosts[jump_host_name] || raise(Error, "Host #{host} jump_host not found: #{jump_host_name}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def repmgr_config_for(host)
|
|
135
|
+
overrides = node_config_for(host)&.dig('repmgr') || {}
|
|
136
|
+
component_config(:repmgr).merge(symbolize_keys(overrides))
|
|
137
|
+
end
|
|
138
|
+
|
|
114
139
|
def node_label_for(host)
|
|
115
140
|
if host == primary_host
|
|
116
141
|
@primary['label']
|
|
@@ -133,14 +158,7 @@ module ActivePostgres
|
|
|
133
158
|
raise Error, 'monitoring.grafana.host is required when grafana is enabled'
|
|
134
159
|
end
|
|
135
160
|
end
|
|
136
|
-
|
|
137
|
-
pg_config = component_config(:pgbackrest)
|
|
138
|
-
retention_full = pg_config[:retention_full]
|
|
139
|
-
retention_archive = pg_config[:retention_archive]
|
|
140
|
-
if retention_full && retention_archive && retention_archive.to_i < retention_full.to_i
|
|
141
|
-
raise Error, 'pgbackrest.retention_archive must be >= retention_full for PITR safety'
|
|
142
|
-
end
|
|
143
|
-
end
|
|
161
|
+
validate_pgbackrest!
|
|
144
162
|
|
|
145
163
|
if component_enabled?(:pgbouncer)
|
|
146
164
|
pgbouncer_app_hosts.each do |app_host|
|
|
@@ -149,6 +167,17 @@ module ActivePostgres
|
|
|
149
167
|
end
|
|
150
168
|
end
|
|
151
169
|
|
|
170
|
+
all_hosts.each do |host|
|
|
171
|
+
jump_host_name = node_config_for(host)&.dig('jump_host')
|
|
172
|
+
next unless jump_host_name
|
|
173
|
+
|
|
174
|
+
jump_host = @jump_hosts[jump_host_name]
|
|
175
|
+
raise Error, "Host #{host} jump_host not found: #{jump_host_name}" unless jump_host
|
|
176
|
+
raise Error, "Jump host #{jump_host_name} must include host" if jump_host['host'].to_s.strip.empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
validate_standby_seed_methods!
|
|
180
|
+
|
|
152
181
|
if component_enabled?(:repmgr)
|
|
153
182
|
dns_failover = component_config(:repmgr)[:dns_failover]
|
|
154
183
|
if dns_failover && dns_failover[:enabled]
|
|
@@ -246,12 +275,6 @@ module ActivePostgres
|
|
|
246
275
|
result
|
|
247
276
|
end
|
|
248
277
|
|
|
249
|
-
def node_config_for(host)
|
|
250
|
-
return @primary if host == primary_host
|
|
251
|
-
|
|
252
|
-
standby_config_for(host)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
278
|
def private_ip_for(node_config)
|
|
256
279
|
return unless node_config
|
|
257
280
|
|
|
@@ -295,5 +318,9 @@ module ActivePostgres
|
|
|
295
318
|
raise Error,
|
|
296
319
|
"Invalid ssh_host_key_verification '#{value}'. Use 'always' or 'accept_new'."
|
|
297
320
|
end
|
|
321
|
+
|
|
322
|
+
def optional_path(path)
|
|
323
|
+
File.expand_path(path) if path
|
|
324
|
+
end
|
|
298
325
|
end
|
|
299
326
|
end
|
|
@@ -13,12 +13,16 @@ module ActivePostgres
|
|
|
13
13
|
@skip_validation = skip_validation
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def execute
|
|
16
|
+
def execute(dry_run: false)
|
|
17
17
|
ErrorHandler.with_handling(context: { operation: operation_name }) do
|
|
18
18
|
print_header
|
|
19
19
|
validate_prerequisites
|
|
20
20
|
run_preflight_checks unless skip_validation
|
|
21
21
|
print_deployment_plan
|
|
22
|
+
if dry_run
|
|
23
|
+
logger.info "\n[DRY RUN] No changes made."
|
|
24
|
+
return
|
|
25
|
+
end
|
|
22
26
|
return unless confirm_deployment
|
|
23
27
|
|
|
24
28
|
rollback_manager.with_rollback(description: operation_name) do
|
|
@@ -57,6 +57,13 @@ shared: &shared
|
|
|
57
57
|
|
|
58
58
|
pgbackrest:
|
|
59
59
|
enabled: false
|
|
60
|
+
# For large, frequently changed relation files, bundle repository objects
|
|
61
|
+
# and back up changed blocks instead of copying each whole file.
|
|
62
|
+
# repo_bundle: true
|
|
63
|
+
# repo_block: true
|
|
64
|
+
# schedule_full: "0 2 * * 0"
|
|
65
|
+
# schedule_differential: "35 2 * * 1-6"
|
|
66
|
+
# schedule_incremental: "5 */4 * * *"
|
|
60
67
|
|
|
61
68
|
monitoring:
|
|
62
69
|
enabled: false
|
|
@@ -28,7 +28,7 @@ module ActivePostgres
|
|
|
28
28
|
rollback_manager: rollback_manager,
|
|
29
29
|
skip_validation: skip_validation
|
|
30
30
|
)
|
|
31
|
-
flow.execute
|
|
31
|
+
flow.execute(dry_run: dry_run)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def setup_component(component_name)
|
|
@@ -117,7 +117,7 @@ module ActivePostgres
|
|
|
117
117
|
rollback_manager: rollback_manager,
|
|
118
118
|
skip_validation: skip_validation
|
|
119
119
|
)
|
|
120
|
-
flow.execute
|
|
120
|
+
flow.execute(dry_run: dry_run)
|
|
121
121
|
end
|
|
122
122
|
end
|
|
123
123
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require 'sshkit'
|
|
2
2
|
require 'sshkit/dsl'
|
|
3
|
+
require 'net/ssh/proxy/command'
|
|
3
4
|
require 'securerandom'
|
|
5
|
+
require 'shellwords'
|
|
4
6
|
require 'stringio'
|
|
5
7
|
|
|
6
8
|
module ActivePostgres
|
|
@@ -20,11 +22,11 @@ module ActivePostgres
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def execute_on_host(host, &)
|
|
23
|
-
on(
|
|
25
|
+
on(ssh_host_for(host), &)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def execute_on_host_as(host, user, &)
|
|
27
|
-
on(
|
|
29
|
+
on(ssh_host_for(host, user:), &)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def execute_on_primary(&)
|
|
@@ -32,12 +34,12 @@ module ActivePostgres
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def execute_on_standbys(&)
|
|
35
|
-
hosts = config.standby_hosts.map { |
|
|
37
|
+
hosts = config.standby_hosts.map { |host| ssh_host_for(host) }
|
|
36
38
|
on(hosts, in: :parallel, &)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def execute_on_all_hosts(&)
|
|
40
|
-
hosts = config.all_hosts.map { |
|
|
42
|
+
hosts = config.all_hosts.map { |host| ssh_host_for(host) }
|
|
41
43
|
on(hosts, in: :parallel, &)
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -307,6 +309,8 @@ module ActivePostgres
|
|
|
307
309
|
number_of_password_prompts: 0
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file
|
|
313
|
+
|
|
310
314
|
if config.ssh_key && File.exist?(config.ssh_key)
|
|
311
315
|
options[:keys] = [config.ssh_key]
|
|
312
316
|
options[:keys_only] = true
|
|
@@ -315,5 +319,58 @@ module ActivePostgres
|
|
|
315
319
|
ssh.ssh_options = options
|
|
316
320
|
end
|
|
317
321
|
end
|
|
322
|
+
|
|
323
|
+
def ssh_host_for(host, user: config.user)
|
|
324
|
+
ssh_host = SSHKit::Host.new(host)
|
|
325
|
+
ssh_host.user = user
|
|
326
|
+
|
|
327
|
+
node_config = config.node_config_for(host) || {}
|
|
328
|
+
ssh_host.port = node_config['ssh_port'] || node_config['port'] if node_config['ssh_port'] || node_config['port']
|
|
329
|
+
|
|
330
|
+
options = {
|
|
331
|
+
verify_host_key: config.ssh_host_key_verification || :always
|
|
332
|
+
}
|
|
333
|
+
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file
|
|
334
|
+
|
|
335
|
+
if node_config['ssh_key']
|
|
336
|
+
options[:keys] = [File.expand_path(node_config['ssh_key'])]
|
|
337
|
+
options[:keys_only] = true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if (jump_host_config = config.jump_host_config_for(host))
|
|
341
|
+
options[:proxy] = jump_proxy(jump_host_config)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
ssh_host.ssh_options = options
|
|
345
|
+
ssh_host
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def jump_proxy(jump_host_config)
|
|
349
|
+
jump_user = jump_host_config['ssh_user'] || jump_host_config['user'] || config.user
|
|
350
|
+
jump_port = jump_host_config['ssh_port'] || jump_host_config['port']
|
|
351
|
+
jump_key = jump_host_config['ssh_key'] || config.ssh_key
|
|
352
|
+
command = [
|
|
353
|
+
'ssh',
|
|
354
|
+
'-F', '/dev/null',
|
|
355
|
+
'-o', 'BatchMode=yes',
|
|
356
|
+
'-o', 'ForwardAgent=no',
|
|
357
|
+
'-o', "StrictHostKeyChecking=#{open_ssh_host_key_mode}"
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
command.push('-o', "UserKnownHostsFile=#{config.ssh_known_hosts_file}") if config.ssh_known_hosts_file
|
|
361
|
+
|
|
362
|
+
command.push('-i', File.expand_path(jump_key), '-o', 'IdentitiesOnly=yes') if
|
|
363
|
+
jump_key && File.exist?(File.expand_path(jump_key))
|
|
364
|
+
|
|
365
|
+
command.push('-p', jump_port.to_s) if jump_port
|
|
366
|
+
command.push('-W', '%h:%p', "#{jump_user}@#{jump_host_config.fetch('host')}")
|
|
367
|
+
|
|
368
|
+
command_line = Shellwords.join(command).gsub('\\%h', '%h').gsub('\\%p', '%p')
|
|
369
|
+
Net::SSH::Proxy::Command.new(command_line)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def open_ssh_host_key_mode
|
|
373
|
+
config.ssh_host_key_verification == :accept_new ? 'accept-new' : 'yes'
|
|
374
|
+
end
|
|
318
375
|
end
|
|
319
376
|
end
|
|
@@ -21,9 +21,9 @@ module ActivePostgres
|
|
|
21
21
|
def validate_specific_requirements
|
|
22
22
|
abort "❌ #{standby_host} is not configured as a standby in config/postgres.yml" unless config.standby_hosts.include?(standby_host)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
abort '❌ repmgr component must be enabled to setup standbys' unless config.component_enabled?(:repmgr)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
validate_required_secrets!
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def run_preflight_checks
|
|
@@ -49,17 +49,22 @@ module ActivePostgres
|
|
|
49
49
|
|
|
50
50
|
def list_deployment_steps
|
|
51
51
|
logger.info " • Install PostgreSQL #{config.version} packages on #{standby_host}"
|
|
52
|
-
|
|
52
|
+
if config.standby_seed_method_for(standby_host) == :pgbackrest
|
|
53
|
+
logger.info ' • Restore the latest valid pgBackRest backup as a standby'
|
|
54
|
+
else
|
|
55
|
+
logger.info " • Clone data from primary: #{config.primary_host}"
|
|
56
|
+
end
|
|
53
57
|
logger.info ' • Register standby with repmgr cluster'
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def list_warnings
|
|
57
|
-
logger.info "\n⚠️ Primary
|
|
61
|
+
logger.info "\n⚠️ Primary stays online; setup performs idempotent role and repmgr checks"
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def deploy_components
|
|
61
65
|
deploy_ssl if config.component_enabled?(:ssl)
|
|
62
66
|
deploy_core
|
|
67
|
+
deploy_pgbackrest if config.component_enabled?(:pgbackrest)
|
|
63
68
|
deploy_repmgr
|
|
64
69
|
deploy_optional_components
|
|
65
70
|
end
|
|
@@ -79,8 +84,15 @@ module ActivePostgres
|
|
|
79
84
|
end
|
|
80
85
|
end
|
|
81
86
|
|
|
87
|
+
def deploy_pgbackrest
|
|
88
|
+
logger.task('Installing pgBackRest on standby') do
|
|
89
|
+
component = Components::PgBackRest.new(config, ssh_executor, secrets)
|
|
90
|
+
component.install_on_standby(standby_host)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
def deploy_repmgr
|
|
83
|
-
logger.task('Setting up repmgr and
|
|
95
|
+
logger.task('Setting up repmgr and seeding standby') do
|
|
84
96
|
component = Components::Repmgr.new(config, ssh_executor, secrets)
|
|
85
97
|
register_rollback('repmgr', component)
|
|
86
98
|
component.setup_standby_only(standby_host)
|
|
@@ -107,13 +119,39 @@ module ActivePostgres
|
|
|
107
119
|
end
|
|
108
120
|
|
|
109
121
|
def register_rollback(component_name, component)
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
target_host = standby_host
|
|
123
|
+
captured_logger = logger
|
|
124
|
+
|
|
125
|
+
rollback_manager.register("Uninstall #{component_name} on #{target_host}") do
|
|
126
|
+
if component.respond_to?(:uninstall_from)
|
|
127
|
+
component.uninstall_from(target_host)
|
|
128
|
+
else
|
|
129
|
+
component.uninstall
|
|
130
|
+
end
|
|
112
131
|
rescue StandardError => e
|
|
113
|
-
|
|
132
|
+
captured_logger.warn "Failed to uninstall #{component_name} on #{target_host}: #{e.message}"
|
|
114
133
|
end
|
|
115
134
|
end
|
|
116
135
|
|
|
136
|
+
def validate_required_secrets!
|
|
137
|
+
required = %w[repmgr_password replication_password]
|
|
138
|
+
required.push('ssl_cert', 'ssl_key') if config.component_enabled?(:ssl)
|
|
139
|
+
required << 'monitoring_password' if config.component_enabled?(:monitoring)
|
|
140
|
+
|
|
141
|
+
if config.component_enabled?(:pgbackrest)
|
|
142
|
+
pgbackrest = secrets.resolve_value(config.component_config(:pgbackrest))
|
|
143
|
+
if pgbackrest[:repo_type] == 's3'
|
|
144
|
+
required.push('s3_access_key', 's3_secret_key')
|
|
145
|
+
raise Error, 'pgbackrest.s3_bucket did not resolve' if pgbackrest[:s3_bucket].to_s.empty?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
missing = required.uniq.select { |key| secrets.resolve(key).to_s.empty? }
|
|
150
|
+
return if missing.empty?
|
|
151
|
+
|
|
152
|
+
raise Error, "Required standby secrets did not resolve: #{missing.join(', ')}"
|
|
153
|
+
end
|
|
154
|
+
|
|
117
155
|
def list_next_steps
|
|
118
156
|
logger.info ' 1. Check cluster status: active_postgres status'
|
|
119
157
|
logger.info ' 2. Verify replication: Check repmgr cluster show'
|
|
@@ -40,6 +40,15 @@ repo1-path=<%= pgbackrest_config[:repo_path] || '/var/lib/pgbackrest' %>
|
|
|
40
40
|
repo1-retention-full=<%= pgbackrest_config[:retention_full] || 7 %>
|
|
41
41
|
repo1-retention-archive=<%= pgbackrest_config[:retention_archive] || 14 %>
|
|
42
42
|
|
|
43
|
+
<% if pgbackrest_config[:repo_bundle] %>
|
|
44
|
+
# Bundle small files and metadata into fewer repository objects.
|
|
45
|
+
repo1-bundle=y
|
|
46
|
+
<% end %>
|
|
47
|
+
<% if pgbackrest_config[:repo_block] %>
|
|
48
|
+
# Back up changed blocks instead of entire relation files.
|
|
49
|
+
repo1-block=y
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
43
52
|
# Compression
|
|
44
53
|
compress-type=lz4
|
|
45
54
|
compress-level=3
|
|
@@ -69,4 +78,3 @@ pg1-path=/var/lib/postgresql/<%= config.version %>/main
|
|
|
69
78
|
pg1-port=5432
|
|
70
79
|
pg1-socket-path=/var/run/postgresql
|
|
71
80
|
pg1-user=<%= config.postgres_user %>
|
|
72
|
-
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_postgres
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BoringCache
|
|
@@ -126,6 +126,8 @@ files:
|
|
|
126
126
|
- lib/active_postgres/components/repmgr.rb
|
|
127
127
|
- lib/active_postgres/components/ssl.rb
|
|
128
128
|
- lib/active_postgres/configuration.rb
|
|
129
|
+
- lib/active_postgres/configuration/pgbackrest_policy.rb
|
|
130
|
+
- lib/active_postgres/configuration/standby_policy.rb
|
|
129
131
|
- lib/active_postgres/connection_pooler.rb
|
|
130
132
|
- lib/active_postgres/credentials.rb
|
|
131
133
|
- lib/active_postgres/deployment_flow.rb
|