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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d06925c9a64ca3f5b96402257fda9ad13f32bbd0e3ac5377032d737c71fb2ea
4
- data.tar.gz: 19cde3ee7e86d325378661aa72350d51d1bfebf33a2e6f03a1c90d34c987a997
3
+ metadata.gz: 2f7bf86efa4e32bcb1543e317bd6219983f9f42eb0ed0bf8787ff75b6a573a48
4
+ data.tar.gz: 8a2b2f0ec44bdfd94552dfa0fd84145b64ded472b4f421f216c86af71e2d859e
5
5
  SHA512:
6
- metadata.gz: 2dbcc33b6ad2ffadeeea4145bc5bc36910203002af908030893298df2005089e87fa8e5d465a425ffae11c60ee01b66f301e1ae594e3bb662d747c0c342b20cb
7
- data.tar.gz: 2772e4a192f87c60d54ccaaeb5ea2952f7a98ce34f3daec5839e77758179f75f3f9ac09a76ab74bc929311b8b417e074e5ae156f286bbba55423c3a923b47d77
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
- # You can schedule full + incremental separately:
183
- # schedule_full: "0 2 * * *"
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 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 = host == config.primary_host ? 1 : (config.standby_hosts.index(host) || 997) + 2
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,7 @@ 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
+ 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.component_config(:repmgr)
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
- 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)...'
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
- 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'
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 'Data directory successfully cloned!'
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 command succeeded!"
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 "Clone command failed with exit code #{e.message}"
350
+ error "Standby seed command failed with exit code #{e.message}"
347
351
  rescue StandardError => e
348
- error "Clone command raised exception: #{e.class}: #{e.message}"
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 '✗ repmgr standby clone failed'
354
- raise 'repmgr standby clone failed to create data directory'
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
- return if repmgr_config[:auto_failover] == false
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
- attr_reader :environment, :version, :user, :ssh_key, :ssh_host_key_verification, :primary, :standbys, :components, :secrets_config,
6
- :database_config
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
- 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
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("#{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.7'.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
-
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.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