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.
- checksums.yaml +7 -0
- data/LICENSE +22 -0
- data/README.md +461 -0
- data/exe/boringservices +5 -0
- data/lib/boring_services/cli.rb +100 -0
- data/lib/boring_services/configuration.rb +80 -0
- data/lib/boring_services/generators/boring_services/install_generator.rb +25 -0
- data/lib/boring_services/health_checker.rb +47 -0
- data/lib/boring_services/installer.rb +142 -0
- data/lib/boring_services/railtie.rb +19 -0
- data/lib/boring_services/secrets.rb +55 -0
- data/lib/boring_services/services/base.rb +168 -0
- data/lib/boring_services/services/haproxy.rb +272 -0
- data/lib/boring_services/services/memcached.rb +113 -0
- data/lib/boring_services/services/nginx.rb +96 -0
- data/lib/boring_services/services/redis.rb +55 -0
- data/lib/boring_services/ssh_executor.rb +206 -0
- data/lib/boring_services/version.rb +3 -0
- data/lib/boring_services.rb +32 -0
- data/lib/tasks/services.rake +47 -0
- metadata +164 -0
|
@@ -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
|