active_postgres 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d06925c9a64ca3f5b96402257fda9ad13f32bbd0e3ac5377032d737c71fb2ea
4
- data.tar.gz: 19cde3ee7e86d325378661aa72350d51d1bfebf33a2e6f03a1c90d34c987a997
3
+ metadata.gz: c3d6f986ee2ac96f5cdd8b69541a5acae87733f506b10efb94c1046082b990a9
4
+ data.tar.gz: f64bb049c6601cf853de16c6cfeff8293ebb06d622db9633330298f02ef82135
5
5
  SHA512:
6
- metadata.gz: 2dbcc33b6ad2ffadeeea4145bc5bc36910203002af908030893298df2005089e87fa8e5d465a425ffae11c60ee01b66f301e1ae594e3bb662d747c0c342b20cb
7
- data.tar.gz: 2772e4a192f87c60d54ccaaeb5ea2952f7a98ce34f3daec5839e77758179f75f3f9ac09a76ab74bc929311b8b417e074e5ae156f286bbba55423c3a923b47d77
6
+ metadata.gz: b07c93095ab642d39d6d658e93ef82c7ca993a30cf06b3f320d02e20dc5f45a546ffcdb2196f5bae0d1ab2051fb3a453a0a445e3e3f49b89ec1e3be9ee19f042
7
+ data.tar.gz: aeae944d32c7ee5f661470eaee9f66c01ef47c1bb4f578bbae0fd8d3e50f81044d24ddf49f85304a5aa6437de207aa22600cd3cc6cf7167713c5f87204e3cee9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.9.8 - 2026-10-02
11
+
12
+ ### Fixed
13
+
14
+ - Support explicit node IDs so repmgr identities survive primary changes and standby reordering.
15
+ - Reject duplicate or invalid node IDs before deployment.
16
+
17
+ ## 0.9.7 - 2026-09-01
18
+
19
+ ### Added
20
+
21
+ - Support strict operator SSH through declared jump hosts.
22
+ - Allow a standby to override repmgr failover and priority policy.
23
+ - Allow a standby to be seeded from the latest valid pgBackRest backup.
24
+ - Make deployment-flow `--dry-run` stop before confirmation or mutation.
25
+ - Fail standby setup before mutation when required credentials do not resolve.
26
+ - Scope standby rollback to the requested host and keep existing cluster nodes intact.
27
+ - Preserve pgBackRest recovery options as one shell-safe argument during remote restore.
28
+ - Preserve the complete pgBackRest command as the single `bash -lc` command string.
29
+ - Run component rollback in local Ruby context instead of an SSHKit backend scope.
30
+ - Explicitly stop SysV-backed repmgrd before disabling it on manual-failover standbys.
31
+ - Support opt-in bundled block backups for large, frequently changed PostgreSQL relations.
32
+ - Support independent full, differential, and incremental pgBackRest schedules.
33
+
10
34
  ## 0.9.6 - 2026-08-20
11
35
 
12
36
  ### 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,20 @@ 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
+
79
+ Set a unique positive integer `node_id` on every primary and standby entry
80
+ before permanently changing their roles or reordering standbys. Match the
81
+ existing IDs in `repmgr cluster show`, and keep each ID with its host after a
82
+ switchover. Without explicit IDs, the primary defaults to 1 and standbys use
83
+ their list position starting at 2. Duplicate or invalid IDs are rejected.
84
+
59
85
  Add credentials (`rails credentials:edit`):
60
86
 
61
87
  ```yaml
@@ -179,8 +205,13 @@ components:
179
205
  pgbackrest:
180
206
  retention_full: 7
181
207
  retention_archive: 14
182
- # You can schedule full + incremental separately:
183
- # schedule_full: "0 2 * * *"
208
+ # Reduce incremental backup churn for large, frequently changed relations.
209
+ # Block backups require repository bundling.
210
+ repo_bundle: true
211
+ repo_block: true
212
+ # You can schedule full, differential, and incremental backups separately:
213
+ # schedule_full: "0 2 * * 0"
214
+ # schedule_differential: "35 2 * * 1-6"
184
215
  # schedule_incremental: "0 * * * *"
185
216
  ```
186
217
 
@@ -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 do |host|
26
- is_primary = host == config.primary_host
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
- ssh_executor.execute_on_host(host) do
38
- cluster_output = begin
39
- capture(:sudo, '-u', 'postgres', 'repmgr', 'cluster', 'show')
40
- rescue StandardError
41
- ''
42
- end
30
+ def uninstall_from(host)
31
+ node_id = config.node_id_for(host)
43
32
 
44
- if cluster_output.include?("| #{node_id}") && cluster_output.include?('running')
45
- info "✓ Skipping repmgr removal from #{host} - node #{node_id} is running"
46
- else
47
- info "Removing repmgr from #{host}"
48
- execute :sudo, 'apt-get', 'remove', '-y', '-q', 'postgresql-*-repmgr', '||', 'true'
49
- end
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 will not be touched)..."
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,8 @@ module ActivePostgres
103
98
  puts ' Setting up primary with repmgr...'
104
99
 
105
100
  host = config.primary_host
106
- repmgr_config = config.component_config(:repmgr)
101
+ primary_node_id = config.node_id_for(host)
102
+ repmgr_config = config.repmgr_config_for(host)
107
103
  version = config.version
108
104
  repmgr_password = normalize_repmgr_password(secrets.resolve('repmgr_password'))
109
105
  replication_password = normalize_replication_password(secrets.resolve('replication_password'))
@@ -213,7 +209,7 @@ module ActivePostgres
213
209
  unless cluster_show&.match?(/primary/i)
214
210
  # Fallback: verify via repmgr metadata in the repmgr database
215
211
  db_check = executor.run_sql_on_backend(self,
216
- 'SELECT type FROM repmgr.nodes WHERE node_id = 1;',
212
+ "SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id};",
217
213
  postgres_user: 'postgres',
218
214
  database: repmgr_db,
219
215
  tuples_only: true,
@@ -236,7 +232,7 @@ module ActivePostgres
236
232
  puts " Setting up standby: #{standby_host}..."
237
233
 
238
234
  host = standby_host
239
- repmgr_config = config.component_config(:repmgr)
235
+ repmgr_config = config.repmgr_config_for(standby_host)
240
236
  primary_replication_host = config.primary_replication_host
241
237
  version = config.version
242
238
  secrets_obj = secrets
@@ -244,13 +240,18 @@ module ActivePostgres
244
240
  repmgr_db = config.repmgr_database
245
241
  postgres_user = config.postgres_user
246
242
  replication_user = config.replication_user
243
+ seed_method = config.standby_seed_method_for(standby_host)
244
+ restore_arguments = if seed_method == :pgbackrest
245
+ standby_label = config.node_label_for(standby_host) || "standby-#{standby_host.split('.').first}"
246
+ pgbackrest_restore_arguments(build_primary_conninfo(standby_label))
247
+ end
247
248
 
248
249
  # Variables used in ERB templates via binding
249
250
  _ = host
250
251
  _ = repmgr_config
251
252
  _ = secrets_obj
252
253
 
253
- node_id = config.standby_hosts.index(standby_host) + 2
254
+ node_id = config.node_id_for(standby_host)
254
255
  repmgr_password = normalize_repmgr_password(secrets_obj.resolve('repmgr_password'))
255
256
  replication_password = normalize_replication_password(secrets_obj.resolve('replication_password'))
256
257
  if replication_user == repmgr_user && replication_password != repmgr_password
@@ -300,58 +301,61 @@ module ActivePostgres
300
301
  nil
301
302
  end
302
303
 
303
- info 'Cloning from primary over private network...'
304
- # Create a temporary repmgr config for cloning that points to the primary
305
- # The regular config points to localhost which doesn't work during initial clone
306
- temp_repmgr_conf = <<~CONF
307
- node_id=#{node_id}
308
- node_name='#{standby_host}'
309
- conninfo='host=#{primary_replication_host} user=#{repmgr_user} dbname=#{repmgr_db} connect_timeout=10'
310
- data_directory='/var/lib/postgresql/#{version}/main'
311
- CONF
312
-
313
- info 'Creating temporary repmgr config for cloning...'
314
- upload! StringIO.new(temp_repmgr_conf), '/tmp/repmgr_clone.conf'
315
- execute :sudo, 'mv', '/tmp/repmgr_clone.conf', '/etc/repmgr_clone.conf'
316
- execute :sudo, 'chown', 'postgres:postgres', '/etc/repmgr_clone.conf'
317
- execute :sudo, 'chmod', '600', '/etc/repmgr_clone.conf'
318
-
319
- info 'Running: repmgr standby clone (this may take a few minutes to copy the database)...'
304
+ if seed_method == :repmgr
305
+ info 'Cloning from primary over private network...'
306
+ # Create a temporary repmgr config for cloning that points to the primary.
307
+ # The regular config points to localhost, which does not work during the initial clone.
308
+ temp_repmgr_conf = <<~CONF
309
+ node_id=#{node_id}
310
+ node_name='#{standby_host}'
311
+ conninfo='host=#{primary_replication_host} user=#{repmgr_user} dbname=#{repmgr_db} connect_timeout=10'
312
+ data_directory='/var/lib/postgresql/#{version}/main'
313
+ CONF
314
+
315
+ info 'Creating temporary repmgr config for cloning...'
316
+ upload! StringIO.new(temp_repmgr_conf), '/tmp/repmgr_clone.conf'
317
+ execute :sudo, 'mv', '/tmp/repmgr_clone.conf', '/etc/repmgr_clone.conf'
318
+ execute :sudo, 'chown', 'postgres:postgres', '/etc/repmgr_clone.conf'
319
+ execute :sudo, 'chmod', '600', '/etc/repmgr_clone.conf'
320
+ info 'Running: repmgr standby clone (this may take a few minutes to copy the database)...'
321
+ else
322
+ info 'Restoring the latest valid pgBackRest backup as a standby...'
323
+ end
320
324
 
321
325
  clone_success = false
322
326
  begin
323
- execute :sudo, '-u', postgres_user, 'env',
324
- 'HOME=/var/lib/postgresql',
325
- 'repmgr', 'standby', 'clone',
326
- '-h', primary_replication_host,
327
- '-U', repmgr_user,
328
- '-d', repmgr_db,
329
- '-f', '/etc/repmgr_clone.conf',
330
- '--force',
331
- '--verbose'
332
-
333
- info 'Clone command completed'
327
+ if seed_method == :pgbackrest
328
+ execute(*restore_arguments)
329
+ info 'pgBackRest standby restore completed'
330
+ else
331
+ execute :sudo, '-u', postgres_user, 'env',
332
+ 'HOME=/var/lib/postgresql',
333
+ 'repmgr', 'standby', 'clone',
334
+ '-h', primary_replication_host,
335
+ '-U', repmgr_user,
336
+ '-d', repmgr_db,
337
+ '-f', '/etc/repmgr_clone.conf',
338
+ '--force',
339
+ '--verbose'
340
+ info 'Clone command completed'
341
+ end
334
342
 
335
- # Check if clone actually succeeded
336
343
  if test(:sudo, 'test', '-d', "/var/lib/postgresql/#{version}/main")
337
344
  clone_success = true
338
- info 'Data directory successfully cloned!'
339
-
340
- # Clean up temporary clone config
341
- execute :sudo, 'rm', '-f', '/etc/repmgr_clone.conf'
345
+ info 'Standby data directory successfully seeded!'
346
+ execute :sudo, 'rm', '-f', '/etc/repmgr_clone.conf' if seed_method == :repmgr
342
347
  else
343
- error "Data directory /var/lib/postgresql/#{version}/main was not created even though command succeeded!"
348
+ error "Data directory /var/lib/postgresql/#{version}/main was not created even though seeding succeeded!"
344
349
  end
345
350
  rescue SSHKit::Command::Failed => e
346
- error "Clone command failed with exit code #{e.message}"
351
+ error "Standby seed command failed with exit code #{e.message}"
347
352
  rescue StandardError => e
348
- error "Clone command raised exception: #{e.class}: #{e.message}"
353
+ error "Standby seed command raised exception: #{e.class}: #{e.message}"
349
354
  end
350
355
 
351
- # If clone failed, raise error
352
356
  unless clone_success
353
- error '✗ repmgr standby clone failed'
354
- raise 'repmgr standby clone failed to create data directory'
357
+ error '✗ Standby data seeding failed'
358
+ raise 'standby data seeding failed to create data directory'
355
359
  end
356
360
  end
357
361
 
@@ -676,6 +680,7 @@ module ActivePostgres
676
680
  puts 'Verifying PostgreSQL HA cluster health...'
677
681
 
678
682
  primary_host = config.primary_host
683
+ primary_node_id = config.node_id_for(primary_host)
679
684
  standby_hosts = config.standby_hosts
680
685
  version = config.version
681
686
  postgres_user = config.postgres_user
@@ -706,12 +711,12 @@ module ActivePostgres
706
711
  cluster_output = capture(:sudo, '-u', 'postgres',
707
712
  'repmgr', '-f', '/etc/repmgr.conf', 'cluster', 'show',
708
713
  raise_on_non_zero_exit: false).to_s
709
- # Primary always has node_id=1, check if it's registered and running
710
- if cluster_output.match?(/\s+1\s+\|.*primary.*\*\s+running/i)
714
+ # Check the configured node identity, which survives role changes.
715
+ if cluster_output.match?(/\s+#{primary_node_id}\s+\|.*primary.*\*\s+running/i)
711
716
  info '✓ Primary is registered with repmgr'
712
717
  else
713
718
  db_check = executor.run_sql_on_backend(self,
714
- 'SELECT type FROM repmgr.nodes WHERE node_id = 1 AND active IS TRUE;',
719
+ "SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id} AND active IS TRUE;",
715
720
  postgres_user: postgres_user,
716
721
  database: repmgr_db,
717
722
  tuples_only: true,
@@ -907,6 +912,21 @@ module ActivePostgres
907
912
  "host=#{primary_host} user=#{user} dbname=#{dbname} application_name=#{standby_label}"
908
913
  end
909
914
 
915
+ def pgbackrest_restore_arguments(primary_conninfo)
916
+ command = Shellwords.join(
917
+ [
918
+ 'pgbackrest', '--stanza=main', '--type=standby',
919
+ "--recovery-option=primary_conninfo=#{primary_conninfo}",
920
+ 'restore'
921
+ ]
922
+ )
923
+
924
+ [
925
+ :sudo, '-u', config.postgres_user,
926
+ :bash, '-lc', Shellwords.escape(command)
927
+ ]
928
+ end
929
+
910
930
  def normalize_repmgr_password(raw_password)
911
931
  password = raw_password.to_s.rstrip
912
932
 
@@ -933,7 +953,7 @@ module ActivePostgres
933
953
 
934
954
  def ensure_primary_registered
935
955
  host = config.primary_host
936
- config.version
956
+ primary_node_id = config.node_id_for(host)
937
957
  is_registered = false
938
958
 
939
959
  ssh_executor.execute_on_host(host) do
@@ -947,8 +967,8 @@ module ActivePostgres
947
967
  ''
948
968
  end
949
969
 
950
- if cluster_output.include?('| 1') && cluster_output.include?('primary')
951
- info '✓ Primary is registered (node_id=1)'
970
+ if cluster_output.match?(/\b#{primary_node_id}\s+\|.*primary/)
971
+ info "✓ Primary is registered (node_id=#{primary_node_id})"
952
972
  else
953
973
  warn "⚠ Primary not found in cluster, assuming it's registered"
954
974
  end
@@ -1101,7 +1121,13 @@ module ActivePostgres
1101
1121
  end
1102
1122
 
1103
1123
  def enable_repmgrd_if_configured(host, repmgr_config)
1104
- return if repmgr_config[:auto_failover] == false
1124
+ if repmgr_config[:auto_failover] == false
1125
+ ssh_executor.execute_on_host(host) do
1126
+ execute :sudo, 'systemctl', 'stop', 'repmgrd', raise_on_non_zero_exit: false
1127
+ execute :sudo, 'systemctl', 'disable', 'repmgrd', raise_on_non_zero_exit: false
1128
+ end
1129
+ return
1130
+ end
1105
1131
 
1106
1132
  default_config = <<~CONF
1107
1133
  REPMGRD_ENABLED=yes
@@ -0,0 +1,22 @@
1
+ module ActivePostgres
2
+ class Configuration
3
+ module NodeIdentity
4
+ def node_id_for(host)
5
+ node = node_config_for(host)
6
+ raise Error, "Unknown PostgreSQL host: #{host}" unless node
7
+
8
+ value = node.fetch('node_id') { host == primary_host ? 1 : standby_hosts.index(host) + 2 }
9
+ raise Error, "node_id for #{host} must be a positive 32-bit integer" unless value.is_a?(Integer) && value.between?(1, 2_147_483_647)
10
+
11
+ value
12
+ end
13
+
14
+ private
15
+
16
+ def validate_node_ids!
17
+ node_ids = all_hosts.map { |host| node_id_for(host) }
18
+ raise Error, 'PostgreSQL node_id values must be unique' unless node_ids.uniq.size == node_ids.size
19
+ end
20
+ end
21
+ end
22
+ end
@@ -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,16 @@
1
1
  require 'yaml'
2
+ require_relative 'configuration/pgbackrest_policy'
3
+ require_relative 'configuration/standby_policy'
4
+ require_relative 'configuration/node_identity'
2
5
 
3
6
  module ActivePostgres
4
7
  class Configuration
5
- attr_reader :environment, :version, :user, :ssh_key, :ssh_host_key_verification, :primary, :standbys, :components, :secrets_config,
6
- :database_config
8
+ include PgBackRestPolicy
9
+ include StandbyPolicy
10
+ include NodeIdentity
11
+
12
+ attr_reader :environment, :version, :user, :ssh_key, :ssh_known_hosts_file, :ssh_host_key_verification,
13
+ :primary, :standbys, :jump_hosts, :components, :secrets_config, :database_config
7
14
 
8
15
  def initialize(config_hash, environment = 'development')
9
16
  @environment = environment
@@ -14,6 +21,7 @@ module ActivePostgres
14
21
  @version = env_config['version'] || 18
15
22
  @user = env_config['user'] || 'ubuntu'
16
23
  @ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
24
+ @ssh_known_hosts_file = optional_path(env_config['ssh_known_hosts_file'])
17
25
  @ssh_host_key_verification = normalize_ssh_host_key_verification(
18
26
  env_config['ssh_host_key_verification'] || env_config['ssh_verify_host_key']
19
27
  )
@@ -21,6 +29,7 @@ module ActivePostgres
21
29
  @primary = env_config['primary'] || {}
22
30
  @standbys = env_config['standby'] || []
23
31
  @standbys = [@standbys] unless @standbys.is_a?(Array)
32
+ @jump_hosts = env_config['jump_hosts'] || {}
24
33
 
25
34
  @components = parse_components(env_config['components'] || {})
26
35
  @secrets_config = env_config['secrets'] || {}
@@ -111,6 +120,24 @@ module ActivePostgres
111
120
  @standbys.find { |s| s['host'] == host }
112
121
  end
113
122
 
123
+ def node_config_for(host)
124
+ return @primary if host == primary_host
125
+
126
+ standby_config_for(host)
127
+ end
128
+
129
+ def jump_host_config_for(host)
130
+ jump_host_name = node_config_for(host)&.dig('jump_host')
131
+ return unless jump_host_name
132
+
133
+ @jump_hosts[jump_host_name] || raise(Error, "Host #{host} jump_host not found: #{jump_host_name}")
134
+ end
135
+
136
+ def repmgr_config_for(host)
137
+ overrides = node_config_for(host)&.dig('repmgr') || {}
138
+ component_config(:repmgr).merge(symbolize_keys(overrides))
139
+ end
140
+
114
141
  def node_label_for(host)
115
142
  if host == primary_host
116
143
  @primary['label']
@@ -122,6 +149,8 @@ module ActivePostgres
122
149
  def validate!
123
150
  raise Error, 'No primary host defined' unless primary_host
124
151
 
152
+ validate_node_ids!
153
+
125
154
  # Validate required secrets if components are enabled
126
155
  raise Error, 'Missing replication_password secret' if component_enabled?(:repmgr) && !secrets_config['replication_password']
127
156
  raise Error, 'Missing monitoring_password secret' if component_enabled?(:monitoring) && !secrets_config['monitoring_password']
@@ -133,14 +162,7 @@ module ActivePostgres
133
162
  raise Error, 'monitoring.grafana.host is required when grafana is enabled'
134
163
  end
135
164
  end
136
- if component_enabled?(:pgbackrest)
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
165
+ validate_pgbackrest!
144
166
 
145
167
  if component_enabled?(:pgbouncer)
146
168
  pgbouncer_app_hosts.each do |app_host|
@@ -149,6 +171,17 @@ module ActivePostgres
149
171
  end
150
172
  end
151
173
 
174
+ all_hosts.each do |host|
175
+ jump_host_name = node_config_for(host)&.dig('jump_host')
176
+ next unless jump_host_name
177
+
178
+ jump_host = @jump_hosts[jump_host_name]
179
+ raise Error, "Host #{host} jump_host not found: #{jump_host_name}" unless jump_host
180
+ raise Error, "Jump host #{jump_host_name} must include host" if jump_host['host'].to_s.strip.empty?
181
+ end
182
+
183
+ validate_standby_seed_methods!
184
+
152
185
  if component_enabled?(:repmgr)
153
186
  dns_failover = component_config(:repmgr)[:dns_failover]
154
187
  if dns_failover && dns_failover[:enabled]
@@ -246,12 +279,6 @@ module ActivePostgres
246
279
  result
247
280
  end
248
281
 
249
- def node_config_for(host)
250
- return @primary if host == primary_host
251
-
252
- standby_config_for(host)
253
- end
254
-
255
282
  def private_ip_for(node_config)
256
283
  return unless node_config
257
284
 
@@ -295,5 +322,9 @@ module ActivePostgres
295
322
  raise Error,
296
323
  "Invalid ssh_host_key_verification '#{value}'. Use 'always' or 'accept_new'."
297
324
  end
325
+
326
+ def optional_path(path)
327
+ File.expand_path(path) if path
328
+ end
298
329
  end
299
330
  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("#{config.user}@#{host}", &)
25
+ on(ssh_host_for(host), &)
24
26
  end
25
27
 
26
28
  def execute_on_host_as(host, user, &)
27
- on("#{user}@#{host}", &)
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 { |h| "#{config.user}@#{h}" }
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 { |h| "#{config.user}@#{h}" }
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
- return if config.component_enabled?(:repmgr)
24
+ abort '❌ repmgr component must be enabled to setup standbys' unless config.component_enabled?(:repmgr)
25
25
 
26
- abort '❌ repmgr component must be enabled to setup standbys'
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
- logger.info " • Clone data from primary: #{config.primary_host}"
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 database will NOT be touched"
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 cloning from primary') do
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
- rollback_manager.register("Uninstall #{component_name} on #{standby_host}", host: standby_host) do
111
- component.uninstall
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
- logger.warn "Failed to uninstall #{component_name} on #{standby_host}: #{e.message}"
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'
@@ -1,3 +1,3 @@
1
1
  module ActivePostgres
2
- VERSION = '0.9.6'.freeze
2
+ VERSION = '0.9.8'.freeze
3
3
  end
@@ -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
-
@@ -9,7 +9,7 @@
9
9
  end
10
10
  end
11
11
  %>
12
- node_id=<%= host == config.primary_host ? 1 : config.standby_hosts.index(host) + 2 %>
12
+ node_id=<%= config.node_id_for(host) %>
13
13
  node_name='<%= node_label %>'
14
14
  <%
15
15
  # conninfo describes how OTHER nodes connect to THIS node
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.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - BoringCache
@@ -126,6 +126,9 @@ 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/node_identity.rb
130
+ - lib/active_postgres/configuration/pgbackrest_policy.rb
131
+ - lib/active_postgres/configuration/standby_policy.rb
129
132
  - lib/active_postgres/connection_pooler.rb
130
133
  - lib/active_postgres/credentials.rb
131
134
  - lib/active_postgres/deployment_flow.rb