boring_services 0.2.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.
@@ -0,0 +1,47 @@
1
+ module BoringServices
2
+ class HealthChecker
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 check_all
11
+ results = {}
12
+ config.enabled_services.each do |service|
13
+ results[service['name']] = check_service(service)
14
+ end
15
+ results
16
+ end
17
+
18
+ def check_service(service)
19
+ service_name = service['name']
20
+ host = service['host']
21
+ return { status: 'no_host' } unless host
22
+
23
+ host_result = check_service_on_host(service_name, host)
24
+
25
+ {
26
+ status: host_result[:running] ? 'healthy' : 'unhealthy',
27
+ host: host_result
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def check_service_on_host(service_name, host)
34
+ result = { running: false, message: '' }
35
+
36
+ ssh_executor.execute_on_host(host) do
37
+ status_output = ssh_executor.systemd_status(service_name)
38
+ result[:running] = status_output.include?('active (running)')
39
+ result[:message] = status_output
40
+ end
41
+
42
+ result
43
+ rescue StandardError => e
44
+ { running: false, message: e.message }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,142 @@
1
+ module BoringServices
2
+ class Installer
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 install_all
11
+ puts 'Installing enabled services...'
12
+ credentials_hints = []
13
+ config.enabled_services.each do |service|
14
+ result = install_service_entry(service)
15
+ credentials_hints << result if result.is_a?(Hash)
16
+ end
17
+ puts 'All services installed successfully!'
18
+ print_credentials_summary(credentials_hints) if credentials_hints.any?
19
+ end
20
+
21
+ def install_service(service_name)
22
+ service = config.service_config(service_name)
23
+ raise Error, "Service #{service_name} not found in configuration" unless service
24
+ raise Error, "Service #{service_name} is disabled" if service['enabled'] == false
25
+
26
+ install_service_entry(service)
27
+ end
28
+
29
+ def install_service_entry(service)
30
+ service_name = service['name']
31
+ service_label = service['label'] || service['host']
32
+ raise Error, "Service #{service_name} is disabled" if service['enabled'] == false
33
+
34
+ puts "\nInstalling #{service_name}..."
35
+ service_class = get_service_class(service_name)
36
+ service_instance = service_class.new(config, ssh_executor, service)
37
+ result = service_instance.install
38
+ puts "✓ #{service_name} installed (#{service_label})"
39
+ result
40
+ end
41
+
42
+ def uninstall_service(service_name)
43
+ service = config.service_config(service_name)
44
+ raise Error, "Service #{service_name} not found in configuration" unless service
45
+
46
+ puts "\nUninstalling #{service_name}..."
47
+ service_class = get_service_class(service_name)
48
+ service_instance = service_class.new(config, ssh_executor, service)
49
+ service_instance.uninstall
50
+ puts "✓ #{service_name} uninstalled"
51
+ end
52
+
53
+ def restart_service(service_name)
54
+ service = config.service_config(service_name)
55
+ raise Error, "Service #{service_name} not found in configuration" unless service
56
+
57
+ puts "\nRestarting #{service_name}..."
58
+ service_class = get_service_class(service_name)
59
+ service_instance = service_class.new(config, ssh_executor, service)
60
+ service_instance.restart
61
+ puts "✓ #{service_name} restarted"
62
+ end
63
+
64
+ def reconfigure_service(service_name)
65
+ service = config.service_config(service_name)
66
+ raise Error, "Service #{service_name} not found in configuration" unless service
67
+ raise Error, "Service #{service_name} is disabled" if service['enabled'] == false
68
+
69
+ puts "\nReconfiguring #{service_name}..."
70
+ service_class = get_service_class(service_name)
71
+ service_instance = service_class.new(config, ssh_executor, service)
72
+ service_instance.reconfigure
73
+ puts "✓ #{service_name} reconfigured"
74
+ end
75
+
76
+ def reconfigure_all
77
+ puts 'Reconfiguring enabled services...'
78
+ credentials_hints = []
79
+ config.enabled_services.each do |service|
80
+ result = reconfigure_service_entry(service)
81
+ credentials_hints << result if result.is_a?(Hash)
82
+ end
83
+ puts 'All services reconfigured successfully!'
84
+ print_credentials_summary(credentials_hints) if credentials_hints.any?
85
+ end
86
+
87
+ def reconfigure_service_entry(service)
88
+ service_name = service['name']
89
+ service_label = service['label'] || service['host']
90
+ raise Error, "Service #{service_name} is disabled" if service['enabled'] == false
91
+
92
+ puts "\nReconfiguring #{service_name}..."
93
+ service_class = get_service_class(service_name)
94
+ service_instance = service_class.new(config, ssh_executor, service)
95
+ result = service_instance.reconfigure
96
+ puts "✓ #{service_name} reconfigured (#{service_label})"
97
+ result
98
+ end
99
+
100
+ private
101
+
102
+ def get_service_class(service_name)
103
+ case service_name.to_s.downcase
104
+ when 'memcached'
105
+ Services::Memcached
106
+ when 'redis'
107
+ Services::Redis
108
+ when 'haproxy'
109
+ Services::HAProxy
110
+ when 'nginx'
111
+ Services::Nginx
112
+ else
113
+ raise Error, "Unknown service: #{service_name}"
114
+ end
115
+ end
116
+
117
+ def print_credentials_summary(hints)
118
+ puts "\n" + "=" * 50
119
+ puts "📋 Add to Rails credentials:"
120
+ puts "=" * 50
121
+
122
+ # Group by service type
123
+ grouped = hints.group_by { |h| h[:type] }
124
+
125
+ grouped.each do |type, entries|
126
+ puts "\n#{type}:"
127
+
128
+ # Group by region within each type
129
+ by_region = entries.group_by { |e| e[:region] }
130
+ by_region.each do |region, region_entries|
131
+ puts " #{region}:"
132
+ puts " servers:"
133
+ region_entries.each do |entry|
134
+ puts " - #{entry[:server]} # #{entry[:label]}"
135
+ end
136
+ end
137
+ end
138
+
139
+ puts "\n" + "=" * 50
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,19 @@
1
+ begin
2
+ require 'rails/railtie'
3
+
4
+ module BoringServices
5
+ class Railtie < Rails::Railtie
6
+ railtie_name :boring_services
7
+
8
+ rake_tasks do
9
+ load File.expand_path('../tasks/services.rake', __dir__)
10
+ end
11
+
12
+ generators do
13
+ require_relative 'generators/boring_services/install_generator'
14
+ end
15
+ end
16
+ end
17
+ rescue LoadError
18
+ # Rails not available - gem works standalone
19
+ end
@@ -0,0 +1,55 @@
1
+ require 'English'
2
+ module BoringServices
3
+ class Secrets
4
+ def self.resolve(value)
5
+ return nil if value.nil? || value.to_s.strip.empty?
6
+
7
+ value_str = value.to_s.strip
8
+
9
+ if value_str.start_with?('credentials:')
10
+ resolve_rails_credentials(value_str)
11
+ elsif value_str.start_with?('$')
12
+ resolve_env_var(value_str)
13
+ elsif value_str.start_with?('$(') && value_str.end_with?(')')
14
+ resolve_command(value_str[2..-2])
15
+ else
16
+ value_str
17
+ end
18
+ end
19
+
20
+ def self.resolve_rails_credentials(value)
21
+ # Extract the key path from "credentials:ssl.certificate" -> "ssl.certificate"
22
+ key_path = value.sub(/^credentials:/, '')
23
+ keys = key_path.split('.')
24
+
25
+ # Use rails credentials:show to fetch the value
26
+ # This requires being run from the Rails root directory
27
+ command = "RAILS_ENV=production bundle exec rails runner \"puts Rails.application.credentials.dig(#{keys.map { |k| ":#{k}" }.join(', ')})\""
28
+ result = `#{command}`.strip
29
+
30
+ unless $CHILD_STATUS.success?
31
+ raise Error, "Failed to resolve Rails credentials: #{key_path}. Make sure you're running from the Rails root directory."
32
+ end
33
+
34
+ if result.empty?
35
+ raise Error, "Rails credentials key not found: #{key_path}"
36
+ end
37
+
38
+ result
39
+ end
40
+
41
+ def self.resolve_env_var(value)
42
+ var_name = value[1..]
43
+ ENV.fetch(var_name) do
44
+ raise Error, "Environment variable #{var_name} not set"
45
+ end
46
+ end
47
+
48
+ def self.resolve_command(command)
49
+ result = `#{command}`.strip
50
+ raise Error, "Command failed: #{command}" unless $CHILD_STATUS.success?
51
+
52
+ result
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,168 @@
1
+ module BoringServices
2
+ module Services
3
+ class Base
4
+ attr_reader :config, :ssh_executor, :service_config
5
+
6
+ def initialize(config, ssh_executor, service_config)
7
+ @config = config
8
+ @ssh_executor = ssh_executor
9
+ @service_config = service_config
10
+ end
11
+
12
+ def install
13
+ raise NotImplementedError, 'Subclasses must implement #install'
14
+ end
15
+
16
+ def uninstall
17
+ raise NotImplementedError, 'Subclasses must implement #uninstall'
18
+ end
19
+
20
+ def restart
21
+ raise NotImplementedError, 'Subclasses must implement #restart'
22
+ end
23
+
24
+ def reconfigure
25
+ raise NotImplementedError, 'Subclasses must implement #reconfigure'
26
+ end
27
+
28
+ protected
29
+
30
+ def service_name
31
+ service_config['name']
32
+ end
33
+
34
+ def host
35
+ host_context_value('host')
36
+ end
37
+
38
+ def label
39
+ host_context_value('label')
40
+ end
41
+
42
+ def port
43
+ host_context_value('port')
44
+ end
45
+
46
+ def memory_mb
47
+ host_context_value('memory_mb')
48
+ end
49
+
50
+ def private_ip
51
+ host_context_value('private_ip')
52
+ end
53
+
54
+ def resolve_secret(key)
55
+ Secrets.resolve(config.secrets[key])
56
+ end
57
+
58
+ def template_path(filename)
59
+ File.join(BoringServices.root, 'templates', filename)
60
+ end
61
+
62
+ def execute_on_host(&)
63
+ raise ArgumentError, 'block required' unless block_given?
64
+
65
+ service_instance = self
66
+ ssh_executor.execute_on_host_for_service(service_config) do |host_entry|
67
+ backend = SSHKit::Backend.current || self
68
+ service_instance.send(:with_host_context, host_entry) do
69
+ service_instance.send(:with_backend, backend) do
70
+ service_instance.instance_exec(&)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def execute(*, &)
77
+ ensure_backend!
78
+ current_backend.execute(*, &)
79
+ end
80
+
81
+ def capture(*, &)
82
+ ensure_backend!
83
+ current_backend.capture(*, &)
84
+ end
85
+
86
+ def upload!(*)
87
+ ensure_backend!
88
+ current_backend.upload!(*)
89
+ end
90
+
91
+ def download!(*)
92
+ ensure_backend!
93
+ current_backend.download!(*)
94
+ end
95
+
96
+ def test(*, &)
97
+ ensure_backend!
98
+ current_backend.test(*, &)
99
+ end
100
+
101
+ def within(*, &)
102
+ ensure_backend!
103
+ current_backend.within(*, &)
104
+ end
105
+
106
+ def with(*, &)
107
+ ensure_backend!
108
+ current_backend.with(*, &)
109
+ end
110
+
111
+ def as(*, &)
112
+ ensure_backend!
113
+ current_backend.as(*, &)
114
+ end
115
+
116
+ private
117
+
118
+ def host_context_value(key)
119
+ context = current_host_context
120
+ return context[key] if context.key?(key)
121
+ return context[key.to_sym] if context.key?(key.to_sym)
122
+
123
+ service_config[key] || service_config[key.to_sym]
124
+ end
125
+
126
+ def with_backend(backend)
127
+ backend_stack.push(backend)
128
+ yield
129
+ ensure
130
+ backend_stack.pop
131
+ end
132
+
133
+ def with_host_context(host_entry)
134
+ previous = @current_host_context
135
+ @current_host_context = normalize_host_entry(host_entry)
136
+ yield
137
+ ensure
138
+ @current_host_context = previous
139
+ end
140
+
141
+ def current_host_context
142
+ @current_host_context ||= {}
143
+ end
144
+
145
+ def normalize_host_entry(host_entry)
146
+ return {} unless host_entry
147
+
148
+ if host_entry.is_a?(Hash)
149
+ host_entry.transform_keys(&:to_s)
150
+ else
151
+ { 'host' => host_entry.to_s }
152
+ end
153
+ end
154
+
155
+ def current_backend
156
+ backend_stack.last || SSHKit::Backend.current
157
+ end
158
+
159
+ def ensure_backend!
160
+ raise 'SSHKit backend is not available. Use execute_on_host to run remote commands.' unless current_backend
161
+ end
162
+
163
+ def backend_stack
164
+ Thread.current[:boring_services_backend_stack] ||= []
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,272 @@
1
+ module BoringServices
2
+ module Services
3
+ class HAProxy < Base
4
+ def install
5
+ execute_on_host do
6
+ puts " Installing HAProxy on #{label || host}..."
7
+ ssh_executor.install_package('haproxy')
8
+ setup_ssl_certificates if ssl_enabled?
9
+ configure_haproxy
10
+ validate_config
11
+ ssh_executor.systemd_enable('haproxy')
12
+ ssh_executor.systemd_restart('haproxy') # Use restart instead of start to reload config
13
+ verify_listening_ports
14
+ end
15
+ end
16
+
17
+ def uninstall
18
+ execute_on_host do
19
+ puts " Uninstalling HAProxy from #{label || host}..."
20
+ ssh_executor.systemd_stop('haproxy')
21
+ ssh_executor.systemd_disable('haproxy')
22
+ ssh_executor.uninstall_package('haproxy')
23
+ end
24
+ end
25
+
26
+ def restart
27
+ execute_on_host do
28
+ puts " Restarting HAProxy on #{label || host}..."
29
+ ssh_executor.systemd_restart('haproxy')
30
+ end
31
+ end
32
+
33
+ def reconfigure
34
+ execute_on_host do
35
+ puts " Reconfiguring HAProxy on #{label || host}..."
36
+ setup_ssl_certificates if ssl_enabled?
37
+ configure_haproxy
38
+ validate_config
39
+ ssh_executor.systemd_restart('haproxy')
40
+ verify_listening_ports
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def ssl_enabled?
47
+ service_config['ssl'] == true || service_config['ssl_cert']
48
+ end
49
+
50
+ def ssl_cert_path
51
+ '/etc/haproxy/ssl/certificate.pem'
52
+ end
53
+
54
+ def setup_ssl_certificates
55
+ puts ' Setting up SSL certificates...'
56
+
57
+ execute :sudo, :mkdir, '-p', '/etc/haproxy/ssl'
58
+ execute :sudo, :chmod, '750', '/etc/haproxy/ssl'
59
+
60
+ # Support multiple ways to provide certificates:
61
+ # 1. Direct file paths: ssl_cert_path: /path/to/cert.pem
62
+ # 2. Credentials reference: ssl_cert: "credentials:ssl.certificate"
63
+ # 3. Inline content: ssl_cert: "-----BEGIN CERTIFICATE-----..."
64
+ ssl_cert = resolve_ssl_content('ssl_cert')
65
+ ssl_key = resolve_ssl_content('ssl_key')
66
+
67
+ if ssl_cert && ssl_key
68
+ combined_pem = "#{ssl_cert}\n#{ssl_key}"
69
+ upload! StringIO.new(combined_pem), '/tmp/certificate.pem'
70
+ execute :sudo, :mv, '/tmp/certificate.pem', ssl_cert_path
71
+ execute :sudo, :chmod, '600', ssl_cert_path
72
+ execute :sudo, :chown, 'haproxy:haproxy', ssl_cert_path
73
+ puts ' ✓ SSL certificates installed'
74
+ else
75
+ puts ' ⚠ SSL enabled but no certificates provided, using self-signed'
76
+ generate_self_signed_cert
77
+ end
78
+ end
79
+
80
+ def resolve_ssl_content(key)
81
+ value = service_config[key]
82
+ return nil unless value
83
+
84
+ # Check if it's a file path
85
+ if value.start_with?('/') && File.exist?(value)
86
+ File.read(value)
87
+ # Check if it's a credentials reference
88
+ elsif value.start_with?('credentials:')
89
+ resolve_secret(value)
90
+ # Otherwise treat as inline content
91
+ else
92
+ value
93
+ end
94
+ end
95
+
96
+ def generate_self_signed_cert
97
+ execute :sudo, :openssl, :req, '-x509', '-newkey', 'rsa:4096',
98
+ '-keyout', '/tmp/key.pem',
99
+ '-out', '/tmp/cert.pem',
100
+ '-days', '825',
101
+ '-nodes',
102
+ '-subj', "\"/CN=#{host}\""
103
+ execute :sudo, :cat, '/tmp/cert.pem', '/tmp/key.pem', '>', '/tmp/certificate.pem'
104
+ execute :sudo, :mv, '/tmp/certificate.pem', ssl_cert_path
105
+ execute :sudo, :chmod, '600', ssl_cert_path
106
+ execute :sudo, :chown, 'haproxy:haproxy', ssl_cert_path
107
+ execute :sudo, :rm, '-f', '/tmp/cert.pem', '/tmp/key.pem'
108
+ end
109
+
110
+ def resolve_secret(value)
111
+ return nil unless value
112
+
113
+ BoringServices::Secrets.resolve(value)
114
+ end
115
+
116
+ def validate_config
117
+ puts ' Validating HAProxy configuration...'
118
+ success = execute :sudo, :haproxy, '-c', '-f', '/etc/haproxy/haproxy.cfg', raise_on_error: false
119
+ if success
120
+ puts ' ✓ Configuration valid'
121
+ else
122
+ puts ' ✗ Configuration validation failed!'
123
+ raise 'HAProxy configuration validation failed'
124
+ end
125
+ end
126
+
127
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
128
+ def configure_haproxy
129
+ # Check if custom config template is provided
130
+ if service_config['custom_config_template'] && File.exist?(service_config['custom_config_template'])
131
+ puts " Using custom HAProxy config template: #{service_config['custom_config_template']}"
132
+ config_content = File.read(service_config['custom_config_template'])
133
+ upload! StringIO.new(config_content), '/tmp/haproxy.cfg'
134
+ execute :sudo, :mv, '/tmp/haproxy.cfg', '/etc/haproxy/haproxy.cfg'
135
+ execute :sudo, :chown, 'root:root', '/etc/haproxy/haproxy.cfg'
136
+ execute :sudo, :chmod, '644', '/etc/haproxy/haproxy.cfg'
137
+ return
138
+ end
139
+
140
+ backends = service_config['backends'] || []
141
+ frontend_port = port || 80
142
+ https_port = service_config['https_port'] || 443
143
+ stats_port = service_config['stats_port'] || 8404
144
+
145
+ # Get custom overrides or use defaults
146
+ custom = service_config['custom_params'] || {}
147
+ timeout_connect = custom['timeout_connect'] || 5000
148
+ timeout_client = custom['timeout_client'] || 50_000
149
+ timeout_server = custom['timeout_server'] || 50_000
150
+ balance_algorithm = custom['balance'] || 'roundrobin'
151
+ ssl_ciphers = custom['ssl_ciphers'] || 'ECDHE+AESGCM:ECDHE+AES256:!aNULL:!MD5:!DSS'
152
+ ssl_options = custom['ssl_options'] || 'no-sslv3 no-tlsv10 no-tlsv11'
153
+
154
+ config_content = <<~HAPROXY
155
+ global
156
+ log /dev/log local0
157
+ log /dev/log local1 notice
158
+ chroot /var/lib/haproxy
159
+ stats socket /run/haproxy/admin.sock mode 660 level admin
160
+ stats timeout 30s
161
+ user haproxy
162
+ group haproxy
163
+ daemon
164
+ #{"ssl-default-bind-ciphers #{ssl_ciphers}" if ssl_enabled?}
165
+ #{"ssl-default-bind-options #{ssl_options}" if ssl_enabled?}
166
+
167
+ defaults
168
+ log global
169
+ mode http
170
+ option httplog
171
+ option dontlognull
172
+ timeout connect #{timeout_connect}
173
+ timeout client #{timeout_client}
174
+ timeout server #{timeout_server}
175
+ errorfile 400 /etc/haproxy/errors/400.http
176
+ errorfile 403 /etc/haproxy/errors/403.http
177
+ errorfile 408 /etc/haproxy/errors/408.http
178
+ errorfile 500 /etc/haproxy/errors/500.http
179
+ errorfile 502 /etc/haproxy/errors/502.http
180
+ errorfile 503 /etc/haproxy/errors/503.http
181
+ errorfile 504 /etc/haproxy/errors/504.http
182
+ HAPROXY
183
+
184
+ config_content += if ssl_enabled?
185
+ <<~HAPROXY
186
+
187
+ frontend https_front
188
+ bind *:#{https_port} ssl crt #{ssl_cert_path}
189
+ http-request set-header X-Forwarded-Proto https
190
+ default_backend web_servers
191
+
192
+ frontend http_front
193
+ bind *:#{frontend_port}
194
+ http-request set-header X-Forwarded-Proto http
195
+ default_backend web_servers
196
+ HAPROXY
197
+ else
198
+ <<~HAPROXY
199
+
200
+ frontend http_front
201
+ bind *:#{frontend_port}
202
+ http-request set-header X-Forwarded-Proto http
203
+ default_backend web_servers
204
+ HAPROXY
205
+ end
206
+
207
+ # Get health check path from service config, custom params, or use default
208
+ health_check_path = service_config['health_check_path'] || custom['health_check_path'] || '/health'
209
+ health_check_domain = service_config['health_check_domain'] || custom['health_check_domain'] || host
210
+
211
+ config_content += <<~HAPROXY
212
+
213
+ backend web_servers
214
+ balance #{balance_algorithm}
215
+ option forwardfor
216
+ http-request set-header X-Forwarded-Host %[req.hdr(Host)]
217
+ # Health check with proper headers
218
+ option httpchk
219
+ http-check send meth GET uri #{health_check_path} hdr Host #{health_check_domain}
220
+ HAPROXY
221
+
222
+ backends.each_with_index do |backend, idx|
223
+ backend_host = backend.is_a?(Hash) ? backend['host'] : backend
224
+ backend_port = backend.is_a?(Hash) ? (backend['port'] || 3000) : 3000
225
+ config_content += " server web#{idx + 1} #{backend_host}:#{backend_port} check\n"
226
+ end
227
+
228
+ config_content += <<~HAPROXY
229
+
230
+ frontend stats
231
+ bind *:#{stats_port}
232
+ stats enable
233
+ stats uri /
234
+ stats refresh 10s
235
+ HAPROXY
236
+
237
+ upload! StringIO.new(config_content), '/tmp/haproxy.cfg'
238
+ execute :sudo, :mv, '/tmp/haproxy.cfg', '/etc/haproxy/haproxy.cfg'
239
+ execute :sudo, :chown, 'root:root', '/etc/haproxy/haproxy.cfg'
240
+ execute :sudo, :chmod, '644', '/etc/haproxy/haproxy.cfg'
241
+ end
242
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
243
+
244
+ def verify_listening_ports
245
+ puts ' Verifying HAProxy is listening on ports...'
246
+
247
+ # Wait a moment for HAProxy to fully start
248
+ sleep 2
249
+
250
+ # Check if HAProxy is listening on expected ports
251
+ frontend_port = port || 80
252
+ https_port = service_config['https_port'] || 443
253
+ stats_port = service_config['stats_port'] || 8404
254
+
255
+ ports_to_check = [frontend_port]
256
+ ports_to_check << https_port if ssl_enabled?
257
+ ports_to_check << stats_port
258
+
259
+ ports_to_check.each do |check_port|
260
+ result = execute :sudo, :ss, '-tlnp', '|', :grep, "-E", "':#{check_port} '", raise_on_error: false
261
+ if result
262
+ puts " ✓ Port #{check_port} is listening"
263
+ else
264
+ puts " ✗ Warning: Port #{check_port} is not listening"
265
+ end
266
+ end
267
+
268
+ puts ' ✓ HAProxy port verification complete'
269
+ end
270
+ end
271
+ end
272
+ end