boring_services 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afc3d44acfb6c7fe4d741a643dc87d08b2b9ae371daa24a054dea6c25f6c6429
4
- data.tar.gz: d2c240bdbfe39bf716ac2025e4f8249b01e3b5dceda5fa6766601aeca308abb0
3
+ metadata.gz: e386da541954a876687c37adade51199a571e5b75bc5583946a676a42a69e5e0
4
+ data.tar.gz: bb16f6517193ad18614eb91632c462e031856d06ac9d73abd25675698df50f43
5
5
  SHA512:
6
- metadata.gz: 924f46cbdd506ecd7212bdd0836aa557f8e397986861f21f684f9d0e7a02d1ede2a627e1aca67adb11f4a7faf94ef207ca1dc4476c2e5768ee80ddc6420cd714
7
- data.tar.gz: 180534ff00c723563bd8e09bd022d94e100cf9f2e864cfe561db2021d288cd2a818957fe9baef5436af267d69b5876df7c1e67560dc974ccb3036eea1891c65c
6
+ metadata.gz: 87986c7987ff4b9ee2490fe4ca8035ad536a90b571d2d9cdcf0d995c93a4e8d7e8c2836a2c4fa99fcf2137c1096aad91c166b300b1ff4a88df817220e29aaa51
7
+ data.tar.gz: ae84eb250c619ceb130e90db62fb4538939ee22755700e42231164f3c012ab67df397a5ad6f927534862827b346cf00f82b563b154a97ea698835f798275e8cc
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
 
3
5
  module BoringServices
@@ -16,8 +18,7 @@ module BoringServices
16
18
  desc 'setup', 'Setup/install all services (alias for install)'
17
19
  def setup
18
20
  config = Configuration.load(options[:config], options[:environment])
19
- installer = Installer.new(config)
20
- installer.install_all
21
+ Installer.new(config).install_all
21
22
  rescue Error => e
22
23
  puts "Error: #{e.message}"
23
24
  exit 1
@@ -27,12 +28,7 @@ module BoringServices
27
28
  def install(service_name = nil)
28
29
  config = Configuration.load(options[:config], options[:environment])
29
30
  installer = Installer.new(config)
30
-
31
- if service_name
32
- installer.install_service(service_name)
33
- else
34
- installer.install_all
35
- end
31
+ service_name ? installer.install_service(service_name) : installer.install_all
36
32
  rescue Error => e
37
33
  puts "Error: #{e.message}"
38
34
  exit 1
@@ -41,8 +37,7 @@ module BoringServices
41
37
  desc 'uninstall SERVICE', 'Uninstall a specific service'
42
38
  def uninstall(service_name)
43
39
  config = Configuration.load(options[:config], options[:environment])
44
- installer = Installer.new(config)
45
- installer.uninstall_service(service_name)
40
+ Installer.new(config).uninstall_service(service_name)
46
41
  rescue Error => e
47
42
  puts "Error: #{e.message}"
48
43
  exit 1
@@ -51,8 +46,7 @@ module BoringServices
51
46
  desc 'restart SERVICE', 'Restart a specific service'
52
47
  def restart(service_name)
53
48
  config = Configuration.load(options[:config], options[:environment])
54
- installer = Installer.new(config)
55
- installer.restart_service(service_name)
49
+ Installer.new(config).restart_service(service_name)
56
50
  rescue Error => e
57
51
  puts "Error: #{e.message}"
58
52
  exit 1
@@ -62,12 +56,7 @@ module BoringServices
62
56
  def reconfigure(service_name = nil)
63
57
  config = Configuration.load(options[:config], options[:environment])
64
58
  installer = Installer.new(config)
65
-
66
- if service_name
67
- installer.reconfigure_service(service_name)
68
- else
69
- installer.reconfigure_all
70
- end
59
+ service_name ? installer.reconfigure_service(service_name) : installer.reconfigure_all
71
60
  rescue Error => e
72
61
  puts "Error: #{e.message}"
73
62
  exit 1
@@ -76,15 +65,12 @@ module BoringServices
76
65
  desc 'status', 'Check health status of all services'
77
66
  def status
78
67
  Configuration.load(options[:config], options[:environment])
79
- results = BoringServices.status
80
-
81
- results.each do |service_name, result|
68
+ BoringServices.status.each do |service_name, result|
82
69
  puts "\n#{service_name}: #{result[:status]}"
83
70
  next unless result[:hosts]
84
71
 
85
- result[:hosts].each do |host, host_result|
86
- status_icon = host_result[:running] ? '✓' : '✗'
87
- puts " #{status_icon} #{host}: #{host_result[:running] ? 'running' : 'stopped'}"
72
+ result[:hosts].each do |host_result|
73
+ print_host_status(service_name, host_result)
88
74
  end
89
75
  end
90
76
  rescue Error => e
@@ -96,5 +82,53 @@ module BoringServices
96
82
  def version
97
83
  puts "boring_services #{VERSION}"
98
84
  end
85
+
86
+ private
87
+
88
+ def print_host_status(service_name, host_result)
89
+ status_icon = host_result[:running] ? '✓' : '✗'
90
+ label = host_result[:label] || host_result[:host]
91
+ status_text = host_result[:running] ? 'running' : 'stopped'
92
+ puts " #{status_icon} #{label}: #{status_text}"
93
+
94
+ return unless host_result[:stats]&.any?
95
+
96
+ case service_name
97
+ when 'redis' then print_redis_stats(host_result[:stats])
98
+ when 'memcached' then print_memcached_stats(host_result[:stats])
99
+ end
100
+ end
101
+
102
+ def print_redis_stats(stats)
103
+ memory = stats['used_memory_human'] || 'N/A'
104
+ max_memory = stats['maxmemory_human'] || 'N/A'
105
+ clients = stats['connected_clients'] || '0'
106
+ hit_rate = calculate_hit_rate(stats['keyspace_hits'], stats['keyspace_misses'])
107
+ puts " memory: #{memory} / #{max_memory}, clients: #{clients}, hit rate: #{hit_rate}%"
108
+ end
109
+
110
+ def print_memcached_stats(stats)
111
+ bytes = format_bytes(stats['bytes']&.to_i || 0)
112
+ conns = stats['curr_connections'] || '0'
113
+ items = stats['curr_items'] || '0'
114
+ hit_rate = calculate_hit_rate(stats['get_hits'], stats['get_misses'])
115
+ puts " memory: #{bytes}, connections: #{conns}, items: #{items}, hit rate: #{hit_rate}%"
116
+ end
117
+
118
+ def calculate_hit_rate(hits, misses)
119
+ hits = hits&.to_i || 0
120
+ misses = misses&.to_i || 0
121
+ total = hits + misses
122
+ total.positive? ? ((hits.to_f / total) * 100).round(1) : 0
123
+ end
124
+
125
+ def format_bytes(bytes)
126
+ return '0B' if bytes.zero?
127
+
128
+ units = %w[B KB MB GB]
129
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
130
+ exp = [exp, units.length - 1].min
131
+ "#{(bytes.to_f / (1024**exp)).round(1)}#{units[exp]}"
132
+ end
99
133
  end
100
134
  end
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module BoringServices
2
4
  class HealthChecker
5
+ REDIS_INFO_KEYS = %w[used_memory_human connected_clients keyspace_hits keyspace_misses maxmemory_human].freeze
6
+
3
7
  attr_reader :config, :ssh_executor
4
8
 
5
9
  def initialize(config)
@@ -16,32 +20,91 @@ module BoringServices
16
20
  end
17
21
 
18
22
  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)
23
+ hosts = extract_hosts(service)
24
+ return { status: 'no_host' } if hosts.empty?
24
25
 
26
+ host_results = hosts.map { |host_config| check_host(service['name'], host_config) }
25
27
  {
26
- status: host_result[:running] ? 'healthy' : 'unhealthy',
27
- host: host_result
28
+ status: host_results.all? { |r| r[:running] } ? 'healthy' : 'unhealthy',
29
+ hosts: host_results
28
30
  }
29
31
  end
30
32
 
31
33
  private
32
34
 
35
+ def extract_hosts(service)
36
+ return Array(service['hosts']) if service['hosts']
37
+ return [service['host']] if service['host']
38
+
39
+ []
40
+ end
41
+
42
+ def check_host(service_name, host_config)
43
+ host = host_config.is_a?(Hash) ? (host_config['host'] || host_config[:host]) : host_config
44
+ label = host_config.is_a?(Hash) ? (host_config['label'] || host_config[:label]) : nil
45
+
46
+ result = check_service_on_host(service_name, host)
47
+ result[:label] = label
48
+ result[:host] = host
49
+ result
50
+ end
51
+
33
52
  def check_service_on_host(service_name, host)
34
- result = { running: false, message: '' }
53
+ systemd_service = service_name == 'redis' ? 'redis-server' : service_name
54
+ status_output = ssh_executor.systemd_status(systemd_service, host)
55
+ running = status_output.to_s.include?('active (running)')
35
56
 
57
+ result = { running: running, message: status_output.to_s }
58
+ result[:stats] = fetch_stats(service_name, host) if running
59
+ result
60
+ rescue StandardError => e
61
+ { running: false, message: e.message }
62
+ end
63
+
64
+ def fetch_stats(service_name, host)
65
+ case service_name
66
+ when 'redis' then get_redis_stats(host)
67
+ when 'memcached' then get_memcached_stats(host)
68
+ else {}
69
+ end
70
+ end
71
+
72
+ def get_redis_stats(host)
73
+ password = config.secrets['redis_password']
74
+ password_resolved = password ? Secrets.resolve(password) : nil
75
+
76
+ result = {}
36
77
  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
78
+ args = ['redis-cli']
79
+ args += ['-a', password_resolved] if password_resolved
80
+ args << 'INFO'
81
+
82
+ info = capture(*args, raise_on_non_zero_exit: false)
83
+ info.to_s.each_line do |line|
84
+ key, value = line.strip.split(':')
85
+ result[key] = value if key && value && REDIS_INFO_KEYS.include?(key)
86
+ end
40
87
  end
88
+ result
89
+ rescue StandardError
90
+ {}
91
+ end
92
+
93
+ def get_memcached_stats(host)
94
+ result = {}
95
+ ssh_executor.execute_on_host(host) do
96
+ stats = capture(:bash, '-c', "echo 'stats' | nc localhost 11211", raise_on_non_zero_exit: false)
97
+ stats.to_s.each_line do |line|
98
+ parts = line.strip.split
99
+ next unless parts[0] == 'STAT' && parts.length >= 3
100
+ next unless %w[bytes curr_connections get_hits get_misses curr_items].include?(parts[1])
41
101
 
102
+ result[parts[1]] = parts[2]
103
+ end
104
+ end
42
105
  result
43
- rescue StandardError => e
44
- { running: false, message: e.message }
106
+ rescue StandardError
107
+ {}
45
108
  end
46
109
  end
47
110
  end
@@ -7,7 +7,7 @@ module BoringServices
7
7
  ssh_executor.install_package('memcached')
8
8
  configure_memcached
9
9
  ssh_executor.systemd_enable('memcached')
10
- ssh_executor.systemd_start('memcached')
10
+ ssh_executor.systemd_restart('memcached')
11
11
  end
12
12
  rails_credentials_entry
13
13
  end
@@ -37,6 +37,7 @@ module BoringServices
37
37
  config_content = <<~REDIS
38
38
  bind 0.0.0.0
39
39
  port #{listen_port}
40
+ dir /var/lib/redis
40
41
  maxmemory #{memory}mb
41
42
  maxmemory-policy allkeys-lru
42
43
  appendonly yes
@@ -49,6 +50,10 @@ module BoringServices
49
50
  execute :sudo, :mv, '/tmp/redis.conf', '/etc/redis/redis.conf'
50
51
  execute :sudo, :chown, 'redis:redis', '/etc/redis/redis.conf'
51
52
  execute :sudo, :chmod, '640', '/etc/redis/redis.conf'
53
+
54
+ # Set vm.overcommit_memory for Redis background saves
55
+ execute :sudo, :sysctl, '-w', 'vm.overcommit_memory=1'
56
+ execute "echo 'vm.overcommit_memory = 1' | sudo tee -a /etc/sysctl.conf > /dev/null || true"
52
57
  end
53
58
  end
54
59
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sshkit'
2
4
  require 'sshkit/dsl'
3
5
 
@@ -5,6 +7,9 @@ module BoringServices
5
7
  class SSHExecutor
6
8
  include SSHKit::DSL
7
9
 
10
+ DPKG_LOCK_MAX_WAIT = 300
11
+ DPKG_LOCK_INTERVAL = 10
12
+
8
13
  attr_reader :config
9
14
 
10
15
  def initialize(config)
@@ -35,7 +40,6 @@ module BoringServices
35
40
  execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', package
36
41
  end
37
42
  else
38
- # Called from within SSHKit context
39
43
  wait_for_dpkg_lock
40
44
  backend.execute :sudo, 'apt-get', 'update'
41
45
  backend.execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', package
@@ -43,36 +47,23 @@ module BoringServices
43
47
  end
44
48
 
45
49
  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
50
  elapsed = 0
50
51
 
51
52
  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
53
+ break unless backend.test '[ -f /var/lib/dpkg/lock-frontend ]'
54
+
55
+ processes = backend.capture(:sudo, :fuser, '/var/lib/dpkg/lock-frontend', '2>/dev/null',
56
+ raise_on_non_zero_exit: false).strip
57
+ break if processes.empty?
58
+
59
+ if elapsed >= DPKG_LOCK_MAX_WAIT
60
+ puts " ⚠ Warning: dpkg lock still held after #{DPKG_LOCK_MAX_WAIT}s, proceeding anyway..."
74
61
  break
75
62
  end
63
+
64
+ puts " ⏳ Waiting for dpkg lock to be released (#{elapsed}s elapsed)..."
65
+ sleep DPKG_LOCK_INTERVAL
66
+ elapsed += DPKG_LOCK_INTERVAL
76
67
  end
77
68
  end
78
69
 
@@ -119,9 +110,7 @@ module BoringServices
119
110
 
120
111
  def systemd_start(service_name, host = nil)
121
112
  if host
122
- execute_on_host(host) do
123
- execute :sudo, 'systemctl', 'start', service_name
124
- end
113
+ execute_on_host(host) { execute :sudo, 'systemctl', 'start', service_name }
125
114
  else
126
115
  backend.execute :sudo, 'systemctl', 'start', service_name
127
116
  end
@@ -129,9 +118,7 @@ module BoringServices
129
118
 
130
119
  def systemd_stop(service_name, host = nil)
131
120
  if host
132
- execute_on_host(host) do
133
- execute :sudo, 'systemctl', 'stop', service_name
134
- end
121
+ execute_on_host(host) { execute :sudo, 'systemctl', 'stop', service_name }
135
122
  else
136
123
  backend.execute :sudo, 'systemctl', 'stop', service_name
137
124
  end
@@ -139,9 +126,7 @@ module BoringServices
139
126
 
140
127
  def systemd_restart(service_name, host = nil)
141
128
  if host
142
- execute_on_host(host) do
143
- execute :sudo, 'systemctl', 'restart', service_name
144
- end
129
+ execute_on_host(host) { execute :sudo, 'systemctl', 'restart', service_name }
145
130
  else
146
131
  backend.execute :sudo, 'systemctl', 'restart', service_name
147
132
  end
@@ -149,9 +134,7 @@ module BoringServices
149
134
 
150
135
  def systemd_disable(service_name, host = nil)
151
136
  if host
152
- execute_on_host(host) do
153
- execute :sudo, 'systemctl', 'disable', service_name
154
- end
137
+ execute_on_host(host) { execute :sudo, 'systemctl', 'disable', service_name }
155
138
  else
156
139
  backend.execute :sudo, 'systemctl', 'disable', service_name
157
140
  end
@@ -159,21 +142,28 @@ module BoringServices
159
142
 
160
143
  def systemd_status(service_name, host = nil)
161
144
  if host
162
- result = nil
145
+ output = nil
163
146
  execute_on_host(host) do
164
- result = capture :sudo, 'systemctl', 'status', service_name, raise_on_non_zero_exit: false
147
+ output = capture :sudo, 'systemctl', 'status', service_name, raise_on_non_zero_exit: false
165
148
  end
166
- result
149
+ output
167
150
  else
168
151
  backend.capture :sudo, 'systemctl', 'status', service_name, raise_on_non_zero_exit: false
169
152
  end
170
153
  end
171
154
 
155
+ def capture_on_host(host, *args)
156
+ output = nil
157
+ execute_on_host(host) do
158
+ output = capture(*args, raise_on_non_zero_exit: false)
159
+ end
160
+ output
161
+ end
162
+
172
163
  private
173
164
 
174
165
  def backend
175
- SSHKit::Backend.current ||
176
- raise('SSHKit backend is not available. Provide a host or call within execute_on_host.')
166
+ SSHKit::Backend.current || raise(Error, 'SSHKit backend not available')
177
167
  end
178
168
 
179
169
  def setup_sshkit
@@ -195,12 +185,19 @@ module BoringServices
195
185
  target_host = host['host'] || host[:host]
196
186
  raise Error, 'Host entry missing host field' unless target_host
197
187
 
198
- user = host['user'] || host[:user] || config.user
199
- "#{user}@#{target_host}"
188
+ build_sshkit_host(target_host, host['user'] || host[:user])
200
189
  else
201
190
  host_string = host.to_s
202
- host_string.include?('@') ? host_string : "#{config.user}@#{host_string}"
191
+ return SSHKit::Host.new(host_string) if host_string.include?('@')
192
+
193
+ build_sshkit_host(host_string, nil)
203
194
  end
204
195
  end
196
+
197
+ def build_sshkit_host(hostname, user)
198
+ sshkit_host = SSHKit::Host.new(hostname)
199
+ sshkit_host.user = user || config.user
200
+ sshkit_host
201
+ end
205
202
  end
206
203
  end
@@ -1,3 +1,3 @@
1
1
  module BoringServices
2
- VERSION = '0.2.0'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boring_services
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BoringCache
@@ -158,7 +158,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
158
158
  - !ruby/object:Gem::Version
159
159
  version: '0'
160
160
  requirements: []
161
- rubygems_version: 3.6.7
161
+ rubygems_version: 3.7.2
162
162
  specification_version: 4
163
163
  summary: Deploy infrastructure services for Ruby & Rails apps
164
164
  test_files: []