diff --git a/CHANGELOG.md b/CHANGELOG.md index b52abaf..b684a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/active_postgres/components/repmgr.rb b/lib/active_postgres/components/repmgr.rb index 0313c25..069b066 100644 --- a/lib/active_postgres/components/repmgr.rb +++ b/lib/active_postgres/components/repmgr.rb @@ -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 @@ -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 @@ -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', diff --git a/lib/active_postgres/standby_deployment_flow.rb b/lib/active_postgres/standby_deployment_flow.rb index c05c4df..8408c13 100644 --- a/lib/active_postgres/standby_deployment_flow.rb +++ b/lib/active_postgres/standby_deployment_flow.rb @@ -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 @@ -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' diff --git a/test/repmgr_test.rb b/test/repmgr_test.rb index 1c52f72..61109aa 100644 --- a/test/repmgr_test.rb +++ b/test/repmgr_test.rb @@ -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' }, diff --git a/test/standby_deployment_flow_test.rb b/test/standby_deployment_flow_test.rb new file mode 100644 index 0000000..83ea95f --- /dev/null +++ b/test/standby_deployment_flow_test.rb @@ -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