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,113 @@
1
+ module BoringServices
2
+ module Services
3
+ class Memcached < Base
4
+ def install
5
+ execute_on_host do
6
+ puts " Installing Memcached on #{label || host}..."
7
+ ssh_executor.install_package('memcached')
8
+ configure_memcached
9
+ ssh_executor.systemd_enable('memcached')
10
+ ssh_executor.systemd_start('memcached')
11
+ end
12
+ rails_credentials_entry
13
+ end
14
+
15
+ def uninstall
16
+ execute_on_host do
17
+ puts " Uninstalling Memcached from #{label || host}..."
18
+ ssh_executor.systemd_stop('memcached')
19
+ ssh_executor.systemd_disable('memcached')
20
+ ssh_executor.uninstall_package('memcached')
21
+ end
22
+ end
23
+
24
+ def restart
25
+ execute_on_host do
26
+ puts " Restarting Memcached on #{label || host}..."
27
+ ssh_executor.systemd_restart('memcached')
28
+ end
29
+ end
30
+
31
+ def reconfigure
32
+ execute_on_host do
33
+ puts " Reconfiguring Memcached on #{label || host}..."
34
+ configure_memcached
35
+ ssh_executor.systemd_restart('memcached')
36
+ end
37
+ rails_credentials_entry
38
+ end
39
+
40
+ private
41
+
42
+ def configure_memcached
43
+ # Check if custom config file is provided
44
+ if service_config['custom_config_template'] && File.exist?(service_config['custom_config_template'])
45
+ puts " Using custom Memcached config template: #{service_config['custom_config_template']}"
46
+ config_content = File.read(service_config['custom_config_template'])
47
+ upload! StringIO.new(config_content), '/tmp/memcached.conf'
48
+ execute :sudo, :mv, '/tmp/memcached.conf', '/etc/memcached.conf'
49
+ execute :sudo, :chown, 'root:root', '/etc/memcached.conf'
50
+ execute :sudo, :chmod, '644', '/etc/memcached.conf'
51
+ return
52
+ end
53
+
54
+ memory = memory_mb || 64
55
+ listen_port = port || 11_211
56
+
57
+ # Get custom overrides or use defaults
58
+ custom = service_config['custom_params'] || {}
59
+ # Use private_ip (WireGuard) if available, otherwise custom listen_address or localhost
60
+ listen_address = private_ip || custom['listen_address'] || '127.0.0.1'
61
+ max_connections = custom['max_connections'] || 1024
62
+ threads = custom['threads'] || 4
63
+ max_item_size = custom['max_item_size'] # Optional, defaults to 1MB
64
+ verbosity = custom['verbosity'] # Optional, no default
65
+ run_as_user = custom['user'] || 'memcache'
66
+ disable_udp = custom.fetch('disable_udp', true)
67
+ modern_mode = custom.fetch('modern', true)
68
+ slab_growth_factor = custom['slab_growth_factor'] # Optional, default 1.25
69
+
70
+ config_content = <<~CONFIG
71
+ # Production-ready Memcached configuration
72
+ # Run as non-root user
73
+ -u #{run_as_user}
74
+ # Network settings
75
+ -l #{listen_address}
76
+ -p #{listen_port}
77
+ # Memory and performance
78
+ -m #{memory}
79
+ -c #{max_connections}
80
+ -t #{threads}
81
+ CONFIG
82
+
83
+ # Security: Disable UDP to prevent amplification attacks
84
+ config_content += "-U 0\n" if disable_udp
85
+ # Modern mode: enables slab rebalancing, LRU crawler, etc.
86
+ config_content += "-o modern\n" if modern_mode
87
+ # Custom slab growth factor (default 1.25, lower = less memory waste)
88
+ config_content += "-f #{slab_growth_factor}\n" if slab_growth_factor
89
+ # Max item size (default 1MB)
90
+ config_content += "-I #{max_item_size}\n" if max_item_size
91
+ # Verbosity for debugging
92
+ config_content += "#{'-v' * verbosity.to_i}\n" if verbosity&.to_i&.positive?
93
+
94
+ upload! StringIO.new(config_content), '/tmp/memcached.conf'
95
+ execute :sudo, :mv, '/tmp/memcached.conf', '/etc/memcached.conf'
96
+ execute :sudo, :chown, 'root:root', '/etc/memcached.conf'
97
+ execute :sudo, :chmod, '644', '/etc/memcached.conf'
98
+ end
99
+
100
+ def rails_credentials_entry
101
+ listen_port = port || 11_211
102
+ server_address = private_ip || host
103
+ region_label = label&.include?('us') ? 'us' : 'eu'
104
+ {
105
+ type: 'memcached',
106
+ region: region_label,
107
+ server: "#{server_address}:#{listen_port}",
108
+ label: label || host
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,96 @@
1
+ module BoringServices
2
+ module Services
3
+ class Nginx < Base
4
+ def install
5
+ execute_on_host do
6
+ puts " Installing Nginx on #{label || host}..."
7
+ ssh_executor.install_package('nginx')
8
+ configure_nginx
9
+ ssh_executor.systemd_enable('nginx')
10
+ ssh_executor.systemd_start('nginx')
11
+ end
12
+ end
13
+
14
+ def uninstall
15
+ execute_on_host do
16
+ puts " Uninstalling Nginx from #{label || host}..."
17
+ ssh_executor.systemd_stop('nginx')
18
+ ssh_executor.systemd_disable('nginx')
19
+ ssh_executor.uninstall_package('nginx')
20
+ end
21
+ end
22
+
23
+ def restart
24
+ execute_on_host do
25
+ puts " Restarting Nginx on #{label || host}..."
26
+ ssh_executor.systemd_restart('nginx')
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def configure_nginx
33
+ backends = service_config['backends'] || []
34
+ listen_port = port || 80
35
+ ssl_enabled = service_config['ssl'] == true
36
+
37
+ upstream_servers = backends.map do |backend|
38
+ backend_host = backend.is_a?(Hash) ? backend['host'] : backend
39
+ backend_port = backend.is_a?(Hash) ? (backend['port'] || 3000) : 3000
40
+ " server #{backend_host}:#{backend_port};"
41
+ end.join("\n")
42
+
43
+ config_content = <<~NGINX
44
+ upstream backend {
45
+ #{upstream_servers}
46
+ }
47
+
48
+ server {
49
+ listen #{listen_port};
50
+ server_name _;
51
+
52
+ location / {
53
+ proxy_pass http://backend;
54
+ proxy_set_header Host $host;
55
+ proxy_set_header X-Real-IP $remote_addr;
56
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
57
+ proxy_set_header X-Forwarded-Proto $scheme;
58
+ }
59
+
60
+ location /health {
61
+ access_log off;
62
+ return 200 "healthy\\n";
63
+ add_header Content-Type text/plain;
64
+ }
65
+ }
66
+ NGINX
67
+
68
+ if ssl_enabled
69
+ config_content += <<~NGINX
70
+
71
+ server {
72
+ listen 443 ssl http2;
73
+ server_name _;
74
+
75
+ ssl_certificate /etc/nginx/ssl/cert.pem;
76
+ ssl_certificate_key /etc/nginx/ssl/key.pem;
77
+
78
+ location / {
79
+ proxy_pass http://backend;
80
+ proxy_set_header Host $host;
81
+ proxy_set_header X-Real-IP $remote_addr;
82
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
83
+ proxy_set_header X-Forwarded-Proto $scheme;
84
+ }
85
+ }
86
+ NGINX
87
+ end
88
+
89
+ upload! StringIO.new(config_content), '/tmp/default'
90
+ execute :sudo, :mv, '/tmp/default', '/etc/nginx/sites-available/default'
91
+ execute :sudo, :chown, 'root:root', '/etc/nginx/sites-available/default'
92
+ execute :sudo, :chmod, '644', '/etc/nginx/sites-available/default'
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,55 @@
1
+ module BoringServices
2
+ module Services
3
+ class Redis < Base
4
+ def install
5
+ execute_on_host do
6
+ puts " Installing Redis on #{label || host}..."
7
+ ssh_executor.install_package('redis-server')
8
+ configure_redis
9
+ ssh_executor.systemd_enable('redis-server')
10
+ ssh_executor.systemd_start('redis-server')
11
+ end
12
+ end
13
+
14
+ def uninstall
15
+ execute_on_host do
16
+ puts " Uninstalling Redis from #{label || host}..."
17
+ ssh_executor.systemd_stop('redis-server')
18
+ ssh_executor.systemd_disable('redis-server')
19
+ ssh_executor.uninstall_package('redis-server')
20
+ end
21
+ end
22
+
23
+ def restart
24
+ execute_on_host do
25
+ puts " Restarting Redis on #{label || host}..."
26
+ ssh_executor.systemd_restart('redis-server')
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def configure_redis
33
+ memory = memory_mb || 256
34
+ listen_port = port || 6379
35
+ password = resolve_secret('redis_password') if config.secrets['redis_password']
36
+
37
+ config_content = <<~REDIS
38
+ bind 0.0.0.0
39
+ port #{listen_port}
40
+ maxmemory #{memory}mb
41
+ maxmemory-policy allkeys-lru
42
+ appendonly yes
43
+ appendfsync everysec
44
+ REDIS
45
+
46
+ config_content += "requirepass #{password}\n" if password
47
+
48
+ upload! StringIO.new(config_content), '/tmp/redis.conf'
49
+ execute :sudo, :mv, '/tmp/redis.conf', '/etc/redis/redis.conf'
50
+ execute :sudo, :chown, 'redis:redis', '/etc/redis/redis.conf'
51
+ execute :sudo, :chmod, '640', '/etc/redis/redis.conf'
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,206 @@
1
+ require 'sshkit'
2
+ require 'sshkit/dsl'
3
+
4
+ module BoringServices
5
+ class SSHExecutor
6
+ include SSHKit::DSL
7
+
8
+ attr_reader :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ setup_sshkit
13
+ end
14
+
15
+ def execute_on_host(host, &)
16
+ on(formatted_host(host), &)
17
+ end
18
+
19
+ def execute_on_host_for_service(service, &block)
20
+ hosts = Array(service['hosts'] || service['host']).compact
21
+ raise Error, "No hosts defined for service #{service['name'] || 'unknown'}" if hosts.empty?
22
+
23
+ hosts.each do |host|
24
+ on formatted_host(host) do
25
+ block.call(host)
26
+ end
27
+ end
28
+ end
29
+
30
+ def install_package(package, host = nil)
31
+ if host
32
+ execute_on_host(host) do
33
+ wait_for_dpkg_lock
34
+ execute :sudo, 'apt-get', 'update'
35
+ execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', package
36
+ end
37
+ else
38
+ # Called from within SSHKit context
39
+ wait_for_dpkg_lock
40
+ backend.execute :sudo, 'apt-get', 'update'
41
+ backend.execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', package
42
+ end
43
+ end
44
+
45
+ def wait_for_dpkg_lock
46
+ # Wait for dpkg/apt locks to be released (e.g., unattended-upgrades)
47
+ max_wait = 300 # 5 minutes max
48
+ wait_interval = 10 # Check every 10 seconds
49
+ elapsed = 0
50
+
51
+ loop do
52
+ # Check if dpkg lock exists and is held by another process
53
+ lock_held = backend.test '[ -f /var/lib/dpkg/lock-frontend ]'
54
+
55
+ if lock_held
56
+ # Check if lock is actually held by checking fuser
57
+ processes = backend.capture(:sudo, :fuser, '/var/lib/dpkg/lock-frontend', '2>/dev/null', raise_on_non_zero_exit: false).strip
58
+
59
+ if processes.empty?
60
+ # Lock file exists but no process holding it - safe to proceed
61
+ break
62
+ end
63
+
64
+ if elapsed >= max_wait
65
+ puts " ⚠ Warning: dpkg lock still held after #{max_wait}s, proceeding anyway..."
66
+ break
67
+ end
68
+
69
+ puts " ⏳ Waiting for dpkg lock to be released (#{elapsed}s elapsed)..."
70
+ sleep wait_interval
71
+ elapsed += wait_interval
72
+ else
73
+ # No lock file - safe to proceed
74
+ break
75
+ end
76
+ end
77
+ end
78
+
79
+ def uninstall_package(package, host = nil)
80
+ if host
81
+ execute_on_host(host) do
82
+ execute :sudo, 'apt-get', 'remove', '-y', package
83
+ execute :sudo, 'apt-get', 'autoremove', '-y'
84
+ end
85
+ else
86
+ backend.execute :sudo, 'apt-get', 'remove', '-y', package
87
+ backend.execute :sudo, 'apt-get', 'autoremove', '-y'
88
+ end
89
+ end
90
+
91
+ def upload_template(template_path, destination, context = {}, host = nil)
92
+ template = File.read(template_path)
93
+ result = ERB.new(template).result_with_hash(context)
94
+
95
+ if host
96
+ execute_on_host(host) do
97
+ upload! StringIO.new(result), destination
98
+ execute :sudo, 'chown', 'root:root', destination
99
+ execute :sudo, 'chmod', '644', destination
100
+ end
101
+ else
102
+ backend.upload! StringIO.new(result), destination
103
+ backend.execute :sudo, 'chown', 'root:root', destination
104
+ backend.execute :sudo, 'chmod', '644', destination
105
+ end
106
+ end
107
+
108
+ def systemd_enable(service_name, host = nil)
109
+ if host
110
+ execute_on_host(host) do
111
+ execute :sudo, 'systemctl', 'daemon-reload'
112
+ execute :sudo, 'systemctl', 'enable', service_name
113
+ end
114
+ else
115
+ backend.execute :sudo, 'systemctl', 'daemon-reload'
116
+ backend.execute :sudo, 'systemctl', 'enable', service_name
117
+ end
118
+ end
119
+
120
+ def systemd_start(service_name, host = nil)
121
+ if host
122
+ execute_on_host(host) do
123
+ execute :sudo, 'systemctl', 'start', service_name
124
+ end
125
+ else
126
+ backend.execute :sudo, 'systemctl', 'start', service_name
127
+ end
128
+ end
129
+
130
+ def systemd_stop(service_name, host = nil)
131
+ if host
132
+ execute_on_host(host) do
133
+ execute :sudo, 'systemctl', 'stop', service_name
134
+ end
135
+ else
136
+ backend.execute :sudo, 'systemctl', 'stop', service_name
137
+ end
138
+ end
139
+
140
+ def systemd_restart(service_name, host = nil)
141
+ if host
142
+ execute_on_host(host) do
143
+ execute :sudo, 'systemctl', 'restart', service_name
144
+ end
145
+ else
146
+ backend.execute :sudo, 'systemctl', 'restart', service_name
147
+ end
148
+ end
149
+
150
+ def systemd_disable(service_name, host = nil)
151
+ if host
152
+ execute_on_host(host) do
153
+ execute :sudo, 'systemctl', 'disable', service_name
154
+ end
155
+ else
156
+ backend.execute :sudo, 'systemctl', 'disable', service_name
157
+ end
158
+ end
159
+
160
+ def systemd_status(service_name, host = nil)
161
+ if host
162
+ result = nil
163
+ execute_on_host(host) do
164
+ result = capture :sudo, 'systemctl', 'status', service_name, raise_on_non_zero_exit: false
165
+ end
166
+ result
167
+ else
168
+ backend.capture :sudo, 'systemctl', 'status', service_name, raise_on_non_zero_exit: false
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def backend
175
+ SSHKit::Backend.current ||
176
+ raise('SSHKit backend is not available. Provide a host or call within execute_on_host.')
177
+ end
178
+
179
+ def setup_sshkit
180
+ SSHKit::Backend::Netssh.configure do |ssh|
181
+ ssh.ssh_options = {
182
+ user: config.user,
183
+ keys: [File.expand_path(config.ssh_key)],
184
+ forward_agent: config.forward_agent,
185
+ auth_methods: config.ssh_auth_methods,
186
+ keys_only: !config.use_ssh_agent,
187
+ use_agent: config.use_ssh_agent
188
+ }
189
+ end
190
+ end
191
+
192
+ def formatted_host(host)
193
+ case host
194
+ when Hash
195
+ target_host = host['host'] || host[:host]
196
+ raise Error, 'Host entry missing host field' unless target_host
197
+
198
+ user = host['user'] || host[:user] || config.user
199
+ "#{user}@#{target_host}"
200
+ else
201
+ host_string = host.to_s
202
+ host_string.include?('@') ? host_string : "#{config.user}@#{host_string}"
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,3 @@
1
+ module BoringServices
2
+ VERSION = '0.2.0'.freeze
3
+ end
@@ -0,0 +1,32 @@
1
+ require_relative 'boring_services/version'
2
+ require_relative 'boring_services/configuration'
3
+ require_relative 'boring_services/secrets'
4
+ require_relative 'boring_services/cli'
5
+ require_relative 'boring_services/installer'
6
+ require_relative 'boring_services/ssh_executor'
7
+ require_relative 'boring_services/health_checker'
8
+
9
+ require_relative 'boring_services/services/base'
10
+ require_relative 'boring_services/services/memcached'
11
+ require_relative 'boring_services/services/redis'
12
+ require_relative 'boring_services/services/haproxy'
13
+ require_relative 'boring_services/services/nginx'
14
+
15
+ module BoringServices
16
+ class Error < StandardError; end
17
+
18
+ def self.root
19
+ File.expand_path('..', __dir__)
20
+ end
21
+
22
+ def self.status
23
+ config = Configuration.load
24
+ health_checker = HealthChecker.new(config)
25
+ health_checker.check_all
26
+ end
27
+ end
28
+ require 'stringio'
29
+
30
+ # Load railtie if Rails is already loaded (safe to require Rails components)
31
+ # This avoids load order issues with ActiveSupport
32
+ require_relative 'boring_services/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,47 @@
1
+ namespace :boring_services do
2
+ def boringservices_cli(*args)
3
+ cli_args = ['bundle', 'exec', 'boringservices', *args]
4
+ env = ENV.fetch('BORING_SERVICES_ENV', nil)
5
+ cli_args += ['-e', env] if env && !env.empty?
6
+ config_path = ENV.fetch('BORING_SERVICES_CONFIG', nil)
7
+ cli_args += ['-c', config_path] if config_path && !config_path.empty?
8
+ system(*cli_args)
9
+ end
10
+
11
+ desc 'Install BoringServices configuration'
12
+ task :install do
13
+ system('rails generate boring_services:install')
14
+ end
15
+
16
+ desc 'Deploy all services'
17
+ task :setup do
18
+ config_path = 'config/services.yml'
19
+ unless File.exist?(config_path)
20
+ puts "⚠️ Config file not found: #{config_path}"
21
+ puts '📦 Running install generator first...'
22
+ system('rails generate boring_services:install') || raise('Failed to generate config file')
23
+ end
24
+ boringservices_cli('setup')
25
+ end
26
+
27
+ desc 'Check services health'
28
+ task :health do
29
+ boringservices_cli('status')
30
+ end
31
+
32
+ desc 'Restart all services'
33
+ task :restart do
34
+ boringservices_cli('restart')
35
+ end
36
+
37
+ desc 'Reconfigure all services (skip package install, update config only)'
38
+ task :reconfigure do
39
+ boringservices_cli('reconfigure')
40
+ end
41
+
42
+ desc 'Show services status'
43
+ task :status do
44
+ puts "\n🔧 Infrastructure Services Status\n\n"
45
+ boringservices_cli('status')
46
+ end
47
+ end