active_postgres 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +158 -0
- data/exe/activepostgres +5 -0
- data/lib/active_postgres/cli.rb +157 -0
- data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
- data/lib/active_postgres/component_resolver.rb +24 -0
- data/lib/active_postgres/components/base.rb +38 -0
- data/lib/active_postgres/components/core.rb +158 -0
- data/lib/active_postgres/components/extensions.rb +99 -0
- data/lib/active_postgres/components/monitoring.rb +55 -0
- data/lib/active_postgres/components/pgbackrest.rb +94 -0
- data/lib/active_postgres/components/pgbouncer.rb +137 -0
- data/lib/active_postgres/components/repmgr.rb +651 -0
- data/lib/active_postgres/components/ssl.rb +86 -0
- data/lib/active_postgres/configuration.rb +190 -0
- data/lib/active_postgres/connection_pooler.rb +429 -0
- data/lib/active_postgres/credentials.rb +17 -0
- data/lib/active_postgres/deployment_flow.rb +154 -0
- data/lib/active_postgres/error_handler.rb +185 -0
- data/lib/active_postgres/failover.rb +83 -0
- data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
- data/lib/active_postgres/health_checker.rb +244 -0
- data/lib/active_postgres/installer.rb +114 -0
- data/lib/active_postgres/log_sanitizer.rb +67 -0
- data/lib/active_postgres/logger.rb +125 -0
- data/lib/active_postgres/performance_tuner.rb +246 -0
- data/lib/active_postgres/rails/database_config.rb +174 -0
- data/lib/active_postgres/rails/migration_guard.rb +25 -0
- data/lib/active_postgres/railtie.rb +28 -0
- data/lib/active_postgres/retry_helper.rb +80 -0
- data/lib/active_postgres/rollback_manager.rb +140 -0
- data/lib/active_postgres/secrets.rb +86 -0
- data/lib/active_postgres/ssh_executor.rb +288 -0
- data/lib/active_postgres/standby_deployment_flow.rb +122 -0
- data/lib/active_postgres/validator.rb +143 -0
- data/lib/active_postgres/version.rb +3 -0
- data/lib/active_postgres.rb +67 -0
- data/lib/tasks/postgres.rake +855 -0
- data/lib/tasks/rolling_update.rake +258 -0
- data/lib/tasks/rotate_credentials.rake +193 -0
- data/templates/pg_hba.conf.erb +47 -0
- data/templates/pgbackrest.conf.erb +43 -0
- data/templates/pgbouncer.ini.erb +55 -0
- data/templates/postgresql.conf.erb +157 -0
- data/templates/repmgr.conf.erb +40 -0
- metadata +224 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class Credentials
|
|
3
|
+
def self.get(key_path)
|
|
4
|
+
# Try to get from Rails credentials if Rails is available
|
|
5
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
6
|
+
value = Rails.application.credentials.dig(*key_path.split('.').map(&:to_sym))
|
|
7
|
+
return value if value
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.available?
|
|
14
|
+
defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class DeploymentFlow
|
|
3
|
+
include ComponentResolver
|
|
4
|
+
|
|
5
|
+
attr_reader :config, :ssh_executor, :secrets, :logger, :rollback_manager, :skip_validation
|
|
6
|
+
|
|
7
|
+
def initialize(config, ssh_executor:, secrets:, logger:, rollback_manager:, skip_validation: false)
|
|
8
|
+
@config = config
|
|
9
|
+
@ssh_executor = ssh_executor
|
|
10
|
+
@secrets = secrets
|
|
11
|
+
@logger = logger
|
|
12
|
+
@rollback_manager = rollback_manager
|
|
13
|
+
@skip_validation = skip_validation
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
ErrorHandler.with_handling(context: { operation: operation_name }) do
|
|
18
|
+
print_header
|
|
19
|
+
validate_prerequisites
|
|
20
|
+
run_preflight_checks unless skip_validation
|
|
21
|
+
print_deployment_plan
|
|
22
|
+
return unless confirm_deployment
|
|
23
|
+
|
|
24
|
+
rollback_manager.with_rollback(description: operation_name) do
|
|
25
|
+
deploy_components
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
print_success_message
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def operation_name
|
|
35
|
+
raise NotImplementedError, 'Subclasses must implement #operation_name'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_header
|
|
39
|
+
logger.section(operation_name)
|
|
40
|
+
logger.info "Environment: #{config.environment}"
|
|
41
|
+
print_targets
|
|
42
|
+
puts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def print_targets
|
|
46
|
+
raise NotImplementedError, 'Subclasses must implement #print_targets'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_prerequisites
|
|
50
|
+
config.validate!
|
|
51
|
+
validate_specific_requirements
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_specific_requirements; end
|
|
55
|
+
|
|
56
|
+
def run_preflight_checks
|
|
57
|
+
logger.task('Running pre-flight validation') do
|
|
58
|
+
validator = Validator.new(config, ssh_executor)
|
|
59
|
+
abort 'ā Validation failed. Fix errors before proceeding, or use --skip-validation to bypass.' unless validator.validate_all
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_deployment_plan
|
|
64
|
+
logger.info "\nThis will:"
|
|
65
|
+
list_deployment_steps
|
|
66
|
+
list_warnings
|
|
67
|
+
puts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def list_deployment_steps
|
|
71
|
+
raise NotImplementedError, 'Subclasses must implement #list_deployment_steps'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def list_warnings; end
|
|
75
|
+
|
|
76
|
+
def confirm_deployment
|
|
77
|
+
print 'Do you want to proceed? (yes/no): '
|
|
78
|
+
response = $stdin.gets.chomp.downcase
|
|
79
|
+
unless %w[yes y].include?(response)
|
|
80
|
+
puts 'Deployment cancelled.'
|
|
81
|
+
return false
|
|
82
|
+
end
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def deploy_components
|
|
87
|
+
raise NotImplementedError, 'Subclasses must implement #deploy_components'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def print_success_message
|
|
91
|
+
logger.success "\nā #{operation_name} complete!"
|
|
92
|
+
print_connection_details
|
|
93
|
+
logger.info "\nNext steps:"
|
|
94
|
+
list_next_steps
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def print_connection_details
|
|
98
|
+
logger.section('Database Connection Details')
|
|
99
|
+
print_primary_info
|
|
100
|
+
print_standby_info
|
|
101
|
+
print_rails_config
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def print_primary_info
|
|
105
|
+
primary_private_ip = config.primary['private_ip'] || config.primary_host
|
|
106
|
+
logger.info "Primary Host (Public): #{config.primary_host}"
|
|
107
|
+
logger.info "Primary Host (Private): #{primary_private_ip}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def print_standby_info
|
|
111
|
+
return unless config.standby_hosts.any?
|
|
112
|
+
|
|
113
|
+
logger.info "\nStandbys:"
|
|
114
|
+
config.standby_hosts.each do |host|
|
|
115
|
+
standby_config = config.standby_config_for(host)
|
|
116
|
+
private_ip = standby_config&.dig('private_ip') || host
|
|
117
|
+
logger.info " - #{host} (Private: #{private_ip})"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def print_rails_config
|
|
122
|
+
primary_private_ip = config.primary['private_ip'] || config.primary_host
|
|
123
|
+
logger.info "\nFor Rails config/database.yml (production):"
|
|
124
|
+
logger.info " host: #{primary_private_ip} # Use private IP for internal connections"
|
|
125
|
+
logger.info ' port: 5432'
|
|
126
|
+
logger.info " username: <%= Rails.application.credentials.dig(:postgres, :username) || 'app' %>"
|
|
127
|
+
logger.info ' password: <%= Rails.application.credentials.dig(:postgres, :password) %>'
|
|
128
|
+
puts ''
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def list_next_steps
|
|
132
|
+
raise NotImplementedError, 'Subclasses must implement #list_next_steps'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def setup_component(component_name, hosts)
|
|
136
|
+
logger.task("Setting up #{component_name}") do
|
|
137
|
+
component_class = component_class_for(component_name)
|
|
138
|
+
component = component_class.new(config, ssh_executor, secrets)
|
|
139
|
+
|
|
140
|
+
Array(hosts).each do |host|
|
|
141
|
+
captured_logger = logger
|
|
142
|
+
rollback_manager.register("Uninstall #{component_name} on #{host}", host: host) do
|
|
143
|
+
component.uninstall
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
captured_logger.warn "Failed to uninstall #{component_name} on #{host}: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
component.install
|
|
150
|
+
logger.success "#{component_name} setup complete"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class ErrorHandler
|
|
3
|
+
# Define common errors and their troubleshooting steps
|
|
4
|
+
ERROR_GUIDES = {
|
|
5
|
+
ssh_connection: {
|
|
6
|
+
title: 'SSH Connection Failed',
|
|
7
|
+
hints: [
|
|
8
|
+
'Ensure SSH keys are properly configured for the target host',
|
|
9
|
+
'Verify the host is reachable: ping <hostname>',
|
|
10
|
+
'Check SSH config in ~/.ssh/config',
|
|
11
|
+
'Try manual SSH connection: ssh <user>@<host>'
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
private_network_connectivity: {
|
|
16
|
+
title: 'Private Network Connectivity Failed',
|
|
17
|
+
hints: [
|
|
18
|
+
'Ensure the private/VPC network (or WireGuard) is configured on all nodes',
|
|
19
|
+
'Verify interfaces are up and have the expected IPs',
|
|
20
|
+
'Check firewall/security-group rules for the replication subnet',
|
|
21
|
+
'Test connectivity: ping <private_ip>',
|
|
22
|
+
'Confirm routing tables/NAT rules allow node-to-node traffic'
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
postgresql_not_starting: {
|
|
27
|
+
title: 'PostgreSQL Failed to Start',
|
|
28
|
+
hints: [
|
|
29
|
+
'Check PostgreSQL logs: sudo tail -100 /var/log/postgresql/postgresql-*-main.log',
|
|
30
|
+
'Verify configuration: sudo -u postgres pg_lsclusters',
|
|
31
|
+
'Check if port 5432 is already in use: sudo lsof -i :5432',
|
|
32
|
+
'Verify data directory permissions: ls -la /var/lib/postgresql/*/main',
|
|
33
|
+
'Check systemd status: sudo systemctl status postgresql'
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
repmgr_clone_failed: {
|
|
38
|
+
title: 'Repmgr Standby Clone Failed',
|
|
39
|
+
hints: [
|
|
40
|
+
'Verify primary PostgreSQL is running and accessible',
|
|
41
|
+
'Check pg_hba.conf allows replication from standby IP',
|
|
42
|
+
'Test connection: psql -h <primary_ip> -U repmgr -d repmgr',
|
|
43
|
+
'Ensure sufficient disk space on standby',
|
|
44
|
+
'Check repmgr logs for detailed error messages',
|
|
45
|
+
'Verify repmgr user has replication privileges'
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
repmgr_register_failed: {
|
|
50
|
+
title: 'Repmgr Registration Failed',
|
|
51
|
+
hints: [
|
|
52
|
+
'Ensure primary is registered first: repmgr cluster show',
|
|
53
|
+
'Verify standby can connect to primary PostgreSQL',
|
|
54
|
+
'Check repmgr.conf conninfo is correct',
|
|
55
|
+
'Ensure repmgr database and tables exist on primary',
|
|
56
|
+
'Verify standby PostgreSQL is running before registration'
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
ssl_certificate_error: {
|
|
61
|
+
title: 'SSL Certificate Error',
|
|
62
|
+
hints: [
|
|
63
|
+
'Check certificate file permissions (should be 600 for .key)',
|
|
64
|
+
'Verify certificate paths in postgresql.conf',
|
|
65
|
+
'Ensure certificate is valid: openssl x509 -in <cert> -text -noout',
|
|
66
|
+
'Check certificate ownership: ls -la /etc/postgresql/*/main/server.*'
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
disk_space_error: {
|
|
71
|
+
title: 'Insufficient Disk Space',
|
|
72
|
+
hints: [
|
|
73
|
+
'Check available disk space: df -h /var/lib/postgresql',
|
|
74
|
+
'Clean up old PostgreSQL logs if needed',
|
|
75
|
+
'Consider increasing volume size',
|
|
76
|
+
'Check for large files: du -sh /var/lib/postgresql/* | sort -h'
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
authentication_failed: {
|
|
81
|
+
title: 'PostgreSQL Authentication Failed',
|
|
82
|
+
hints: [
|
|
83
|
+
'Verify pg_hba.conf has correct authentication methods',
|
|
84
|
+
"Check if user exists: sudo -u postgres psql -c '\\du'",
|
|
85
|
+
'Ensure password is set correctly',
|
|
86
|
+
'Reload PostgreSQL after pg_hba.conf changes: sudo systemctl reload postgresql',
|
|
87
|
+
'Check PostgreSQL logs for authentication errors'
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
class << self
|
|
93
|
+
# Handle an error with context and helpful hints
|
|
94
|
+
def handle(error, context: {}, error_type: nil)
|
|
95
|
+
puts "\n#{'=' * 80}"
|
|
96
|
+
puts 'ā ERROR OCCURRED'.center(80)
|
|
97
|
+
puts '=' * 80
|
|
98
|
+
|
|
99
|
+
puts "\nError: #{LogSanitizer.sanitize(error.message)}"
|
|
100
|
+
puts "Type: #{error.class.name}"
|
|
101
|
+
|
|
102
|
+
if context.any?
|
|
103
|
+
puts "\nContext:"
|
|
104
|
+
context.each do |key, value|
|
|
105
|
+
puts " #{key}: #{LogSanitizer.sanitize(value.to_s)}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if error.backtrace&.any?
|
|
110
|
+
puts "\nBacktrace (last 5 lines):"
|
|
111
|
+
error.backtrace.first(5).each do |line|
|
|
112
|
+
puts " #{LogSanitizer.sanitize(line)}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Try to identify error type from message if not provided
|
|
117
|
+
error_type ||= identify_error_type(error.message)
|
|
118
|
+
|
|
119
|
+
if error_type && ERROR_GUIDES[error_type]
|
|
120
|
+
show_troubleshooting_guide(error_type)
|
|
121
|
+
else
|
|
122
|
+
show_generic_troubleshooting
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
puts "\n#{'=' * 80}\n"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Identify error type from error message
|
|
129
|
+
def identify_error_type(message)
|
|
130
|
+
case message.downcase
|
|
131
|
+
when /ssh|connection refused|network unreachable/
|
|
132
|
+
:ssh_connection
|
|
133
|
+
when /private network|vpn|wireguard network/
|
|
134
|
+
:private_network_connectivity
|
|
135
|
+
when /postgresql.*not.*start|cluster.*not.*running/
|
|
136
|
+
:postgresql_not_starting
|
|
137
|
+
when /repmgr.*clone|data directory/
|
|
138
|
+
:repmgr_clone_failed
|
|
139
|
+
when /repmgr.*register|unable to connect to.*primary/
|
|
140
|
+
:repmgr_register_failed
|
|
141
|
+
when /ssl|certificate|tls/
|
|
142
|
+
:ssl_certificate_error
|
|
143
|
+
when /no space|disk full/
|
|
144
|
+
:disk_space_error
|
|
145
|
+
when /authentication|password|pg_hba/
|
|
146
|
+
:authentication_failed
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Show troubleshooting guide for a specific error type
|
|
151
|
+
def show_troubleshooting_guide(error_type)
|
|
152
|
+
guide = ERROR_GUIDES[error_type]
|
|
153
|
+
return unless guide
|
|
154
|
+
|
|
155
|
+
puts "\n#{'-' * 80}"
|
|
156
|
+
puts "š§ TROUBLESHOOTING: #{guide[:title]}"
|
|
157
|
+
puts '-' * 80
|
|
158
|
+
puts "\nTry these steps:"
|
|
159
|
+
guide[:hints].each_with_index do |hint, index|
|
|
160
|
+
puts " #{index + 1}. #{hint}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Show generic troubleshooting steps
|
|
165
|
+
def show_generic_troubleshooting
|
|
166
|
+
puts "\n#{'-' * 80}"
|
|
167
|
+
puts 'š§ TROUBLESHOOTING STEPS'
|
|
168
|
+
puts '-' * 80
|
|
169
|
+
puts "\n1. Check the error message and backtrace above"
|
|
170
|
+
puts '2. Verify all hosts are accessible via SSH'
|
|
171
|
+
puts '3. Check PostgreSQL logs on affected hosts'
|
|
172
|
+
puts '4. Run with --verbose for more detailed output'
|
|
173
|
+
puts '5. Consult the documentation: https://github.com/your-repo/active_postgres'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Wrap a block with error handling
|
|
177
|
+
def with_handling(context: {})
|
|
178
|
+
yield
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
handle(e, context: context)
|
|
181
|
+
raise
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class Failover
|
|
3
|
+
attr_reader :config, :ssh_executor
|
|
4
|
+
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
@ssh_executor = SSHExecutor.new(config)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def promote(host_or_node)
|
|
11
|
+
# Determine target host
|
|
12
|
+
target_host = resolve_host(host_or_node)
|
|
13
|
+
|
|
14
|
+
raise Error, "Could not resolve host: #{host_or_node}" unless target_host
|
|
15
|
+
|
|
16
|
+
raise Error, "Host is not a standby: #{target_host}" unless config.standby_hosts.include?(target_host)
|
|
17
|
+
|
|
18
|
+
puts "==> Promoting #{target_host} to primary..."
|
|
19
|
+
puts
|
|
20
|
+
puts 'WARNING: This will promote the standby to primary.'
|
|
21
|
+
puts 'Make sure to update your database.yml and restart your application.'
|
|
22
|
+
puts
|
|
23
|
+
print 'Continue? (y/N): '
|
|
24
|
+
|
|
25
|
+
response = $stdin.gets.chomp
|
|
26
|
+
unless response.downcase == 'y'
|
|
27
|
+
puts 'Cancelled.'
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Perform promotion
|
|
32
|
+
if config.component_enabled?(:repmgr)
|
|
33
|
+
promote_with_repmgr(target_host)
|
|
34
|
+
else
|
|
35
|
+
promote_manual(target_host)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts "\nā Promotion complete!"
|
|
39
|
+
puts "\nNext steps:"
|
|
40
|
+
puts " 1. Update database.yml to point to new primary: #{target_host}"
|
|
41
|
+
puts ' 2. Restart your application'
|
|
42
|
+
puts ' 3. Rebuild old primary as new standby (if needed)'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def resolve_host(host_or_node)
|
|
48
|
+
# Check if it's already an IP/hostname
|
|
49
|
+
return host_or_node if config.all_hosts.include?(host_or_node)
|
|
50
|
+
|
|
51
|
+
# Try to find by node name
|
|
52
|
+
config.standbys.each do |standby|
|
|
53
|
+
return standby['host'] if standby['name'] == host_or_node
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def promote_with_repmgr(host)
|
|
60
|
+
puts 'Promoting using repmgr...'
|
|
61
|
+
postgres_user = config.postgres_user
|
|
62
|
+
|
|
63
|
+
ssh_executor.execute_on_host(host) do
|
|
64
|
+
# Stop old primary if still running (optional, skip if manual intervention needed)
|
|
65
|
+
# execute :sudo, "-u", postgres_user, "repmgr", "standby", "switchover"
|
|
66
|
+
|
|
67
|
+
# Promote this standby
|
|
68
|
+
execute :sudo, '-u', postgres_user, 'repmgr', 'standby', 'promote'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def promote_manual(host)
|
|
73
|
+
puts 'Promoting manually (no repmgr)...'
|
|
74
|
+
postgres_user = config.postgres_user
|
|
75
|
+
version = config.version
|
|
76
|
+
|
|
77
|
+
ssh_executor.execute_on_host(host) do
|
|
78
|
+
# Promote standby to primary
|
|
79
|
+
execute :sudo, '-u', postgres_user, 'pg_ctl', 'promote', '-D', "/var/lib/postgresql/#{version}/main"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require 'rails/generators'
|
|
2
|
+
require_relative '../../rails/database_config'
|
|
3
|
+
|
|
4
|
+
module ActivePostgres
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path('templates', __dir__)
|
|
8
|
+
|
|
9
|
+
desc 'Install active_postgres configuration files'
|
|
10
|
+
|
|
11
|
+
def create_config_file
|
|
12
|
+
template 'postgres.yml.erb', 'config/postgres.yml'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def update_database_yml
|
|
16
|
+
if File.exist?('config/database.yml')
|
|
17
|
+
contents = File.read('config/database.yml')
|
|
18
|
+
|
|
19
|
+
# Extract production section
|
|
20
|
+
production_section = contents[/^production:.*?(?=^[a-z_]+:|\z)/m] || ''
|
|
21
|
+
|
|
22
|
+
# Check if we have the NEW Rails credentials-based config in production section
|
|
23
|
+
if production_section.include?('Generated by active_postgres') && production_section.include?('Rails.application.credentials.dig(:postgres')
|
|
24
|
+
say_status :skipped, 'config/database.yml already has ActivePostgres production config'
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if old ENV-based config exists
|
|
29
|
+
if production_section.include?('Generated by active_postgres') && production_section.include?('ENV.fetch')
|
|
30
|
+
say_status :info, 'Found old ENV-based ActivePostgres config'
|
|
31
|
+
return unless yes?('Replace with Rails credentials-based config? (y/n)', :yellow)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Remove existing production section if present
|
|
35
|
+
if contents =~ /^production:/m
|
|
36
|
+
if contents.include?('Generated by active_postgres')
|
|
37
|
+
say_status :info, 'Replacing ActivePostgres production config'
|
|
38
|
+
else
|
|
39
|
+
say_status :info, 'Found existing production config'
|
|
40
|
+
return unless yes?('Replace with ActivePostgres config? (y/n)', :yellow)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
lines = contents.lines
|
|
44
|
+
new_lines = []
|
|
45
|
+
skip_section = false
|
|
46
|
+
skip_boring_comment = false
|
|
47
|
+
|
|
48
|
+
lines.each do |line|
|
|
49
|
+
# Skip the "Generated by active_postgres" comment before production section
|
|
50
|
+
if line =~ /^# Generated by active_postgres/
|
|
51
|
+
skip_boring_comment = true
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if line =~ /^production:/
|
|
56
|
+
skip_section = true
|
|
57
|
+
skip_boring_comment = false
|
|
58
|
+
next
|
|
59
|
+
elsif skip_section && (line =~ /^[a-z_]+:/ || line =~ /^# [A-Z]/)
|
|
60
|
+
skip_section = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
new_lines << line unless skip_section || skip_boring_comment
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Remove trailing whitespace lines
|
|
67
|
+
new_lines.pop while new_lines.last&.strip&.empty?
|
|
68
|
+
contents = new_lines.join
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Append ActivePostgres production config directly
|
|
72
|
+
contents += "\n" unless contents.end_with?("\n")
|
|
73
|
+
contents += generate_production_config
|
|
74
|
+
|
|
75
|
+
File.write('config/database.yml', contents)
|
|
76
|
+
say_status :updated, 'config/database.yml (added ActivePostgres production config)'
|
|
77
|
+
else
|
|
78
|
+
create_file 'config/database.yml', <<~YAML
|
|
79
|
+
default: &default
|
|
80
|
+
adapter: postgresql
|
|
81
|
+
encoding: unicode
|
|
82
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
|
83
|
+
|
|
84
|
+
development:
|
|
85
|
+
<<: *default
|
|
86
|
+
database: #{boring_app_name}_development
|
|
87
|
+
|
|
88
|
+
test:
|
|
89
|
+
<<: *default
|
|
90
|
+
database: #{boring_app_name}_test
|
|
91
|
+
|
|
92
|
+
#{generate_production_config}
|
|
93
|
+
YAML
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def show_credentials_template
|
|
98
|
+
puts "\nš Add to Rails credentials (rails credentials:edit):"
|
|
99
|
+
puts
|
|
100
|
+
puts 'postgres:'
|
|
101
|
+
puts ' # Rails application database credentials'
|
|
102
|
+
puts " username: #{boring_app_name}"
|
|
103
|
+
puts " password: \"#{generate_secure_password}\""
|
|
104
|
+
puts " database: #{boring_app_name}_production"
|
|
105
|
+
puts ' primary_host: YOUR_PRIMARY_IP # Update after provisioning (e.g., 10.8.0.100)'
|
|
106
|
+
puts ' replica_host: YOUR_REPLICA_IP # Update after provisioning (e.g., 10.8.0.101)'
|
|
107
|
+
puts ' port: 5432'
|
|
108
|
+
puts ' statement_timeout: 15s'
|
|
109
|
+
puts " application_name: #{boring_app_name}"
|
|
110
|
+
puts
|
|
111
|
+
puts ' # PostgreSQL server setup credentials (for active_postgres rake tasks)'
|
|
112
|
+
puts " superuser_password: \"#{generate_secure_password}\""
|
|
113
|
+
puts " replication_password: \"#{generate_secure_password}\""
|
|
114
|
+
puts " repmgr_password: \"#{generate_secure_password}\""
|
|
115
|
+
puts
|
|
116
|
+
puts 'š Secure passwords have been auto-generated above.'
|
|
117
|
+
puts 'š Update primary_host and replica_host with your actual IPs after provisioning.'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def show_next_steps
|
|
121
|
+
puts "\nā
ActivePostgres installed!"
|
|
122
|
+
puts "\nš Next steps:"
|
|
123
|
+
puts ' 1. Either:'
|
|
124
|
+
puts ' a) Edit config/postgres.yml with your database servers, OR'
|
|
125
|
+
puts ' b) Use Terraform to auto-generate config/postgres.yml'
|
|
126
|
+
puts ' 2. Add secrets to Rails credentials (see above)'
|
|
127
|
+
puts ' 3. Deploy PostgreSQL HA: rails active_postgres:setup'
|
|
128
|
+
puts ' 4. Check health: rails active_postgres:health'
|
|
129
|
+
puts "\nš See config/postgres.example.yml in gem for full examples"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def boring_app_name
|
|
135
|
+
@boring_app_name ||= begin
|
|
136
|
+
if defined?(::Rails) && ::Rails.application
|
|
137
|
+
::Rails.application.class.module_parent_name.underscore
|
|
138
|
+
else
|
|
139
|
+
File.basename(destination_root).tr('- ', '_')
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError
|
|
142
|
+
'app'
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def generate_production_config
|
|
147
|
+
# Generate the production config using Rails credentials
|
|
148
|
+
# ERB values are quoted to prevent YAML parser confusion with colons
|
|
149
|
+
app_name = boring_app_name
|
|
150
|
+
<<~YAML
|
|
151
|
+
# Generated by active_postgres. Update Rails credentials (rails credentials:edit) to change values.
|
|
152
|
+
production:
|
|
153
|
+
primary:
|
|
154
|
+
<<: *default
|
|
155
|
+
username: "<%= Rails.application.credentials.dig(:postgres, :username) || '#{app_name}' %>"
|
|
156
|
+
password: "<%= Rails.application.credentials.dig(:postgres, :password) %>"
|
|
157
|
+
database: "<%= Rails.application.credentials.dig(:postgres, :database) || '#{app_name}_production' %>"
|
|
158
|
+
host: "<%= Rails.application.credentials.dig(:postgres, :primary_host) || 'localhost' %>"
|
|
159
|
+
port: "<%= Rails.application.credentials.dig(:postgres, :port) || 5432 %>"
|
|
160
|
+
variables:
|
|
161
|
+
statement_timeout: "<%= Rails.application.credentials.dig(:postgres, :statement_timeout) || '15s' %>"
|
|
162
|
+
application_name: "<%= Rails.application.credentials.dig(:postgres, :application_name) || '#{app_name}-primary' %>"
|
|
163
|
+
primary_replica:
|
|
164
|
+
<<: *default
|
|
165
|
+
username: "<%= Rails.application.credentials.dig(:postgres, :username) || '#{app_name}' %>"
|
|
166
|
+
password: "<%= Rails.application.credentials.dig(:postgres, :password) %>"
|
|
167
|
+
database: "<%= Rails.application.credentials.dig(:postgres, :database) || '#{app_name}_production' %>"
|
|
168
|
+
host: "<%= Rails.application.credentials.dig(:postgres, :replica_host) %>"
|
|
169
|
+
port: "<%= Rails.application.credentials.dig(:postgres, :port) || 5432 %>"
|
|
170
|
+
replica: true
|
|
171
|
+
variables:
|
|
172
|
+
statement_timeout: "<%= Rails.application.credentials.dig(:postgres, :statement_timeout) || '15s' %>"
|
|
173
|
+
application_name: "<%= Rails.application.credentials.dig(:postgres, :application_name) || '#{app_name}-replica' %>"
|
|
174
|
+
YAML
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def generate_secure_password(length: 32)
|
|
178
|
+
require 'securerandom'
|
|
179
|
+
# Generate a secure password with letters, numbers, and safe special characters
|
|
180
|
+
# Avoiding quotes and backslashes that could cause issues in YAML/shell
|
|
181
|
+
chars = [*'A'..'Z', *'a'..'z', *'0'..'9', *'!@#$%^&*()-_=+[]{}|;:,.<>?/~'.chars]
|
|
182
|
+
Array.new(length) { chars.sample }.join
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|