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,244 @@
1
+ module ActivePostgres
2
+ class HealthChecker
3
+ attr_reader :config, :ssh_executor
4
+
5
+ def initialize(config)
6
+ @config = config
7
+ @ssh_executor = SSHExecutor.new(config, quiet: true)
8
+ end
9
+
10
+ def show_status
11
+ puts
12
+ puts "PostgreSQL Cluster Status (#{config.environment})"
13
+ puts '=' * 70
14
+ puts
15
+
16
+ # Collect all node data first
17
+ nodes = collect_node_status
18
+
19
+ # Print table
20
+ print_status_table(nodes)
21
+
22
+ # Print components
23
+ print_components
24
+
25
+ puts
26
+ end
27
+
28
+ def run_health_checks
29
+ puts '==> Running health checks...'
30
+ puts
31
+
32
+ all_ok = true
33
+
34
+ # Check primary
35
+ print "Primary (#{config.primary_host})... "
36
+ if check_postgres_running(config.primary_host)
37
+ puts '✓'
38
+ else
39
+ puts '✗'
40
+ all_ok = false
41
+ end
42
+
43
+ # Check standbys
44
+ config.standby_hosts.each do |host|
45
+ print "Standby (#{host})... "
46
+ if check_postgres_running(host) && check_replication_status(host)
47
+ puts '✓'
48
+ else
49
+ puts '✗'
50
+ all_ok = false
51
+ end
52
+ end
53
+
54
+ puts
55
+ if all_ok
56
+ puts '✓ All checks passed'
57
+ else
58
+ puts '✗ Some checks failed'
59
+ end
60
+
61
+ all_ok
62
+ end
63
+
64
+ def cluster_status
65
+ status = {
66
+ primary: {
67
+ host: config.primary_host,
68
+ status: check_postgres_running(config.primary_host) ? 'running' : 'down',
69
+ connections: get_connection_count(config.primary_host),
70
+ replication_lag: nil
71
+ },
72
+ standbys: []
73
+ }
74
+
75
+ config.standby_hosts.each do |host|
76
+ standby_status = {
77
+ host: host,
78
+ status: check_postgres_running(host) ? 'streaming' : 'down',
79
+ lag: get_replication_lag(host),
80
+ sync_state: 'async'
81
+ }
82
+ status[:standbys] << standby_status
83
+ end
84
+
85
+ status
86
+ end
87
+
88
+ private
89
+
90
+ def collect_node_status
91
+ nodes = []
92
+
93
+ # Primary
94
+ primary_config = config.primary
95
+ nodes << {
96
+ role: 'primary',
97
+ host: config.primary_host,
98
+ private_ip: primary_config&.dig('private_ip') || '-',
99
+ label: primary_config&.dig('label') || '-',
100
+ status: check_postgres_running(config.primary_host) ? '✓ running' : '✗ down',
101
+ connections: get_connection_count(config.primary_host),
102
+ lag: '-'
103
+ }
104
+
105
+ # Standbys
106
+ config.standby_hosts.each_with_index do |host, i|
107
+ standby_config = config.standbys[i]
108
+ running = check_postgres_running(host)
109
+
110
+ nodes << {
111
+ role: 'standby',
112
+ host: host,
113
+ private_ip: standby_config&.dig('private_ip') || '-',
114
+ label: standby_config&.dig('label') || '-',
115
+ status: running ? '✓ streaming' : '✗ down',
116
+ connections: running ? get_connection_count(host) : 0,
117
+ lag: running ? get_replication_lag(host) : '-'
118
+ }
119
+ end
120
+
121
+ nodes
122
+ end
123
+
124
+ def print_status_table(nodes)
125
+ cols = calculate_column_widths(nodes)
126
+ print_table_header(cols)
127
+ nodes.each { |node| print_table_row(node, cols) }
128
+ end
129
+
130
+ def calculate_column_widths(nodes)
131
+ {
132
+ role: [4, nodes.map { |n| n[:role].length }.max].max,
133
+ host: [4, nodes.map { |n| n[:host].length }.max].max,
134
+ private_ip: [10, nodes.map { |n| n[:private_ip].to_s.length }.max].max,
135
+ label: [5, nodes.map { |n| n[:label].to_s.length }.max].max,
136
+ status: [6, nodes.map { |n| n[:status].length }.max].max,
137
+ conn: 5,
138
+ lag: [3, nodes.map { |n| n[:lag].to_s.length }.max].max
139
+ }
140
+ end
141
+
142
+ def print_table_header(cols)
143
+ fmt = "%-#{cols[:role]}s %-#{cols[:host]}s %-#{cols[:private_ip]}s " \
144
+ "%-#{cols[:label]}s %-#{cols[:status]}s %#{cols[:conn]}s %#{cols[:lag]}s"
145
+ header = format(fmt, 'Role', 'Host', 'Private IP', 'Label', 'Status', 'Conn', 'Lag')
146
+ puts header
147
+ puts '-' * header.length
148
+ end
149
+
150
+ def print_table_row(node, cols)
151
+ fmt = "%-#{cols[:role]}s %-#{cols[:host]}s %-#{cols[:private_ip]}s " \
152
+ "%-#{cols[:label]}s %-#{cols[:status]}s %#{cols[:conn]}d %#{cols[:lag]}s"
153
+ puts format(fmt, node[:role], node[:host], node[:private_ip], node[:label],
154
+ node[:status], node[:connections], node[:lag])
155
+ end
156
+
157
+ def print_components
158
+ enabled = []
159
+ enabled << 'repmgr' if config.component_enabled?(:repmgr)
160
+ enabled << 'pgbouncer' if config.component_enabled?(:pgbouncer)
161
+ enabled << 'pgbackrest' if config.component_enabled?(:pgbackrest)
162
+ enabled << 'monitoring' if config.component_enabled?(:monitoring)
163
+ enabled << 'ssl' if config.component_enabled?(:ssl)
164
+
165
+ return if enabled.empty?
166
+
167
+ puts
168
+ puts "Components: #{enabled.join(', ')}"
169
+ end
170
+
171
+ def check_host_status(host, is_primary:)
172
+ running = check_postgres_running(host)
173
+
174
+ if running
175
+ puts ' Status: Running'
176
+
177
+ connections = get_connection_count(host)
178
+ puts " Connections: #{connections}"
179
+
180
+ unless is_primary
181
+ lag = get_replication_lag(host)
182
+ puts " Replication lag: #{lag}"
183
+ end
184
+ else
185
+ puts ' Status: Down'
186
+ end
187
+ end
188
+
189
+ def check_postgres_running(host)
190
+ ssh_executor.postgres_running?(host)
191
+ rescue StandardError
192
+ false
193
+ end
194
+
195
+ def check_replication_status(host)
196
+ result = ssh_executor.run_sql(host, 'SELECT pg_is_in_recovery();')
197
+ result.include?('t')
198
+ rescue StandardError
199
+ false
200
+ end
201
+
202
+ def get_connection_count(host)
203
+ result = ssh_executor.run_sql(host, 'SELECT count(*) FROM pg_stat_activity;')
204
+ result.match(/\d+/)[0].to_i
205
+ rescue StandardError
206
+ 0
207
+ end
208
+
209
+ def get_replication_lag(host)
210
+ # Get lag from primary's perspective (more accurate)
211
+ standby_ip = config.replication_host_for(host)
212
+ lag_bytes = get_lag_from_primary(standby_ip)
213
+
214
+ return format_lag(lag_bytes) if lag_bytes
215
+
216
+ # Fallback: query standby directly
217
+ result = ssh_executor.run_sql(host, 'SELECT pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn());')
218
+ lag = result.match(/-?\d+/)[0].to_i.abs
219
+ format_lag(lag)
220
+ rescue StandardError
221
+ 'unknown'
222
+ end
223
+
224
+ def get_lag_from_primary(standby_ip)
225
+ sql = 'SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint as lag ' \
226
+ "FROM pg_stat_replication WHERE client_addr = '#{standby_ip}';"
227
+ result = ssh_executor.run_sql(config.primary_host, sql)
228
+ result.match(/-?\d+/)[0].to_i.abs
229
+ rescue StandardError
230
+ nil
231
+ end
232
+
233
+ def format_lag(bytes)
234
+ return '0 (synced)' if bytes.zero?
235
+ return "#{bytes} B" if bytes < 1024
236
+
237
+ kb = bytes / 1024.0
238
+ return "#{kb.round(1)} KB" if kb < 1024
239
+
240
+ mb = kb / 1024.0
241
+ "#{mb.round(1)} MB"
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,114 @@
1
+ module ActivePostgres
2
+ class Installer
3
+ include ComponentResolver
4
+
5
+ attr_reader :config, :dry_run, :ssh_executor, :secrets, :logger, :rollback_manager, :skip_validation, :use_optimized
6
+
7
+ def initialize(config, dry_run: false, verbose: false, skip_validation: false, use_optimized: true,
8
+ skip_rollback: false)
9
+ @config = config
10
+ @dry_run = dry_run
11
+ @skip_validation = skip_validation
12
+ @use_optimized = use_optimized
13
+ @skip_rollback = skip_rollback || ENV['SKIP_ROLLBACK'] == 'true'
14
+ @ssh_executor = SSHExecutor.new(config)
15
+ @secrets = Secrets.new(config)
16
+ @logger = Logger.new(verbose: verbose || ENV['VERBOSE'] == 'true')
17
+ @rollback_manager = skip_rollback ? nil : RollbackManager.new(config, ssh_executor, logger: logger)
18
+ end
19
+
20
+ def setup
21
+ logger.warn 'Skipping pre-flight validation (--skip-validation flag)' if skip_validation
22
+
23
+ flow = ClusterDeploymentFlow.new(
24
+ config,
25
+ ssh_executor: ssh_executor,
26
+ secrets: secrets,
27
+ logger: logger,
28
+ rollback_manager: rollback_manager,
29
+ skip_validation: skip_validation
30
+ )
31
+ flow.execute
32
+ end
33
+
34
+ def setup_component(component_name)
35
+ logger.task("Setting up #{component_name}") do
36
+ component_class = component_class_for(component_name)
37
+ component = component_class.new(config, ssh_executor, secrets)
38
+
39
+ if dry_run
40
+ logger.info "[DRY RUN] Would setup #{component_name}"
41
+ else
42
+ # Register rollback for this component
43
+ rollback_manager.register("Uninstall #{component_name}", host: nil) do
44
+ component.uninstall
45
+ rescue StandardError => e
46
+ logger.warn "Failed to uninstall #{component_name}: #{e.message}"
47
+ end
48
+
49
+ component.install
50
+ end
51
+
52
+ logger.success "#{component_name} setup complete"
53
+ end
54
+ end
55
+
56
+ def uninstall_component(component_name)
57
+ puts "==> Uninstalling #{component_name}..."
58
+
59
+ component_class = component_class_for(component_name)
60
+ component = component_class.new(config, ssh_executor, secrets)
61
+ component.uninstall
62
+
63
+ puts "✓ #{component_name} uninstalled"
64
+ end
65
+
66
+ def restart_component(component_name)
67
+ puts "==> Restarting #{component_name}..."
68
+
69
+ component_class = component_class_for(component_name)
70
+ component = component_class.new(config, ssh_executor, secrets)
71
+ component.restart
72
+
73
+ puts "✓ #{component_name} restarted"
74
+ end
75
+
76
+ def run_backup(type)
77
+ puts "==> Running #{type} backup..."
78
+
79
+ component = Components::PgBackRest.new(config, ssh_executor, secrets)
80
+ component.run_backup(type)
81
+
82
+ puts '✓ Backup complete'
83
+ end
84
+
85
+ def run_restore(backup_id)
86
+ puts "==> Restoring from backup #{backup_id}..."
87
+
88
+ component = Components::PgBackRest.new(config, ssh_executor, secrets)
89
+ component.run_restore(backup_id)
90
+
91
+ puts '✓ Restore complete'
92
+ end
93
+
94
+ def list_backups
95
+ component = Components::PgBackRest.new(config, ssh_executor, secrets)
96
+ component.list_backups
97
+ end
98
+
99
+ def setup_standby_only(standby_host)
100
+ logger.warn 'Skipping pre-flight validation (--skip-validation flag)' if skip_validation
101
+
102
+ flow = StandbyDeploymentFlow.new(
103
+ config,
104
+ standby_host: standby_host,
105
+ ssh_executor: ssh_executor,
106
+ secrets: secrets,
107
+ logger: logger,
108
+ rollback_manager: rollback_manager,
109
+ skip_validation: skip_validation
110
+ )
111
+ flow.execute
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,67 @@
1
+ module ActivePostgres
2
+ # Sanitizes sensitive information from logs - CRITICAL for production security
3
+ module LogSanitizer
4
+ # Patterns for sensitive data that must NEVER appear in logs
5
+ SENSITIVE_PATTERNS = [
6
+ # Passwords in connection strings (matches until whitespace)
7
+ # Handles special chars like: password=abc}def~ghi!@#$%^&*()
8
+ /password[=:]\s*(\S+)/i,
9
+ /PGPASSWORD[=:]\s*(\S+)/i,
10
+ /passwd[=:]\s*(\S+)/i,
11
+
12
+ # Connection strings with passwords
13
+ %r{(postgresql://[^:]+:)([^@]+)(@)}i,
14
+ %r{(postgres://[^:]+:)([^@]+)(@)}i,
15
+
16
+ # SSH keys
17
+ /-----BEGIN [A-Z ]+ KEY-----[\s\S]+?-----END [A-Z ]+ KEY-----/,
18
+
19
+ # Tokens and secrets
20
+ /token[=:]\s*(\S+)/i,
21
+ /secret[=:]\s*(\S+)/i,
22
+ /api[_-]?key[=:]\s*(\S+)/i,
23
+
24
+ # AWS credentials
25
+ /aws[_-]?access[_-]?key[_-]?id[=:]\s*(\S+)/i,
26
+ /aws[_-]?secret[_-]?access[_-]?key[=:]\s*(\S+)/i
27
+ ].freeze
28
+
29
+ REDACTED_TEXT = '[REDACTED]'.freeze
30
+
31
+ def self.sanitize(text)
32
+ return text if text.nil? || text.empty?
33
+
34
+ sanitized = text.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
35
+
36
+ SENSITIVE_PATTERNS.each do |pattern|
37
+ sanitized.gsub!(pattern) do |match|
38
+ # Replace only the sensitive part, keep structure
39
+ if ::Regexp.last_match(1) # Captured group exists
40
+ match.gsub(::Regexp.last_match(1), REDACTED_TEXT)
41
+ else
42
+ REDACTED_TEXT
43
+ end
44
+ end
45
+ end
46
+
47
+ sanitized
48
+ end
49
+
50
+ def self.sanitize_hash(hash)
51
+ return hash unless hash.is_a?(Hash)
52
+
53
+ hash.transform_values do |value|
54
+ case value
55
+ when Hash
56
+ sanitize_hash(value)
57
+ when String
58
+ sanitize(value)
59
+ when Array
60
+ value.map { |v| v.is_a?(String) ? sanitize(v) : v }
61
+ else
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,125 @@
1
+ require 'logger'
2
+
3
+ module ActivePostgres
4
+ class Logger
5
+ LEVELS = {
6
+ debug: ::Logger::DEBUG,
7
+ info: ::Logger::INFO,
8
+ warn: ::Logger::WARN,
9
+ error: ::Logger::ERROR,
10
+ fatal: ::Logger::FATAL
11
+ }.freeze
12
+
13
+ attr_reader :logger, :verbose
14
+
15
+ def initialize(verbose: false, log_file: nil)
16
+ @verbose = verbose
17
+ @logger = setup_logger(log_file)
18
+ @step_number = 0
19
+ @current_task = nil
20
+ end
21
+
22
+ def setup_logger(log_file)
23
+ if log_file
24
+ file_logger = ::Logger.new(log_file, 'daily')
25
+ file_logger.level = ::Logger::DEBUG
26
+ file_logger
27
+ else
28
+ ::Logger.new($stdout).tap do |l|
29
+ l.level = verbose ? ::Logger::DEBUG : ::Logger::INFO
30
+ l.formatter = proc do |severity, _datetime, _progname, msg|
31
+ case severity
32
+ when 'DEBUG'
33
+ "#{msg}\n" if verbose
34
+ when 'INFO'
35
+ "#{msg}\n"
36
+ when 'WARN'
37
+ "⚠️ #{msg}\n"
38
+ when 'ERROR'
39
+ "❌ #{msg}\n"
40
+ when 'FATAL'
41
+ "💀 #{msg}\n"
42
+ else
43
+ "#{msg}\n"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ def task(description)
51
+ @step_number += 1
52
+ @current_task = description
53
+ logger.info "#{@step_number}. #{sanitize(description)}"
54
+
55
+ start_time = Time.now
56
+ result = yield
57
+ duration = Time.now - start_time
58
+
59
+ completed_message = "Completed in #{duration.round(2)}s"
60
+ logger.debug " #{sanitize(completed_message)}"
61
+ result
62
+ rescue StandardError => e
63
+ failure_message = "Failed: #{e.message}"
64
+ logger.error " #{sanitize(failure_message)}"
65
+ raise
66
+ ensure
67
+ @current_task = nil
68
+ end
69
+
70
+ def step(description)
71
+ logger.info " → #{sanitize(description)}"
72
+ yield if block_given?
73
+ end
74
+
75
+ def debug(message)
76
+ logger.debug " #{sanitize(message)}"
77
+ end
78
+
79
+ def info(message)
80
+ logger.info " #{sanitize(message)}"
81
+ end
82
+
83
+ def warn(message)
84
+ logger.warn " #{sanitize(message)}"
85
+ end
86
+
87
+ def error(message)
88
+ logger.error " #{sanitize(message)}"
89
+ end
90
+
91
+ def fatal(message)
92
+ logger.fatal " #{sanitize(message)}"
93
+ end
94
+
95
+ def success(message)
96
+ logger.info " ✅ #{sanitize(message)}"
97
+ end
98
+
99
+ def progress(message)
100
+ logger.info " ⏳ #{sanitize(message)}"
101
+ end
102
+
103
+ # Format diagnostic information nicely
104
+ def diagnostic(title, content)
105
+ logger.debug "\n 📋 #{sanitize(title)}:"
106
+ content.each_line do |line|
107
+ logger.debug " #{sanitize(line.chomp)}"
108
+ end
109
+ logger.debug ''
110
+ end
111
+
112
+ # Log a section header
113
+ def section(title)
114
+ logger.info sanitize("\n#{'=' * 60}")
115
+ logger.info sanitize(title.center(60))
116
+ logger.info sanitize("#{'=' * 60}\n")
117
+ end
118
+
119
+ private
120
+
121
+ def sanitize(message)
122
+ LogSanitizer.sanitize(message.to_s)
123
+ end
124
+ end
125
+ end