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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions lib/active_postgres/components/repmgr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/active_postgres/configuration.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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']
Expand Down
22 changes: 22 additions & 0 deletions lib/active_postgres/configuration/node_identity.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/active_postgres/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module ActivePostgres
VERSION = '0.9.7'.freeze
VERSION = '0.9.8'.freeze
end
2 changes: 1 addition & 1 deletion templates/repmgr.conf.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions test/components/repmgr_templates_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
43 changes: 43 additions & 0 deletions test/configuration_node_identity_test.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading