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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +23 -0
  3. data/README.md +158 -0
  4. data/exe/activepostgres +5 -0
  5. data/lib/active_postgres/cli.rb +157 -0
  6. data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
  7. data/lib/active_postgres/component_resolver.rb +24 -0
  8. data/lib/active_postgres/components/base.rb +38 -0
  9. data/lib/active_postgres/components/core.rb +158 -0
  10. data/lib/active_postgres/components/extensions.rb +99 -0
  11. data/lib/active_postgres/components/monitoring.rb +55 -0
  12. data/lib/active_postgres/components/pgbackrest.rb +94 -0
  13. data/lib/active_postgres/components/pgbouncer.rb +137 -0
  14. data/lib/active_postgres/components/repmgr.rb +651 -0
  15. data/lib/active_postgres/components/ssl.rb +86 -0
  16. data/lib/active_postgres/configuration.rb +190 -0
  17. data/lib/active_postgres/connection_pooler.rb +429 -0
  18. data/lib/active_postgres/credentials.rb +17 -0
  19. data/lib/active_postgres/deployment_flow.rb +154 -0
  20. data/lib/active_postgres/error_handler.rb +185 -0
  21. data/lib/active_postgres/failover.rb +83 -0
  22. data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
  23. data/lib/active_postgres/health_checker.rb +244 -0
  24. data/lib/active_postgres/installer.rb +114 -0
  25. data/lib/active_postgres/log_sanitizer.rb +67 -0
  26. data/lib/active_postgres/logger.rb +125 -0
  27. data/lib/active_postgres/performance_tuner.rb +246 -0
  28. data/lib/active_postgres/rails/database_config.rb +174 -0
  29. data/lib/active_postgres/rails/migration_guard.rb +25 -0
  30. data/lib/active_postgres/railtie.rb +28 -0
  31. data/lib/active_postgres/retry_helper.rb +80 -0
  32. data/lib/active_postgres/rollback_manager.rb +140 -0
  33. data/lib/active_postgres/secrets.rb +86 -0
  34. data/lib/active_postgres/ssh_executor.rb +288 -0
  35. data/lib/active_postgres/standby_deployment_flow.rb +122 -0
  36. data/lib/active_postgres/validator.rb +143 -0
  37. data/lib/active_postgres/version.rb +3 -0
  38. data/lib/active_postgres.rb +67 -0
  39. data/lib/tasks/postgres.rake +855 -0
  40. data/lib/tasks/rolling_update.rake +258 -0
  41. data/lib/tasks/rotate_credentials.rake +193 -0
  42. data/templates/pg_hba.conf.erb +47 -0
  43. data/templates/pgbackrest.conf.erb +43 -0
  44. data/templates/pgbouncer.ini.erb +55 -0
  45. data/templates/postgresql.conf.erb +157 -0
  46. data/templates/repmgr.conf.erb +40 -0
  47. 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