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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/).

## Unreleased

### Added

- Support strict operator SSH through declared jump hosts.
- Allow a standby to override repmgr failover and priority policy.

## 0.9.6 - 2026-08-20

### Changed
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ production:
version: 18
user: ubuntu
ssh_key: ~/.ssh/id_rsa
ssh_known_hosts_file: config/ssh_known_hosts

jump_hosts:
app-oci:
host: 129.213.194.40

primary:
host: 34.12.234.81 # Public IP for SSH
Expand All @@ -43,6 +48,12 @@ production:
standby:
- host: 52.23.45.67
private_ip: 10.8.0.11
- host: 100.63.4.227
private_ip: 10.8.0.12
jump_host: app-oci
repmgr:
auto_failover: false
priority: 0

components:
repmgr: {enabled: true}
Expand All @@ -56,6 +67,11 @@ production:
app_password: $POSTGRES_APP_PASSWORD
```

`jump_host` keeps the operator SSH key on the workstation and proxies the
connection through a declared host. A standby-level `repmgr` block overrides
the cluster defaults, so a cross-provider disaster-recovery replica can remain
manual-promotion while the ordinary standby retains automatic failover.

Add credentials (`rails credentials:edit`):

```yaml
Expand Down
14 changes: 14 additions & 0 deletions config/postgres.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ shared: &shared
version: 18
user: ubuntu
ssh_key: ~/.ssh/id_rsa
# Use one reviewed host-key file for direct and proxied SSH connections.
# ssh_known_hosts_file: config/ssh_known_hosts
# SSH host key verification: always (strict) or accept_new (first connection)
ssh_host_key_verification: always

# Optional operator jump hosts. Standbys reference these by name; private
# keys remain on the operator workstation.
# jump_hosts:
# app-oci:
# host: 129.213.194.40
# user: ubuntu

# Components (enable as needed)
components:
Expand Down Expand Up @@ -271,6 +280,11 @@ production:
- host: 18.156.78.90 # Public IP for SSH
private_ip: 10.8.0.102 # Optional: Private IP for replication
label: eu-west-1
# jump_host: app-oci
# Keep a cross-provider DR standby out of automatic promotion elections.
# repmgr:
# auto_failover: false
# priority: 0

# Optional: Add more replicas for read scaling
# - host: 35.178.45.23
Expand Down
11 changes: 8 additions & 3 deletions lib/active_postgres/components/repmgr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def setup_primary
puts ' Setting up primary with repmgr...'

host = config.primary_host
repmgr_config = config.component_config(:repmgr)
repmgr_config = config.repmgr_config_for(host)
version = config.version
repmgr_password = normalize_repmgr_password(secrets.resolve('repmgr_password'))
replication_password = normalize_replication_password(secrets.resolve('replication_password'))
Expand Down Expand Up @@ -236,7 +236,7 @@ def setup_standby(standby_host)
puts " Setting up standby: #{standby_host}..."

host = standby_host
repmgr_config = config.component_config(:repmgr)
repmgr_config = config.repmgr_config_for(standby_host)
primary_replication_host = config.primary_replication_host
version = config.version
secrets_obj = secrets
Expand Down Expand Up @@ -1101,7 +1101,12 @@ def update_postgres_configs_on_standby(host, version)
end

def enable_repmgrd_if_configured(host, repmgr_config)
return if repmgr_config[:auto_failover] == false
if repmgr_config[:auto_failover] == false
ssh_executor.execute_on_host(host) do
execute :sudo, 'systemctl', 'disable', '--now', 'repmgrd', raise_on_non_zero_exit: false
end
return
end

default_config = <<~CONF
REPMGRD_ENABLED=yes
Expand Down
43 changes: 35 additions & 8 deletions lib/active_postgres/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

module ActivePostgres
class Configuration
attr_reader :environment, :version, :user, :ssh_key, :ssh_host_key_verification, :primary, :standbys, :components, :secrets_config,
:database_config
attr_reader :environment, :version, :user, :ssh_key, :ssh_known_hosts_file, :ssh_host_key_verification,
:primary, :standbys, :jump_hosts, :components, :secrets_config, :database_config

def initialize(config_hash, environment = 'development')
@environment = environment
Expand All @@ -14,13 +14,15 @@ def initialize(config_hash, environment = 'development')
@version = env_config['version'] || 18
@user = env_config['user'] || 'ubuntu'
@ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
@ssh_known_hosts_file = optional_path(env_config['ssh_known_hosts_file'])
@ssh_host_key_verification = normalize_ssh_host_key_verification(
env_config['ssh_host_key_verification'] || env_config['ssh_verify_host_key']
)

@primary = env_config['primary'] || {}
@standbys = env_config['standby'] || []
@standbys = [@standbys] unless @standbys.is_a?(Array)
@jump_hosts = env_config['jump_hosts'] || {}

@components = parse_components(env_config['components'] || {})
@secrets_config = env_config['secrets'] || {}
Expand Down Expand Up @@ -111,6 +113,24 @@ def standby_config_for(host)
@standbys.find { |s| s['host'] == host }
end

def node_config_for(host)
return @primary if host == primary_host

standby_config_for(host)
end

def jump_host_config_for(host)
jump_host_name = node_config_for(host)&.dig('jump_host')
return unless jump_host_name

@jump_hosts[jump_host_name] || raise(Error, "Host #{host} jump_host not found: #{jump_host_name}")
end

def repmgr_config_for(host)
overrides = node_config_for(host)&.dig('repmgr') || {}
component_config(:repmgr).merge(symbolize_keys(overrides))
end

def node_label_for(host)
if host == primary_host
@primary['label']
Expand Down Expand Up @@ -149,6 +169,15 @@ def validate!
end
end

all_hosts.each do |host|
jump_host_name = node_config_for(host)&.dig('jump_host')
next unless jump_host_name

jump_host = @jump_hosts[jump_host_name]
raise Error, "Host #{host} jump_host not found: #{jump_host_name}" unless jump_host
raise Error, "Jump host #{jump_host_name} must include host" if jump_host['host'].to_s.strip.empty?
end

if component_enabled?(:repmgr)
dns_failover = component_config(:repmgr)[:dns_failover]
if dns_failover && dns_failover[:enabled]
Expand Down Expand Up @@ -246,12 +275,6 @@ def parse_components(components_config)
result
end

def node_config_for(host)
return @primary if host == primary_host

standby_config_for(host)
end

def private_ip_for(node_config)
return unless node_config

Expand Down Expand Up @@ -295,5 +318,9 @@ def normalize_ssh_host_key_verification(value)
raise Error,
"Invalid ssh_host_key_verification '#{value}'. Use 'always' or 'accept_new'."
end

def optional_path(path)
File.expand_path(path) if path
end
end
end
65 changes: 61 additions & 4 deletions lib/active_postgres/ssh_executor.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
require 'sshkit'
require 'sshkit/dsl'
require 'net/ssh/proxy/command'
require 'securerandom'
require 'shellwords'
require 'stringio'

module ActivePostgres
Expand All @@ -20,24 +22,24 @@ def quiet?
end

def execute_on_host(host, &)
on("#{config.user}@#{host}", &)
on(ssh_host_for(host), &)
end

def execute_on_host_as(host, user, &)
on("#{user}@#{host}", &)
on(ssh_host_for(host, user:), &)
end

def execute_on_primary(&)
execute_on_host(config.primary_host, &)
end

def execute_on_standbys(&)
hosts = config.standby_hosts.map { |h| "#{config.user}@#{h}" }
hosts = config.standby_hosts.map { |host| ssh_host_for(host) }
on(hosts, in: :parallel, &)
end

def execute_on_all_hosts(&)
hosts = config.all_hosts.map { |h| "#{config.user}@#{h}" }
hosts = config.all_hosts.map { |host| ssh_host_for(host) }
on(hosts, in: :parallel, &)
end

Expand Down Expand Up @@ -307,6 +309,8 @@ def setup_sshkit
number_of_password_prompts: 0
}

options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file

if config.ssh_key && File.exist?(config.ssh_key)
options[:keys] = [config.ssh_key]
options[:keys_only] = true
Expand All @@ -315,5 +319,58 @@ def setup_sshkit
ssh.ssh_options = options
end
end

def ssh_host_for(host, user: config.user)
ssh_host = SSHKit::Host.new(host)
ssh_host.user = user

node_config = config.node_config_for(host) || {}
ssh_host.port = node_config['ssh_port'] || node_config['port'] if node_config['ssh_port'] || node_config['port']

options = {
verify_host_key: config.ssh_host_key_verification || :always
}
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file

if node_config['ssh_key']
options[:keys] = [File.expand_path(node_config['ssh_key'])]
options[:keys_only] = true
end

if (jump_host_config = config.jump_host_config_for(host))
options[:proxy] = jump_proxy(jump_host_config)
end

ssh_host.ssh_options = options
ssh_host
end

def jump_proxy(jump_host_config)
jump_user = jump_host_config['ssh_user'] || jump_host_config['user'] || config.user
jump_port = jump_host_config['ssh_port'] || jump_host_config['port']
jump_key = jump_host_config['ssh_key'] || config.ssh_key
command = [
'ssh',
'-F', '/dev/null',
'-o', 'BatchMode=yes',
'-o', 'ForwardAgent=no',
'-o', "StrictHostKeyChecking=#{open_ssh_host_key_mode}"
]

command.push('-o', "UserKnownHostsFile=#{config.ssh_known_hosts_file}") if config.ssh_known_hosts_file

command.push('-i', File.expand_path(jump_key), '-o', 'IdentitiesOnly=yes') if
jump_key && File.exist?(File.expand_path(jump_key))

command.push('-p', jump_port.to_s) if jump_port
command.push('-W', '%h:%p', "#{jump_user}@#{jump_host_config.fetch('host')}")

command_line = Shellwords.join(command).gsub('\\%h', '%h').gsub('\\%p', '%p')
Net::SSH::Proxy::Command.new(command_line)
end

def open_ssh_host_key_mode
config.ssh_host_key_verification == :accept_new ? 'accept-new' : 'yes'
end
end
end
65 changes: 65 additions & 0 deletions test/jump_host_configuration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require 'test_helper'

class JumpHostConfigurationTest < Minitest::Test
def test_standby_can_reference_a_declared_jump_host
config = build_config(
'jump_hosts' => {
'app-oci' => { 'host' => '1.2.3.4', 'user' => 'ubuntu' }
},
'standby' => [
{ 'host' => '5.6.7.8', 'private_ip' => '10.0.0.11', 'jump_host' => 'app-oci' }
]
)

assert_equal '1.2.3.4', config.jump_host_config_for('5.6.7.8').fetch('host')
end

def test_unknown_jump_host_fails_validation
config = build_config(
'components' => { 'repmgr' => { 'enabled' => true } },
'standby' => [
{ 'host' => '5.6.7.8', 'private_ip' => '10.0.0.11', 'jump_host' => 'missing' }
],
'secrets' => { 'replication_password' => 'secret' }
)

error = assert_raises(ActivePostgres::Error) { config.validate! }

assert_match(/jump_host not found: missing/, error.message)
end

def test_standby_repmgr_policy_overrides_the_cluster_default
config = build_config(
'components' => {
'repmgr' => { 'enabled' => true, 'auto_failover' => true, 'priority' => 100 }
},
'standby' => [
{
'host' => 'standby.example.com',
'private_ip' => '10.0.0.11',
'repmgr' => { 'auto_failover' => false, 'priority' => 0 }
}
]
)

policy = config.repmgr_config_for('standby.example.com')

assert_equal false, policy.fetch(:auto_failover)
assert_equal 0, policy.fetch(:priority)
end

private

def build_config(overrides)
config = {
'test' => {
'primary' => { 'host' => 'primary.example.com' },
'standby' => [{ 'host' => 'standby.example.com' }],
'components' => {},
'secrets' => {}
}.merge(overrides)
}

ActivePostgres::Configuration.new(config, 'test')
end
end
Loading