Skip to content

Commit 216490f

Browse files
authored
Preserve PostgreSQL node identities across role changes (#26)
1 parent 7084fb0 commit 216490f

11 files changed

Lines changed: 109 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/).
77

88
## Unreleased
99

10+
## 0.9.8 - 2026-10-02
11+
12+
### Fixed
13+
14+
- Support explicit node IDs so repmgr identities survive primary changes and standby reordering.
15+
- Reject duplicate or invalid node IDs before deployment.
16+
1017
## 0.9.7 - 2026-09-01
1118

1219
### Added

‎Gemfile.lock‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
active_postgres (0.9.7)
4+
active_postgres (0.9.8)
55
bcrypt_pbkdf (~> 1.1)
66
ed25519 (~> 1.4)
77
pg (~> 1.6)

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ manual-promotion while the ordinary standby retains automatic failover.
7676
settings before repmgr registration, avoiding a full base-backup stream from
7777
the primary. The `pgbackrest` component must be enabled.
7878

79+
Set a unique positive integer `node_id` on every primary and standby entry
80+
before permanently changing their roles or reordering standbys. Match the
81+
existing IDs in `repmgr cluster show`, and keep each ID with its host after a
82+
switchover. Without explicit IDs, the primary defaults to 1 and standbys use
83+
their list position starting at 2. Duplicate or invalid IDs are rejected.
84+
7985
Add credentials (`rails credentials:edit`):
8086

8187
```yaml

‎lib/active_postgres/components/repmgr.rb‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def uninstall
2828
end
2929

3030
def uninstall_from(host)
31-
node_id = host == config.primary_host ? 1 : (config.standby_hosts.index(host) || 997) + 2
31+
node_id = config.node_id_for(host)
3232

3333
ssh_executor.execute_on_host(host) do
3434
cluster_output = begin
@@ -98,6 +98,7 @@ def setup_primary
9898
puts ' Setting up primary with repmgr...'
9999

100100
host = config.primary_host
101+
primary_node_id = config.node_id_for(host)
101102
repmgr_config = config.repmgr_config_for(host)
102103
version = config.version
103104
repmgr_password = normalize_repmgr_password(secrets.resolve('repmgr_password'))
@@ -208,7 +209,7 @@ def setup_primary
208209
unless cluster_show&.match?(/primary/i)
209210
# Fallback: verify via repmgr metadata in the repmgr database
210211
db_check = executor.run_sql_on_backend(self,
211-
'SELECT type FROM repmgr.nodes WHERE node_id = 1;',
212+
"SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id};",
212213
postgres_user: 'postgres',
213214
database: repmgr_db,
214215
tuples_only: true,
@@ -250,7 +251,7 @@ def setup_standby(standby_host)
250251
_ = repmgr_config
251252
_ = secrets_obj
252253

253-
node_id = config.standby_hosts.index(standby_host) + 2
254+
node_id = config.node_id_for(standby_host)
254255
repmgr_password = normalize_repmgr_password(secrets_obj.resolve('repmgr_password'))
255256
replication_password = normalize_replication_password(secrets_obj.resolve('replication_password'))
256257
if replication_user == repmgr_user && replication_password != repmgr_password
@@ -679,6 +680,7 @@ def verify_cluster_health
679680
puts 'Verifying PostgreSQL HA cluster health...'
680681

681682
primary_host = config.primary_host
683+
primary_node_id = config.node_id_for(primary_host)
682684
standby_hosts = config.standby_hosts
683685
version = config.version
684686
postgres_user = config.postgres_user
@@ -709,12 +711,12 @@ def verify_cluster_health
709711
cluster_output = capture(:sudo, '-u', 'postgres',
710712
'repmgr', '-f', '/etc/repmgr.conf', 'cluster', 'show',
711713
raise_on_non_zero_exit: false).to_s
712-
# Primary always has node_id=1, check if it's registered and running
713-
if cluster_output.match?(/\s+1\s+\|.*primary.*\*\s+running/i)
714+
# Check the configured node identity, which survives role changes.
715+
if cluster_output.match?(/\s+#{primary_node_id}\s+\|.*primary.*\*\s+running/i)
714716
info '✓ Primary is registered with repmgr'
715717
else
716718
db_check = executor.run_sql_on_backend(self,
717-
'SELECT type FROM repmgr.nodes WHERE node_id = 1 AND active IS TRUE;',
719+
"SELECT type FROM repmgr.nodes WHERE node_id = #{primary_node_id} AND active IS TRUE;",
718720
postgres_user: postgres_user,
719721
database: repmgr_db,
720722
tuples_only: true,
@@ -951,7 +953,7 @@ def reload_postgres_cluster
951953

952954
def ensure_primary_registered
953955
host = config.primary_host
954-
config.version
956+
primary_node_id = config.node_id_for(host)
955957
is_registered = false
956958

957959
ssh_executor.execute_on_host(host) do
@@ -965,8 +967,8 @@ def ensure_primary_registered
965967
''
966968
end
967969

968-
if cluster_output.include?('| 1') && cluster_output.include?('primary')
969-
info '✓ Primary is registered (node_id=1)'
970+
if cluster_output.match?(/\b#{primary_node_id}\s+\|.*primary/)
971+
info "✓ Primary is registered (node_id=#{primary_node_id})"
970972
else
971973
warn "⚠ Primary not found in cluster, assuming it's registered"
972974
end

‎lib/active_postgres/configuration.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
require 'yaml'
22
require_relative 'configuration/pgbackrest_policy'
33
require_relative 'configuration/standby_policy'
4+
require_relative 'configuration/node_identity'
45

56
module ActivePostgres
67
class Configuration
78
include PgBackRestPolicy
89
include StandbyPolicy
10+
include NodeIdentity
911

1012
attr_reader :environment, :version, :user, :ssh_key, :ssh_known_hosts_file, :ssh_host_key_verification,
1113
:primary, :standbys, :jump_hosts, :components, :secrets_config, :database_config
@@ -147,6 +149,8 @@ def node_label_for(host)
147149
def validate!
148150
raise Error, 'No primary host defined' unless primary_host
149151

152+
validate_node_ids!
153+
150154
# Validate required secrets if components are enabled
151155
raise Error, 'Missing replication_password secret' if component_enabled?(:repmgr) && !secrets_config['replication_password']
152156
raise Error, 'Missing monitoring_password secret' if component_enabled?(:monitoring) && !secrets_config['monitoring_password']
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module ActivePostgres
2+
class Configuration
3+
module NodeIdentity
4+
def node_id_for(host)
5+
node = node_config_for(host)
6+
raise Error, "Unknown PostgreSQL host: #{host}" unless node
7+
8+
value = node.fetch('node_id') { host == primary_host ? 1 : standby_hosts.index(host) + 2 }
9+
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)
10+
11+
value
12+
end
13+
14+
private
15+
16+
def validate_node_ids!
17+
node_ids = all_hosts.map { |host| node_id_for(host) }
18+
raise Error, 'PostgreSQL node_id values must be unique' unless node_ids.uniq.size == node_ids.size
19+
end
20+
end
21+
end
22+
end

‎lib/active_postgres/version.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module ActivePostgres
2-
VERSION = '0.9.7'.freeze
2+
VERSION = '0.9.8'.freeze
33
end

‎templates/repmgr.conf.erb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
end
1010
end
1111
%>
12-
node_id=<%= host == config.primary_host ? 1 : config.standby_hosts.index(host) + 2 %>
12+
node_id=<%= config.node_id_for(host) %>
1313
node_name='<%= node_label %>'
1414
<%
1515
# conninfo describes how OTHER nodes connect to THIS node

‎test/components/repmgr_templates_test.rb‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ def test_conf_uses_private_ip_for_conninfo
3636
assert_includes content, "conninfo='host=10.0.0.10 user=repmgr dbname=repmgr connect_timeout=2'"
3737
end
3838

39+
def test_conf_preserves_explicit_node_ids_after_role_change
40+
@config.primary['node_id'] = 3
41+
@config.standbys.first['node_id'] = 1
42+
43+
assert_includes render_conf({}, host: 'primary.example.com'), 'node_id=3'
44+
assert_includes render_conf({}, host: 'standby.example.com'), 'node_id=1'
45+
end
46+
3947
def test_conf_sets_automatic_failover_by_default
4048
content = render_conf({}, host: 'primary.example.com')
4149

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
require 'test_helper'
2+
3+
class ConfigurationNodeIdentityTest < Minitest::Test
4+
def test_legacy_configuration_keeps_position_based_ids
5+
config = configuration({ 'host' => 'a' }, [{ 'host' => 'b' }])
6+
7+
assert config.validate!
8+
assert_equal 1, config.node_id_for('a')
9+
assert_equal 2, config.node_id_for('b')
10+
end
11+
12+
def test_explicit_identities_survive_switchover_and_standby_reordering
13+
nodes = %w[a b c].each_with_index.map { |host, index| { 'host' => host, 'node_id' => index + 1 } }
14+
before = configuration(nodes[0], [nodes[1], nodes[2]])
15+
after = configuration(nodes[2], [nodes[1], nodes[0]])
16+
17+
assert before.validate!
18+
assert after.validate!
19+
nodes.each do |node|
20+
assert_equal before.node_id_for(node['host']), after.node_id_for(node['host'])
21+
end
22+
end
23+
24+
def test_rejects_explicit_and_implicit_id_collision
25+
config = configuration({ 'host' => 'a', 'node_id' => 2 }, [{ 'host' => 'b' }])
26+
27+
error = assert_raises(ActivePostgres::Error) { config.validate! }
28+
assert_match(/must be unique/, error.message)
29+
end
30+
31+
def test_rejects_invalid_ids_before_rendering_sql_or_configuration
32+
[0, -1, 2_147_483_648, '3', '1; DROP TABLE nodes', nil, 1.5].each do |value|
33+
config = configuration({ 'host' => 'a', 'node_id' => value }, [])
34+
assert_raises(ActivePostgres::Error) { config.validate! }
35+
end
36+
end
37+
38+
private
39+
40+
def configuration(primary, standbys)
41+
ActivePostgres::Configuration.new({ 'production' => { 'primary' => primary, 'standby' => standbys } }, 'production')
42+
end
43+
end

0 commit comments

Comments
 (0)