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,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
|