anzen 0.1.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/.rspec +3 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +51 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/README.md +321 -0
- data/Rakefile +23 -0
- data/bin/anzen +168 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/anzen/README_API.md +608 -0
- data/lib/anzen/cli.rb +301 -0
- data/lib/anzen/configuration.rb +254 -0
- data/lib/anzen/exceptions.rb +130 -0
- data/lib/anzen/monitor.rb +85 -0
- data/lib/anzen/monitors/call_stack_depth.rb +147 -0
- data/lib/anzen/monitors/memory.rb +228 -0
- data/lib/anzen/monitors/recursion.rb +193 -0
- data/lib/anzen/registry.rb +169 -0
- data/lib/anzen/version.rb +5 -0
- data/lib/anzen.rb +210 -0
- data/sig/anzen.rbs +4 -0
- metadata +74 -0
data/lib/anzen/cli.rb
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Anzen
|
|
7
|
+
# Command-line interface for Anzen safety monitoring
|
|
8
|
+
#
|
|
9
|
+
# Provides operator-facing commands to query protection status,
|
|
10
|
+
# configuration, and monitor state without code changes.
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
class CLI
|
|
14
|
+
# Display current protection configuration and monitor state
|
|
15
|
+
#
|
|
16
|
+
# @param format [Symbol] Output format (:json or :text)
|
|
17
|
+
# @return [String] Formatted output
|
|
18
|
+
# @api public
|
|
19
|
+
def status(format: :text)
|
|
20
|
+
status_data = Anzen.status
|
|
21
|
+
|
|
22
|
+
case format
|
|
23
|
+
when :json
|
|
24
|
+
JSON.pretty_generate(status_data)
|
|
25
|
+
when :text
|
|
26
|
+
format_status_text(status_data)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Display detailed configuration for all monitors or specific monitor
|
|
33
|
+
#
|
|
34
|
+
# @param monitor_name [String, nil] Specific monitor name, or nil for all
|
|
35
|
+
# @param format [Symbol] Output format (:json or :text)
|
|
36
|
+
# @return [String] Formatted output
|
|
37
|
+
# @api public
|
|
38
|
+
def config(monitor_name: nil, format: :text)
|
|
39
|
+
if monitor_name
|
|
40
|
+
monitor_config = get_monitor_config(monitor_name)
|
|
41
|
+
case format
|
|
42
|
+
when :json
|
|
43
|
+
JSON.pretty_generate(monitor_config)
|
|
44
|
+
when :text
|
|
45
|
+
format_monitor_config_text(monitor_name, monitor_config)
|
|
46
|
+
else
|
|
47
|
+
raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
all_configs = get_all_configs
|
|
51
|
+
case format
|
|
52
|
+
when :json
|
|
53
|
+
JSON.pretty_generate(all_configs)
|
|
54
|
+
when :text
|
|
55
|
+
format_all_configs_text(all_configs)
|
|
56
|
+
else
|
|
57
|
+
raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Display general information about the Anzen gem installation
|
|
63
|
+
#
|
|
64
|
+
# @param format [Symbol] Output format (:json or :text)
|
|
65
|
+
# @return [String] Formatted output
|
|
66
|
+
# @api public
|
|
67
|
+
def info(format: :text)
|
|
68
|
+
info_data = {
|
|
69
|
+
name: 'anzen',
|
|
70
|
+
version: Anzen::VERSION,
|
|
71
|
+
ruby_version: RUBY_VERSION,
|
|
72
|
+
platform: RUBY_PLATFORM,
|
|
73
|
+
monitors_available: %w[recursion memory],
|
|
74
|
+
license: 'MIT',
|
|
75
|
+
documentation_url: 'https://github.com/[org]/anzen'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case format
|
|
79
|
+
when :json
|
|
80
|
+
JSON.pretty_generate(info_data)
|
|
81
|
+
when :text
|
|
82
|
+
format_info_text(info_data)
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Invalid format: #{format}. Use :json or :text"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Display help documentation and command reference
|
|
89
|
+
#
|
|
90
|
+
# @param command [String, nil] Specific command name, or nil for general help
|
|
91
|
+
# @return [String] Help text
|
|
92
|
+
# @api public
|
|
93
|
+
def help(command: nil)
|
|
94
|
+
if command
|
|
95
|
+
format_command_help(command)
|
|
96
|
+
else
|
|
97
|
+
format_general_help
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def get_monitor_config(monitor_name)
|
|
104
|
+
status_data = Anzen.status
|
|
105
|
+
monitor = status_data[:monitors].find { |m| m[:name] == monitor_name }
|
|
106
|
+
|
|
107
|
+
unless monitor
|
|
108
|
+
available = status_data[:monitors].map { |m| m[:name] }
|
|
109
|
+
raise Anzen::MonitorNotFoundError.new(monitor_name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
monitor: monitor_name,
|
|
114
|
+
enabled: monitor[:enabled],
|
|
115
|
+
config: monitor[:thresholds]
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_all_configs
|
|
120
|
+
status_data = Anzen.status
|
|
121
|
+
{
|
|
122
|
+
monitors: status_data[:monitors].each_with_object({}) do |monitor, hash|
|
|
123
|
+
hash[monitor[:name]] = {
|
|
124
|
+
enabled: monitor[:enabled],
|
|
125
|
+
config: monitor[:thresholds]
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def format_status_text(status_data)
|
|
132
|
+
output = []
|
|
133
|
+
output << 'Anzen Safety Protection Status'
|
|
134
|
+
output << ('=' * 40)
|
|
135
|
+
output << ''
|
|
136
|
+
output << "Enabled Monitors: #{status_data[:enabled].join(", ")}"
|
|
137
|
+
output << ''
|
|
138
|
+
|
|
139
|
+
status_data[:monitors].each do |monitor|
|
|
140
|
+
output << "Monitor: #{monitor[:name]}"
|
|
141
|
+
output << " Status: #{monitor[:enabled] ? "enabled" : "disabled"}"
|
|
142
|
+
output << ' Thresholds:'
|
|
143
|
+
|
|
144
|
+
monitor[:thresholds].each do |key, value|
|
|
145
|
+
output << " #{key}: #{value}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
last_check = monitor[:last_check]
|
|
149
|
+
output << if last_check
|
|
150
|
+
" Last Check: #{last_check.strftime("%Y-%m-%d %H:%M:%S %Z")}"
|
|
151
|
+
else
|
|
152
|
+
' Last Check: never'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
output << " Violations Detected: #{monitor[:violations]}"
|
|
156
|
+
output << ''
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
output << "Total Violations: #{status_data[:violations_total]}"
|
|
160
|
+
output << "Setup Time: #{status_data[:setup_at].strftime("%Y-%m-%d %H:%M:%S %Z")}"
|
|
161
|
+
|
|
162
|
+
output.join("\n")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def format_monitor_config_text(monitor_name, config)
|
|
166
|
+
output = []
|
|
167
|
+
output << "Monitor: #{monitor_name}"
|
|
168
|
+
output << " Enabled: #{config[:enabled] ? "yes" : "no"}"
|
|
169
|
+
output << ' Configuration:'
|
|
170
|
+
|
|
171
|
+
config[:config].each do |key, value|
|
|
172
|
+
output << " #{key}: #{value}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
output.join("\n")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def format_all_configs_text(configs)
|
|
179
|
+
output = []
|
|
180
|
+
output << 'Anzen Configuration'
|
|
181
|
+
output << ('=' * 20)
|
|
182
|
+
output << ''
|
|
183
|
+
|
|
184
|
+
configs[:monitors].each do |name, config|
|
|
185
|
+
output << "Monitor: #{name}"
|
|
186
|
+
output << " Enabled: #{config[:enabled] ? "yes" : "no"}"
|
|
187
|
+
output << ' Configuration:'
|
|
188
|
+
|
|
189
|
+
config[:config].each do |key, value|
|
|
190
|
+
output << " #{key}: #{value}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
output << ''
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
output.join("\n")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def format_info_text(info_data)
|
|
200
|
+
output = []
|
|
201
|
+
output << 'Anzen Gem Information'
|
|
202
|
+
output << ('=' * 25)
|
|
203
|
+
output << ''
|
|
204
|
+
output << "Version: #{info_data[:version]}"
|
|
205
|
+
output << "Installed Location: #{Gem.loaded_specs["anzen"]&.full_gem_path || "Not installed as gem"}"
|
|
206
|
+
output << "Ruby Version: #{info_data[:ruby_version]}"
|
|
207
|
+
output << "Platform: #{info_data[:platform]}"
|
|
208
|
+
output << "Available Monitors: #{info_data[:monitors_available].join(", ")}"
|
|
209
|
+
output << "License: #{info_data[:license]}"
|
|
210
|
+
output << "Documentation: #{info_data[:documentation_url]}"
|
|
211
|
+
|
|
212
|
+
output.join("\n")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def format_general_help
|
|
216
|
+
<<~HELP
|
|
217
|
+
Anzen Safety Protection - Command Line Interface
|
|
218
|
+
|
|
219
|
+
Usage: anzen [command] [options]
|
|
220
|
+
|
|
221
|
+
Commands:
|
|
222
|
+
status Display protection status and monitor state
|
|
223
|
+
config Display monitor configuration details
|
|
224
|
+
info Display gem installation information
|
|
225
|
+
help Show this help message
|
|
226
|
+
|
|
227
|
+
Options:
|
|
228
|
+
--format Output format: json, text (default: text)
|
|
229
|
+
--help Show command help
|
|
230
|
+
-v, --version Show gem version
|
|
231
|
+
|
|
232
|
+
Examples:
|
|
233
|
+
anzen status # Show status
|
|
234
|
+
anzen config memory --format json # Show memory config as JSON
|
|
235
|
+
anzen info # Show gem info
|
|
236
|
+
|
|
237
|
+
For more information, see: https://github.com/[org]/anzen
|
|
238
|
+
HELP
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_command_help(command)
|
|
242
|
+
case command
|
|
243
|
+
when 'status'
|
|
244
|
+
<<~HELP
|
|
245
|
+
anzen status - Display protection status and monitor state
|
|
246
|
+
|
|
247
|
+
Usage: anzen status [options]
|
|
248
|
+
|
|
249
|
+
Options:
|
|
250
|
+
--format FORMAT Output format: json, text (default: text)
|
|
251
|
+
|
|
252
|
+
Examples:
|
|
253
|
+
anzen status
|
|
254
|
+
anzen status --format json
|
|
255
|
+
|
|
256
|
+
Output includes:
|
|
257
|
+
- Monitor names and enabled/disabled state
|
|
258
|
+
- Configured thresholds for each monitor
|
|
259
|
+
- Last check timestamp
|
|
260
|
+
- Total violations detected
|
|
261
|
+
HELP
|
|
262
|
+
when 'config'
|
|
263
|
+
<<~HELP
|
|
264
|
+
anzen config - Display monitor configuration details
|
|
265
|
+
|
|
266
|
+
Usage: anzen config [monitor_name] [options]
|
|
267
|
+
|
|
268
|
+
Arguments:
|
|
269
|
+
monitor_name Optional: Show config for specific monitor
|
|
270
|
+
|
|
271
|
+
Options:
|
|
272
|
+
--format FORMAT Output format: json, text (default: text)
|
|
273
|
+
|
|
274
|
+
Examples:
|
|
275
|
+
anzen config
|
|
276
|
+
anzen config memory
|
|
277
|
+
anzen config --format json
|
|
278
|
+
|
|
279
|
+
Shows configuration for all monitors or specific monitor.
|
|
280
|
+
HELP
|
|
281
|
+
when 'info'
|
|
282
|
+
<<~HELP
|
|
283
|
+
anzen info - Display gem installation information
|
|
284
|
+
|
|
285
|
+
Usage: anzen info [options]
|
|
286
|
+
|
|
287
|
+
Options:
|
|
288
|
+
--format FORMAT Output format: json, text (default: text)
|
|
289
|
+
|
|
290
|
+
Examples:
|
|
291
|
+
anzen info
|
|
292
|
+
anzen info --format json
|
|
293
|
+
|
|
294
|
+
Shows gem version, Ruby version, platform, and other metadata.
|
|
295
|
+
HELP
|
|
296
|
+
else
|
|
297
|
+
"Unknown command '#{command}'. Use 'anzen help' for available commands."
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Anzen
|
|
7
|
+
# Configuration manager for Anzen
|
|
8
|
+
#
|
|
9
|
+
# Loads and validates configuration from various sources.
|
|
10
|
+
# Supports dot-notation for accessing nested config values.
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
class Configuration
|
|
14
|
+
# Known monitor names
|
|
15
|
+
KNOWN_MONITORS = %w[call_stack_depth recursion memory].freeze
|
|
16
|
+
private_constant :KNOWN_MONITORS
|
|
17
|
+
# Load configuration from environment variable
|
|
18
|
+
#
|
|
19
|
+
# Expects ANZEN_CONFIG to contain JSON or YAML config.
|
|
20
|
+
#
|
|
21
|
+
# @return [Configuration] configuration instance
|
|
22
|
+
# @raise [ConfigurationError] if config is invalid
|
|
23
|
+
def self.from_env
|
|
24
|
+
config_str = ENV.fetch('ANZEN_CONFIG', nil)
|
|
25
|
+
raise Anzen::ConfigurationError, 'ANZEN_CONFIG not set' unless config_str
|
|
26
|
+
|
|
27
|
+
config = nil
|
|
28
|
+
begin
|
|
29
|
+
# Try JSON first
|
|
30
|
+
config = JSON.parse(config_str)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
# Fall back to YAML
|
|
33
|
+
config = YAML.safe_load(config_str)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Ensure we got a hash back
|
|
37
|
+
raise Anzen::ConfigurationError, 'ANZEN_CONFIG must contain a valid JSON or YAML hash' unless config.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
new(config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load configuration from file
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] path to config file (JSON or YAML)
|
|
45
|
+
# @return [Configuration] configuration instance
|
|
46
|
+
# @raise [ConfigurationError] if file doesn't exist or is invalid
|
|
47
|
+
def self.from_file(path)
|
|
48
|
+
raise Anzen::ConfigurationError, "Config file not found: #{path}" unless File.exist?(path)
|
|
49
|
+
|
|
50
|
+
content = File.read(path)
|
|
51
|
+
config = if path.end_with?('.json')
|
|
52
|
+
JSON.parse(content)
|
|
53
|
+
else
|
|
54
|
+
YAML.safe_load(content)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
new(config)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create configuration from hash (programmatic)
|
|
61
|
+
#
|
|
62
|
+
# @param config [Hash] configuration hash
|
|
63
|
+
# @return [Configuration] configuration instance
|
|
64
|
+
def self.programmatic(config = {})
|
|
65
|
+
new(config)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Initialize Configuration
|
|
69
|
+
#
|
|
70
|
+
# @param config [Hash] configuration hash
|
|
71
|
+
def initialize(config = {})
|
|
72
|
+
@config = normalize_keys(config)
|
|
73
|
+
validate!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get configuration value by dot-notation path
|
|
77
|
+
#
|
|
78
|
+
# @param path [String] dot-notation path (e.g., "monitors.recursion.depth_limit")
|
|
79
|
+
# @return [Object] configuration value
|
|
80
|
+
# @raise [ConfigurationError] if path doesn't exist
|
|
81
|
+
def get(path)
|
|
82
|
+
keys = path.split('.')
|
|
83
|
+
value = @config
|
|
84
|
+
|
|
85
|
+
keys.each do |key|
|
|
86
|
+
case value
|
|
87
|
+
when Hash
|
|
88
|
+
value = value[key] || value[key.to_sym]
|
|
89
|
+
else
|
|
90
|
+
raise Anzen::ConfigurationError, "Cannot access '#{key}' in non-hash value"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
raise Anzen::ConfigurationError, "Config path not found: #{path}" if value.nil?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check if monitor is enabled
|
|
100
|
+
#
|
|
101
|
+
# @param name [String] monitor name
|
|
102
|
+
# @return [Boolean] true if monitor is in enabled_monitors list
|
|
103
|
+
def monitor_enabled?(name)
|
|
104
|
+
enabled = begin
|
|
105
|
+
get('enabled_monitors')
|
|
106
|
+
rescue StandardError
|
|
107
|
+
[]
|
|
108
|
+
end
|
|
109
|
+
enabled.include?(name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get configuration for specific monitor
|
|
113
|
+
#
|
|
114
|
+
# @param name [String] monitor name
|
|
115
|
+
# @return [Hash] monitor configuration
|
|
116
|
+
# @raise [ConfigurationError] if monitor config not found
|
|
117
|
+
def monitor_config(name)
|
|
118
|
+
get("monitors.#{name}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Validate configuration schema
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean] true if valid
|
|
124
|
+
# @raise [ConfigurationError] if invalid
|
|
125
|
+
def validate!
|
|
126
|
+
# Normalize keys to strings for consistent access
|
|
127
|
+
normalized = normalize_keys(@config)
|
|
128
|
+
|
|
129
|
+
# enabled_monitors should be array if present and non-nil
|
|
130
|
+
if normalized.key?('enabled_monitors') && !normalized['enabled_monitors'].nil? && !normalized['enabled_monitors'].is_a?(Array)
|
|
131
|
+
raise Anzen::ConfigurationError, 'enabled_monitors must be an array'
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# monitors should be hash if present and non-nil
|
|
135
|
+
if normalized.key?('monitors') && !normalized['monitors'].nil? && !normalized['monitors'].is_a?(Hash)
|
|
136
|
+
raise Anzen::ConfigurationError, 'monitors must be a hash'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check that all enabled monitors are known
|
|
140
|
+
enabled_monitors = normalized['enabled_monitors'] || []
|
|
141
|
+
unknown_monitors = enabled_monitors - KNOWN_MONITORS
|
|
142
|
+
unless unknown_monitors.empty?
|
|
143
|
+
raise Anzen::ConfigurationError, "Unknown monitor(s): #{unknown_monitors.join(", ")}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Validate monitor configs
|
|
147
|
+
monitors = normalized['monitors'] || {}
|
|
148
|
+
monitors.each do |name, config|
|
|
149
|
+
validate_monitor_config(name, config)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Return raw configuration hash
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash]
|
|
158
|
+
def to_h
|
|
159
|
+
deep_dup(@config)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Deep copy of a hash
|
|
165
|
+
#
|
|
166
|
+
# @param obj [Object] object to deep copy
|
|
167
|
+
# @return [Object] deep copy
|
|
168
|
+
def deep_dup(obj)
|
|
169
|
+
case obj
|
|
170
|
+
when Hash
|
|
171
|
+
obj.each_with_object({}) do |(k, v), hash|
|
|
172
|
+
hash[k] = deep_dup(v)
|
|
173
|
+
end
|
|
174
|
+
when Array
|
|
175
|
+
obj.map { |item| deep_dup(item) }
|
|
176
|
+
else
|
|
177
|
+
begin
|
|
178
|
+
obj.dup
|
|
179
|
+
rescue StandardError
|
|
180
|
+
obj
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Normalize hash keys from symbols to strings (recursively)
|
|
186
|
+
#
|
|
187
|
+
# @param hash [Hash] hash to normalize
|
|
188
|
+
# @return [Hash] hash with string keys
|
|
189
|
+
def normalize_keys(hash)
|
|
190
|
+
return hash unless hash.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
hash.each_with_object({}) do |(key, value), new_hash|
|
|
193
|
+
string_key = key.is_a?(Symbol) ? key.to_s : key
|
|
194
|
+
new_hash[string_key] = value.is_a?(Hash) ? normalize_keys(value) : value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Validate individual monitor configuration
|
|
199
|
+
#
|
|
200
|
+
# @param name [String] monitor name
|
|
201
|
+
# @param config [Hash] monitor configuration
|
|
202
|
+
# @raise [ConfigurationError] if invalid
|
|
203
|
+
def validate_monitor_config(name, config)
|
|
204
|
+
return unless config.is_a?(Hash)
|
|
205
|
+
|
|
206
|
+
case name
|
|
207
|
+
when 'recursion'
|
|
208
|
+
validate_recursion_config(config)
|
|
209
|
+
when 'memory'
|
|
210
|
+
validate_memory_config(config)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Validate recursion monitor config
|
|
215
|
+
#
|
|
216
|
+
# @param config [Hash] recursion config
|
|
217
|
+
# @raise [ConfigurationError] if invalid
|
|
218
|
+
def validate_recursion_config(config)
|
|
219
|
+
normalized = normalize_keys(config)
|
|
220
|
+
|
|
221
|
+
if normalized['depth_limit'] && !normalized['depth_limit'].is_a?(Numeric)
|
|
222
|
+
raise Anzen::ConfigurationError, 'recursion.depth_limit must be numeric'
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
return unless normalized['depth_limit'] && normalized['depth_limit'] <= 0
|
|
226
|
+
|
|
227
|
+
raise Anzen::ConfigurationError, 'recursion.depth_limit must be positive'
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Validate memory monitor config
|
|
231
|
+
#
|
|
232
|
+
# @param config [Hash] memory config
|
|
233
|
+
# @raise [ConfigurationError] if invalid
|
|
234
|
+
def validate_memory_config(config)
|
|
235
|
+
normalized = normalize_keys(config)
|
|
236
|
+
|
|
237
|
+
if normalized['limit_mb'] && !normalized['limit_mb'].is_a?(Numeric)
|
|
238
|
+
raise Anzen::ConfigurationError, 'memory.limit_mb must be numeric'
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if normalized['limit_mb'] && normalized['limit_mb'] <= 0
|
|
242
|
+
raise Anzen::ConfigurationError, 'memory.limit_mb must be positive'
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if normalized['limit_percent'] && !normalized['limit_percent'].is_a?(Numeric)
|
|
246
|
+
raise Anzen::ConfigurationError, 'memory.limit_percent must be numeric'
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if normalized['limit_percent'] && (normalized['limit_percent'] <= 0 || normalized['limit_percent'] > 100)
|
|
250
|
+
raise Anzen::ConfigurationError, 'memory.limit_percent must be between 0 and 100'
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anzen
|
|
4
|
+
# Base exception for all Anzen errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Base exception for safety violations detected by monitors
|
|
8
|
+
#
|
|
9
|
+
# @api public
|
|
10
|
+
class ViolationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when recursion depth exceeds configured threshold
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
class RecursionLimitExceeded < ViolationError
|
|
16
|
+
# @return [Integer] current recursion depth when violation detected
|
|
17
|
+
attr_reader :current_depth
|
|
18
|
+
|
|
19
|
+
# @return [Integer] configured depth threshold
|
|
20
|
+
attr_reader :threshold
|
|
21
|
+
|
|
22
|
+
# Initialize RecursionLimitExceeded exception
|
|
23
|
+
#
|
|
24
|
+
# @param current_depth [Integer] current call stack depth
|
|
25
|
+
# @param threshold [Integer] configured limit
|
|
26
|
+
def initialize(current_depth, threshold)
|
|
27
|
+
@current_depth = current_depth
|
|
28
|
+
@threshold = threshold
|
|
29
|
+
super("Recursion depth (#{current_depth}) exceeded threshold (#{threshold})")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raised when process memory usage exceeds configured threshold
|
|
34
|
+
#
|
|
35
|
+
# @api public
|
|
36
|
+
class MemoryLimitExceeded < ViolationError
|
|
37
|
+
# @return [Integer] current memory usage in MB
|
|
38
|
+
attr_reader :current_memory_mb
|
|
39
|
+
|
|
40
|
+
# @return [Integer] configured memory threshold in MB
|
|
41
|
+
attr_reader :threshold_mb
|
|
42
|
+
|
|
43
|
+
# Initialize MemoryLimitExceeded exception
|
|
44
|
+
#
|
|
45
|
+
# @param current_memory_mb [Integer] current process memory in MB
|
|
46
|
+
# @param threshold_mb [Integer] configured threshold in MB
|
|
47
|
+
def initialize(current_memory_mb, threshold_mb)
|
|
48
|
+
@current_memory_mb = current_memory_mb
|
|
49
|
+
@threshold_mb = threshold_mb
|
|
50
|
+
super("Memory usage (#{current_memory_mb}MB) exceeded threshold (#{threshold_mb}MB)")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised when a monitor's check infrastructure fails (not a violation)
|
|
55
|
+
#
|
|
56
|
+
# Used to distinguish infrastructure errors from actual safety violations.
|
|
57
|
+
# For example: cannot read /proc/self/status when checking memory.
|
|
58
|
+
#
|
|
59
|
+
# @api public
|
|
60
|
+
class CheckFailedError < Error
|
|
61
|
+
# @return [String] name of the monitor that failed
|
|
62
|
+
attr_reader :monitor_name
|
|
63
|
+
|
|
64
|
+
# @return [String] reason for the check failure
|
|
65
|
+
attr_reader :reason
|
|
66
|
+
|
|
67
|
+
# @return [Exception, nil] original exception if available
|
|
68
|
+
attr_reader :original_error
|
|
69
|
+
|
|
70
|
+
# Initialize CheckFailedError
|
|
71
|
+
#
|
|
72
|
+
# @param monitor_name [String] name of the monitor that failed
|
|
73
|
+
# @param reason [String] description of what failed
|
|
74
|
+
# @param original_error [Exception, nil] exception from the failed check
|
|
75
|
+
def initialize(monitor_name, reason, original_error = nil)
|
|
76
|
+
@monitor_name = monitor_name
|
|
77
|
+
@reason = reason
|
|
78
|
+
@original_error = original_error
|
|
79
|
+
|
|
80
|
+
message = "Monitor '#{monitor_name}' check failed: #{reason}"
|
|
81
|
+
message += " (#{original_error.class}: #{original_error.message})" if original_error
|
|
82
|
+
super(message)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Raised when Anzen configuration is invalid
|
|
87
|
+
#
|
|
88
|
+
# @api public
|
|
89
|
+
class ConfigurationError < Error; end
|
|
90
|
+
|
|
91
|
+
# Raised when attempting to access a non-existent monitor
|
|
92
|
+
#
|
|
93
|
+
# @api public
|
|
94
|
+
class MonitorNotFoundError < Error
|
|
95
|
+
# @param monitor_name [String] name of monitor that was not found
|
|
96
|
+
def initialize(monitor_name)
|
|
97
|
+
super("Monitor '#{monitor_name}' not found in registry")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Raised when attempting to register an invalid monitor
|
|
102
|
+
#
|
|
103
|
+
# @api public
|
|
104
|
+
class InvalidMonitorError < Error
|
|
105
|
+
# @param reason [String] description of what makes monitor invalid
|
|
106
|
+
def initialize(reason)
|
|
107
|
+
super("Invalid monitor: #{reason}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Raised when attempting to register a monitor with a name that already exists
|
|
112
|
+
#
|
|
113
|
+
# @api public
|
|
114
|
+
class MonitorNameConflictError < Error
|
|
115
|
+
# @param monitor_name [String] name that caused conflict
|
|
116
|
+
def initialize(monitor_name)
|
|
117
|
+
super("Monitor name '#{monitor_name}' is already registered")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Raised when Anzen.setup is called more than once
|
|
122
|
+
#
|
|
123
|
+
# @api public
|
|
124
|
+
class InitializationError < Error
|
|
125
|
+
# @param reason [String] description of initialization problem
|
|
126
|
+
def initialize(reason = 'Anzen already initialized')
|
|
127
|
+
super(reason)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|