Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Allow a standby to override repmgr failover and priority policy.
- Allow a standby to be seeded from the latest valid pgBackRest backup.
- Make deployment-flow `--dry-run` stop before confirmation or mutation.
- Fail standby setup before mutation when required credentials do not resolve.
- Scope standby rollback to the requested host and keep existing cluster nodes intact.

## 0.9.6 - 2026-08-20

Expand Down
47 changes: 21 additions & 26 deletions lib/active_postgres/components/repmgr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,24 @@ def install
def uninstall
puts 'Uninstalling repmgr...'

config.all_hosts.each do |host|
is_primary = host == config.primary_host
node_id = if is_primary
1
else
begin
config.standby_hosts.index(host) + 2
rescue StandardError
999
end
end
config.all_hosts.each { |host| uninstall_from(host) }
end

ssh_executor.execute_on_host(host) do
cluster_output = begin
capture(:sudo, '-u', 'postgres', 'repmgr', 'cluster', 'show')
rescue StandardError
''
end
def uninstall_from(host)
node_id = host == config.primary_host ? 1 : (config.standby_hosts.index(host) || 997) + 2

if cluster_output.include?("| #{node_id}") && cluster_output.include?('running')
info "✓ Skipping repmgr removal from #{host} - node #{node_id} is running"
else
info "Removing repmgr from #{host}"
execute :sudo, 'apt-get', 'remove', '-y', '-q', 'postgresql-*-repmgr', '||', 'true'
end
ssh_executor.execute_on_host(host) do
cluster_output = begin
capture(:sudo, '-u', 'postgres', 'repmgr', 'cluster', 'show')
rescue StandardError
''
end

if cluster_output.include?("| #{node_id}") && cluster_output.include?('running')
info "✓ Skipping repmgr removal from #{host} - node #{node_id} is running"
else
info "Removing repmgr from #{host}"
execute :sudo, 'apt-get', 'remove', '-y', '-q', 'postgresql-*-repmgr', '||', 'true'
end
end
end
Expand Down Expand Up @@ -245,6 +238,10 @@ def setup_standby(standby_host)
postgres_user = config.postgres_user
replication_user = config.replication_user
seed_method = config.standby_seed_method_for(standby_host)
restore_arguments = if seed_method == :pgbackrest
standby_label = config.node_label_for(standby_host) || "standby-#{standby_host.split('.').first}"
pgbackrest_restore_arguments(build_primary_conninfo(standby_label))
end

# Variables used in ERB templates via binding
_ = host
Expand Down Expand Up @@ -325,9 +322,7 @@ def setup_standby(standby_host)
clone_success = false
begin
if seed_method == :pgbackrest
standby_label = config.node_label_for(standby_host) || "standby-#{standby_host.split('.').first}"
primary_conninfo = build_primary_conninfo(standby_label)
execute(*pgbackrest_restore_arguments(primary_conninfo))
execute(*restore_arguments)
info 'pgBackRest standby restore completed'
else
execute :sudo, '-u', postgres_user, 'env',
Expand Down
29 changes: 26 additions & 3 deletions lib/active_postgres/standby_deployment_flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ def print_targets
def validate_specific_requirements
abort "❌ #{standby_host} is not configured as a standby in config/postgres.yml" unless config.standby_hosts.include?(standby_host)

return if config.component_enabled?(:repmgr)
abort '❌ repmgr component must be enabled to setup standbys' unless config.component_enabled?(:repmgr)

abort '❌ repmgr component must be enabled to setup standbys'
validate_required_secrets!
end

def run_preflight_checks
Expand Down Expand Up @@ -120,12 +120,35 @@ def deploy_monitoring

def register_rollback(component_name, component)
rollback_manager.register("Uninstall #{component_name} on #{standby_host}", host: standby_host) do
component.uninstall
if component.respond_to?(:uninstall_from)
component.uninstall_from(standby_host)
else
component.uninstall
end
rescue StandardError => e
logger.warn "Failed to uninstall #{component_name} on #{standby_host}: #{e.message}"
end
end

def validate_required_secrets!
required = %w[repmgr_password replication_password]
required.push('ssl_cert', 'ssl_key') if config.component_enabled?(:ssl)
required << 'monitoring_password' if config.component_enabled?(:monitoring)

if config.component_enabled?(:pgbackrest)
pgbackrest = secrets.resolve_value(config.component_config(:pgbackrest))
if pgbackrest[:repo_type] == 's3'
required.push('s3_access_key', 's3_secret_key')
raise Error, 'pgbackrest.s3_bucket did not resolve' if pgbackrest[:s3_bucket].to_s.empty?
end
end

missing = required.uniq.select { |key| secrets.resolve(key).to_s.empty? }
return if missing.empty?

raise Error, "Required standby secrets did not resolve: #{missing.join(', ')}"
end

def list_next_steps
logger.info ' 1. Check cluster status: active_postgres status'
logger.info ' 2. Verify replication: Check repmgr cluster show'
Expand Down
21 changes: 21 additions & 0 deletions test/repmgr_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ def test_manual_failover_policy_disables_repmgrd
], commands
end

def test_targeted_uninstall_does_not_visit_other_cluster_nodes
visited_hosts = []
commands = []
backend = Object.new
backend.define_singleton_method(:capture) { |*| '' }
backend.define_singleton_method(:info) { |_| nil }
backend.define_singleton_method(:execute) { |*args| commands << args }

executor = Object.new
executor.define_singleton_method(:execute_on_host) do |host, &block|
visited_hosts << host
backend.instance_exec(&block)
end

component = ActivePostgres::Components::Repmgr.new(@config, executor, @secrets)
component.uninstall_from('standby.example.com')

assert_equal ['standby.example.com'], visited_hosts
assert_equal [:sudo, 'apt-get', 'remove', '-y', '-q', 'postgresql-*-repmgr', '||', 'true'], commands.fetch(0)
end

def test_normalize_dns_servers_handles_hash_and_string
raw = [
{ 'host' => 'public.example.com', 'private_ip' => '10.0.0.10' },
Expand Down
67 changes: 67 additions & 0 deletions test/standby_deployment_flow_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require 'test_helper'

class StandbyDeploymentFlowTest < Minitest::Test
class RollbackRecorder
attr_reader :action

def register(*, &block)
@action = block
end
end

def test_required_credentials_fail_before_deployment
config = configuration(
'repmgr_password' => 'rails_credentials:postgres.repmgr_password',
'replication_password' => 'rails_credentials:postgres.replication_password'
)
flow = build_flow(config)

error = assert_raises(ActivePostgres::Error) { flow.send(:validate_specific_requirements) }

assert_match(/Required standby secrets did not resolve/, error.message)
end

def test_repmgr_rollback_targets_only_the_requested_standby
config = configuration(
'repmgr_password' => 'secret',
'replication_password' => 'secret'
)
rollback = RollbackRecorder.new
flow = build_flow(config, rollback:)
removed = []
component = Object.new
component.define_singleton_method(:uninstall_from) { |host| removed << host }

flow.send(:register_rollback, 'repmgr', component)
rollback.action.call

assert_equal ['standby.example.com'], removed
end

private

def configuration(secrets)
ActivePostgres::Configuration.new(
{
'test' => {
'primary' => { 'host' => 'primary.example.com' },
'standby' => [{ 'host' => 'standby.example.com' }],
'components' => { 'repmgr' => { 'enabled' => true } },
'secrets' => secrets
}
},
'test'
)
end

def build_flow(config, rollback: Object.new)
ActivePostgres::StandbyDeploymentFlow.new(
config,
standby_host: 'standby.example.com',
ssh_executor: Object.new,
secrets: ActivePostgres::Secrets.new(config),
logger: ActivePostgres::Logger.new,
rollback_manager: rollback
)
end
end