diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d3895..5d0c569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/). ## Unreleased +## 0.9.8 - 2026-10-02 + +### Fixed + +- Support explicit node IDs so repmgr identities survive primary changes and standby reordering. +- Reject duplicate or invalid node IDs before deployment. + ## 0.9.7 - 2026-09-01 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index c3aa7bc..537165f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_postgres (0.9.7) + active_postgres (0.9.8) bcrypt_pbkdf (~> 1.1) ed25519 (~> 1.4) pg (~> 1.6) diff --git a/README.md b/README.md index 4a86939..057df38 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ manual-promotion while the ordinary standby retains automatic failover. settings before repmgr registration, avoiding a full base-backup stream from the primary. The `pgbackrest` component must be enabled. +Set a unique positive integer `node_id` on every primary and standby entry +before permanently changing their roles or reordering standbys. Match the +existing IDs in `repmgr cluster show`, and keep each ID with its host after a +switchover. Without explicit IDs, the primary defaults to 1 and standbys use +their list position starting at 2. Duplicate or invalid IDs are rejected. + Add credentials (`rails credentials:edit`): ```yaml diff --git a/lib/active_postgres/components/repmgr.rb b/lib/active_postgres/components/repmgr.rb index 43cc101..24ccca9 100644 --- a/lib/active_postgres/components/repmgr.rb +++ b/lib/active_postgres/components/repmgr.rb @@ -28,7 +28,7 @@ def uninstall end def uninstall_from(host) - node_id = host == config.primary_host ? 1 : (config.standby_hosts.index(host) || 997) + 2 + node_id = config.node_id_for(host) ssh_executor.execute_on_host(host) do cluster_output = begin @@ -98,6 +98,7 @@ def setup_primary puts ' Setting up primary with repmgr...' host = config.primary_host + primary_node_id = config.node_id_for(host) repmgr_config = config.repmgr_config_for(host) version = config.version repmgr_password = normalize_repmgr_password(secrets.resolve('repmgr_password')) @@ -208,7 +209,7 @@ def setup_primary unless cluster_show&.match?(/primary/i) # Fallback: verify via repmgr metadata in the repmgr database db_check = executor.run_sql_on_backend(self, - 'SELECT type FROM repmgr.nodes WHERE node_id = 1;', + "SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id};", postgres_user: 'postgres', database: repmgr_db, tuples_only: true, @@ -250,7 +251,7 @@ def setup_standby(standby_host) _ = repmgr_config _ = secrets_obj - node_id = config.standby_hosts.index(standby_host) + 2 + node_id = config.node_id_for(standby_host) repmgr_password = normalize_repmgr_password(secrets_obj.resolve('repmgr_password')) replication_password = normalize_replication_password(secrets_obj.resolve('replication_password')) if replication_user == repmgr_user && replication_password != repmgr_password @@ -679,6 +680,7 @@ def verify_cluster_health puts 'Verifying PostgreSQL HA cluster health...' primary_host = config.primary_host + primary_node_id = config.node_id_for(primary_host) standby_hosts = config.standby_hosts version = config.version postgres_user = config.postgres_user @@ -709,12 +711,12 @@ def verify_cluster_health cluster_output = capture(:sudo, '-u', 'postgres', 'repmgr', '-f', '/etc/repmgr.conf', 'cluster', 'show', raise_on_non_zero_exit: false).to_s - # Primary always has node_id=1, check if it's registered and running - if cluster_output.match?(/\s+1\s+\|.*primary.*\*\s+running/i) + # Check the configured node identity, which survives role changes. + if cluster_output.match?(/\s+#{primary_node_id}\s+\|.*primary.*\*\s+running/i) info '✓ Primary is registered with repmgr' else db_check = executor.run_sql_on_backend(self, - 'SELECT type FROM repmgr.nodes WHERE node_id = 1 AND active IS TRUE;', + "SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id} AND active IS TRUE;", postgres_user: postgres_user, database: repmgr_db, tuples_only: true, @@ -951,7 +953,7 @@ def reload_postgres_cluster def ensure_primary_registered host = config.primary_host - config.version + primary_node_id = config.node_id_for(host) is_registered = false ssh_executor.execute_on_host(host) do @@ -965,8 +967,8 @@ def ensure_primary_registered '' end - if cluster_output.include?('| 1') && cluster_output.include?('primary') - info '✓ Primary is registered (node_id=1)' + if cluster_output.match?(/\b#{primary_node_id}\s+\|.*primary/) + info "✓ Primary is registered (node_id=#{primary_node_id})" else warn "⚠ Primary not found in cluster, assuming it's registered" end diff --git a/lib/active_postgres/configuration.rb b/lib/active_postgres/configuration.rb index d3ffcf8..56a540d 100644 --- a/lib/active_postgres/configuration.rb +++ b/lib/active_postgres/configuration.rb @@ -1,11 +1,13 @@ require 'yaml' require_relative 'configuration/pgbackrest_policy' require_relative 'configuration/standby_policy' +require_relative 'configuration/node_identity' module ActivePostgres class Configuration include PgBackRestPolicy include StandbyPolicy + include NodeIdentity attr_reader :environment, :version, :user, :ssh_key, :ssh_known_hosts_file, :ssh_host_key_verification, :primary, :standbys, :jump_hosts, :components, :secrets_config, :database_config @@ -147,6 +149,8 @@ def node_label_for(host) def validate! raise Error, 'No primary host defined' unless primary_host + validate_node_ids! + # Validate required secrets if components are enabled raise Error, 'Missing replication_password secret' if component_enabled?(:repmgr) && !secrets_config['replication_password'] raise Error, 'Missing monitoring_password secret' if component_enabled?(:monitoring) && !secrets_config['monitoring_password'] diff --git a/lib/active_postgres/configuration/node_identity.rb b/lib/active_postgres/configuration/node_identity.rb new file mode 100644 index 0000000..65c205a --- /dev/null +++ b/lib/active_postgres/configuration/node_identity.rb @@ -0,0 +1,22 @@ +module ActivePostgres + class Configuration + module NodeIdentity + def node_id_for(host) + node = node_config_for(host) + raise Error, "Unknown PostgreSQL host: #{host}" unless node + + value = node.fetch('node_id') { host == primary_host ? 1 : standby_hosts.index(host) + 2 } + 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) + + value + end + + private + + def validate_node_ids! + node_ids = all_hosts.map { |host| node_id_for(host) } + raise Error, 'PostgreSQL node_id values must be unique' unless node_ids.uniq.size == node_ids.size + end + end + end +end diff --git a/lib/active_postgres/version.rb b/lib/active_postgres/version.rb index 0af6853..155523b 100644 --- a/lib/active_postgres/version.rb +++ b/lib/active_postgres/version.rb @@ -1,3 +1,3 @@ module ActivePostgres - VERSION = '0.9.7'.freeze + VERSION = '0.9.8'.freeze end diff --git a/templates/repmgr.conf.erb b/templates/repmgr.conf.erb index 38e610a..7abe52b 100644 --- a/templates/repmgr.conf.erb +++ b/templates/repmgr.conf.erb @@ -9,7 +9,7 @@ end end %> -node_id=<%= host == config.primary_host ? 1 : config.standby_hosts.index(host) + 2 %> +node_id=<%= config.node_id_for(host) %> node_name='<%= node_label %>' <% # conninfo describes how OTHER nodes connect to THIS node diff --git a/test/components/repmgr_templates_test.rb b/test/components/repmgr_templates_test.rb index 6063e3e..3735fbb 100644 --- a/test/components/repmgr_templates_test.rb +++ b/test/components/repmgr_templates_test.rb @@ -36,6 +36,14 @@ def test_conf_uses_private_ip_for_conninfo assert_includes content, "conninfo='host=10.0.0.10 user=repmgr dbname=repmgr connect_timeout=2'" end + def test_conf_preserves_explicit_node_ids_after_role_change + @config.primary['node_id'] = 3 + @config.standbys.first['node_id'] = 1 + + assert_includes render_conf({}, host: 'primary.example.com'), 'node_id=3' + assert_includes render_conf({}, host: 'standby.example.com'), 'node_id=1' + end + def test_conf_sets_automatic_failover_by_default content = render_conf({}, host: 'primary.example.com') diff --git a/test/configuration_node_identity_test.rb b/test/configuration_node_identity_test.rb new file mode 100644 index 0000000..4c8f1c8 --- /dev/null +++ b/test/configuration_node_identity_test.rb @@ -0,0 +1,43 @@ +require 'test_helper' + +class ConfigurationNodeIdentityTest < Minitest::Test + def test_legacy_configuration_keeps_position_based_ids + config = configuration({ 'host' => 'a' }, [{ 'host' => 'b' }]) + + assert config.validate! + assert_equal 1, config.node_id_for('a') + assert_equal 2, config.node_id_for('b') + end + + def test_explicit_identities_survive_switchover_and_standby_reordering + nodes = %w[a b c].each_with_index.map { |host, index| { 'host' => host, 'node_id' => index + 1 } } + before = configuration(nodes[0], [nodes[1], nodes[2]]) + after = configuration(nodes[2], [nodes[1], nodes[0]]) + + assert before.validate! + assert after.validate! + nodes.each do |node| + assert_equal before.node_id_for(node['host']), after.node_id_for(node['host']) + end + end + + def test_rejects_explicit_and_implicit_id_collision + config = configuration({ 'host' => 'a', 'node_id' => 2 }, [{ 'host' => 'b' }]) + + error = assert_raises(ActivePostgres::Error) { config.validate! } + assert_match(/must be unique/, error.message) + end + + def test_rejects_invalid_ids_before_rendering_sql_or_configuration + [0, -1, 2_147_483_648, '3', '1; DROP TABLE nodes', nil, 1.5].each do |value| + config = configuration({ 'host' => 'a', 'node_id' => value }, []) + assert_raises(ActivePostgres::Error) { config.validate! } + end + end + + private + + def configuration(primary, standbys) + ActivePostgres::Configuration.new({ 'production' => { 'primary' => primary, 'standby' => standbys } }, 'production') + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index acb3beb..2af11b8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -118,6 +118,11 @@ def node_label_for(host) end end + def node_id_for(host) + node = host == primary_host ? primary : standby_config_for(host) + node.fetch('node_id') { host == primary_host ? 1 : standby_hosts.index(host) + 2 } + end + private def evaluate_override(override, arg)