zabbix_manager 5.1.5 → 6.0.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 +4 -4
- data/CHANGELOG.md +55 -9
- data/LICENSE +1 -1
- data/README.md +241 -24
- data/lib/zabbix_manager/classes/actions.rb +19 -17
- data/lib/zabbix_manager/classes/applications.rb +11 -22
- data/lib/zabbix_manager/classes/configurations.rb +20 -10
- data/lib/zabbix_manager/classes/discovery_rules.rb +23 -0
- data/lib/zabbix_manager/classes/errors.rb +18 -27
- data/lib/zabbix_manager/classes/events.rb +8 -3
- data/lib/zabbix_manager/classes/graphs.rb +48 -37
- data/lib/zabbix_manager/classes/host_groups.rb +64 -0
- data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
- data/lib/zabbix_manager/classes/hosts.rb +232 -76
- data/lib/zabbix_manager/classes/http_tests.rb +32 -0
- data/lib/zabbix_manager/classes/items.rb +206 -72
- data/lib/zabbix_manager/classes/maintenance.rb +8 -3
- data/lib/zabbix_manager/classes/media_types.rb +18 -0
- data/lib/zabbix_manager/classes/problems.rb +51 -62
- data/lib/zabbix_manager/classes/proxies.rb +45 -14
- data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
- data/lib/zabbix_manager/classes/roles.rb +46 -41
- data/lib/zabbix_manager/classes/screens.rb +36 -24
- data/lib/zabbix_manager/classes/scripts.rb +16 -12
- data/lib/zabbix_manager/classes/server.rb +8 -1
- data/lib/zabbix_manager/classes/templates.rb +26 -51
- data/lib/zabbix_manager/classes/triggers.rb +251 -67
- data/lib/zabbix_manager/classes/user_groups.rb +63 -0
- data/lib/zabbix_manager/classes/user_macros.rb +110 -0
- data/lib/zabbix_manager/classes/users.rb +16 -23
- data/lib/zabbix_manager/classes/value_maps.rb +52 -0
- data/lib/zabbix_manager/client.rb +183 -137
- data/lib/zabbix_manager/configuration.rb +107 -0
- data/lib/zabbix_manager/http_transport.rb +240 -0
- data/lib/zabbix_manager/log_sanitizer.rb +58 -0
- data/lib/zabbix_manager/monitoring/device.rb +393 -0
- data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
- data/lib/zabbix_manager/monitoring/line.rb +175 -0
- data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
- data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
- data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
- data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
- data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
- data/lib/zabbix_manager/monitoring/validation.rb +92 -0
- data/lib/zabbix_manager/monitoring.rb +265 -0
- data/lib/zabbix_manager/resource.rb +235 -0
- data/lib/zabbix_manager/traffic.rb +238 -0
- data/lib/zabbix_manager/version.rb +1 -1
- data/lib/zabbix_manager.rb +110 -145
- data/zabbix_manager.gemspec +12 -15
- metadata +67 -30
- data/lib/zabbix_manager/basic/basic_alias.rb +0 -20
- data/lib/zabbix_manager/basic/basic_func.rb +0 -84
- data/lib/zabbix_manager/basic/basic_init.rb +0 -35
- data/lib/zabbix_manager/basic/basic_logic.rb +0 -195
- data/lib/zabbix_manager/classes/drules.rb +0 -41
- data/lib/zabbix_manager/classes/hostgroups.rb +0 -20
- data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -29
- data/lib/zabbix_manager/classes/httptests.rb +0 -40
- data/lib/zabbix_manager/classes/mediatypes.rb +0 -79
- data/lib/zabbix_manager/classes/unusable.rb +0 -11
- data/lib/zabbix_manager/classes/usergroups.rb +0 -53
- data/lib/zabbix_manager/classes/usermacros.rb +0 -136
- data/lib/zabbix_manager/classes/valuemaps.rb +0 -32
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
class ZabbixManager
|
|
10
|
+
# 持有串行复用的 Net::HTTP 会话,负责代理、TLS、连接关闭及网络错误转换。
|
|
11
|
+
# 超时配置作用于连接、读取和写入阶段;传输失败不重放请求。
|
|
12
|
+
class HttpTransport
|
|
13
|
+
DEFAULT_TIMEOUT = 60
|
|
14
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT = 30
|
|
15
|
+
DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
16
|
+
# 仅用于中止单次网络操作,不包裹业务编排或应用层事务。
|
|
17
|
+
class RequestDeadline < Timeout::Error; end
|
|
18
|
+
private_constant :RequestDeadline
|
|
19
|
+
NETWORK_ERRORS = [
|
|
20
|
+
Timeout::Error, EOFError, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError,
|
|
21
|
+
Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :uri
|
|
25
|
+
|
|
26
|
+
# 解析连接、超时和代理配置,并准备持久会话。
|
|
27
|
+
# @param options [Hash] URL、超时、TLS 和代理设置;不会在初始化时连接
|
|
28
|
+
# @raise [Invalid] URL、超时或布尔选项无效
|
|
29
|
+
def initialize(options)
|
|
30
|
+
@options = options.dup.freeze
|
|
31
|
+
validate_boolean_options!
|
|
32
|
+
@uri = parse_uri(options.fetch(:url))
|
|
33
|
+
@timeout = positive_number(options.fetch(:timeout, DEFAULT_TIMEOUT), :timeout)
|
|
34
|
+
@request_timeout = positive_number(options.fetch(:request_timeout, @timeout), :request_timeout)
|
|
35
|
+
@max_response_bytes = options.fetch(:max_response_bytes, DEFAULT_MAX_RESPONSE_BYTES)
|
|
36
|
+
unless @max_response_bytes.is_a?(Integer) && @max_response_bytes.positive?
|
|
37
|
+
raise Invalid, "max_response_bytes must be a positive integer"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@open_timeout = positive_number(options.fetch(:open_timeout, @timeout), :open_timeout)
|
|
41
|
+
@read_timeout = positive_number(options.fetch(:read_timeout, @timeout), :read_timeout)
|
|
42
|
+
@write_timeout = positive_number(options.fetch(:write_timeout, @timeout), :write_timeout)
|
|
43
|
+
@keep_alive_timeout = positive_number(
|
|
44
|
+
options.fetch(:keep_alive_timeout, DEFAULT_KEEP_ALIVE_TIMEOUT), :keep_alive_timeout
|
|
45
|
+
)
|
|
46
|
+
@proxy_uri = proxy_uri(options)
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@http = nil
|
|
49
|
+
@pid = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 串行发送请求,失败时关闭连接但不自动重放写操作。
|
|
53
|
+
# @param body [String] 已序列化的 JSON-RPC 请求
|
|
54
|
+
# @param bearer_token [String, nil] 当前请求的 API 认证凭据
|
|
55
|
+
# @return [String] HTTP 成功响应正文,由 Client 验证 JSON-RPC 协议
|
|
56
|
+
# @raise [TransportError] HTTP 状态失败、连接中断或超时;写入可能已生效
|
|
57
|
+
def request(body, bearer_token: nil)
|
|
58
|
+
request = build_request(body, bearer_token)
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
# Net::HTTP 原生阶段超时仍生效;额外期限只覆盖连接、发送及读取,不重放 POST。
|
|
61
|
+
Timeout.timeout(@request_timeout, RequestDeadline) { read_response(request) }
|
|
62
|
+
rescue TransportError
|
|
63
|
+
disconnect
|
|
64
|
+
raise
|
|
65
|
+
rescue *NETWORK_ERRORS => e
|
|
66
|
+
disconnect
|
|
67
|
+
raise TransportError, "#{e.class}: request failed for #{safe_url}", cause: nil
|
|
68
|
+
rescue StandardError
|
|
69
|
+
disconnect
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 显式关闭当前持久连接。
|
|
75
|
+
# @return [true] 重复关闭也成功,下一次请求按需重新连接
|
|
76
|
+
def close
|
|
77
|
+
@mutex.synchronize { disconnect }
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 返回不包含 URL 凭据和查询串的安全地址。
|
|
82
|
+
# @return [String]
|
|
83
|
+
def safe_url
|
|
84
|
+
port = @uri.port == @uri.default_port ? nil : ":#{@uri.port}"
|
|
85
|
+
"#{@uri.scheme}://#{@uri.host}#{port}#{@uri.path}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 返回不包含代理凭据和请求配置的传输层摘要
|
|
89
|
+
# @return [String]
|
|
90
|
+
# @api private
|
|
91
|
+
def inspect
|
|
92
|
+
"#<#{self.class} url=#{safe_url.inspect} persistent=true>"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# 流式累计解压后的响应字节,避免在检查上限之前缓冲整个正文。
|
|
98
|
+
def read_response(request)
|
|
99
|
+
body = +""
|
|
100
|
+
active_http.request(request) do |response|
|
|
101
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
102
|
+
raise TransportError, "HTTP Error: #{response.code} on #{safe_url}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
response.read_body do |chunk|
|
|
106
|
+
if body.bytesize + chunk.bytesize > @max_response_bytes
|
|
107
|
+
raise TransportError, "HTTP response exceeds max_response_bytes on #{safe_url}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
body << chunk
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
body
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 校验并解析 HTTP 或 HTTPS API 地址。
|
|
117
|
+
def parse_uri(value)
|
|
118
|
+
uri = URI.parse(value)
|
|
119
|
+
unless %w[http https].include?(uri.scheme) && uri.hostname && !uri.hostname.empty? &&
|
|
120
|
+
(1..65_535).cover?(uri.port)
|
|
121
|
+
raise Invalid, "url must be an absolute HTTP or HTTPS URL with a valid port"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
uri.freeze
|
|
125
|
+
rescue URI::InvalidURIError, TypeError
|
|
126
|
+
raise Invalid, "url must be an absolute HTTP or HTTPS URL", cause: nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 防止字符串布尔值改变 TLS 校验或代理开关。
|
|
130
|
+
def validate_boolean_options!
|
|
131
|
+
%i[verify_ssl no_proxy].each do |name|
|
|
132
|
+
next unless @options.key?(name)
|
|
133
|
+
next if [true, false].include?(@options[name])
|
|
134
|
+
|
|
135
|
+
raise Invalid, "#{name} must be true or false"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 把超时配置转换为有限正数。
|
|
140
|
+
def positive_number(value, name)
|
|
141
|
+
number = Float(value)
|
|
142
|
+
raise ArgumentError unless number.finite? && number.positive?
|
|
143
|
+
|
|
144
|
+
number
|
|
145
|
+
rescue ArgumentError, TypeError
|
|
146
|
+
raise Invalid, "#{name} must be a finite number greater than zero", cause: nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 复用标准库的代理发现与排除规则,并拒绝无法兑现的代理协议。
|
|
150
|
+
def proxy_uri(options)
|
|
151
|
+
return if options[:no_proxy]
|
|
152
|
+
|
|
153
|
+
value = options[:proxy]
|
|
154
|
+
uri = value.nil? ? URI::HTTP.build(host: @uri.hostname, port: @uri.port).find_proxy : URI.parse(value)
|
|
155
|
+
return unless uri
|
|
156
|
+
unless uri.is_a?(URI::HTTP) && uri.scheme == "http" && uri.hostname &&
|
|
157
|
+
(1..65_535).cover?(uri.port) && ["", "/"].include?(uri.path) && !uri.query && !uri.fragment
|
|
158
|
+
raise Invalid, "proxy must be an absolute HTTP URL without a path, query or fragment"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
uri.freeze
|
|
162
|
+
rescue URI::InvalidURIError, TypeError
|
|
163
|
+
raise Invalid, "proxy must be an absolute HTTP URL", cause: nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# 复用已启动连接,并在 fork 后放弃继承套接字。
|
|
167
|
+
def active_http
|
|
168
|
+
abandon_inherited_connection if @pid && @pid != Process.pid
|
|
169
|
+
@http ||= build_http
|
|
170
|
+
@http.start unless @http.started?
|
|
171
|
+
@pid = Process.pid
|
|
172
|
+
@http
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# 子进程不能向父进程继承的 TLS 套接字发送 close_notify。
|
|
176
|
+
def abandon_inherited_connection
|
|
177
|
+
@http = nil
|
|
178
|
+
@pid = nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# 创建并配置 Net::HTTP 实例。
|
|
182
|
+
def build_http
|
|
183
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port, *proxy_arguments)
|
|
184
|
+
|
|
185
|
+
http.open_timeout = @open_timeout
|
|
186
|
+
http.read_timeout = @read_timeout
|
|
187
|
+
http.write_timeout = @write_timeout
|
|
188
|
+
http.keep_alive_timeout = @keep_alive_timeout
|
|
189
|
+
http.max_retries = 0
|
|
190
|
+
configure_tls(http)
|
|
191
|
+
http
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# 向 Net::HTTP 传入已解析代理,避免二次环境发现;凭据使用 URI 百分号解码。
|
|
195
|
+
def proxy_arguments
|
|
196
|
+
return [nil] unless @proxy_uri
|
|
197
|
+
|
|
198
|
+
[
|
|
199
|
+
@proxy_uri.hostname,
|
|
200
|
+
@proxy_uri.port,
|
|
201
|
+
@proxy_uri.user && URI::DEFAULT_PARSER.unescape(@proxy_uri.user),
|
|
202
|
+
@proxy_uri.password && URI::DEFAULT_PARSER.unescape(@proxy_uri.password)
|
|
203
|
+
]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# 配置 HTTPS;证书校验默认关闭,按调用方选项开启。
|
|
207
|
+
def configure_tls(http)
|
|
208
|
+
return unless @uri.scheme == "https"
|
|
209
|
+
|
|
210
|
+
http.use_ssl = true
|
|
211
|
+
http.verify_mode = @options[:verify_ssl] == true ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
212
|
+
http.ca_file = @options[:ca_file] if @options[:ca_file]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 构建 JSON-RPC POST,并在需要时写入 Bearer 或 Basic 认证。
|
|
216
|
+
def build_request(body, bearer_token)
|
|
217
|
+
if bearer_token && @options[:http_user]
|
|
218
|
+
raise Invalid, "HTTP Basic authentication cannot be combined with Bearer authentication"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
222
|
+
request["Content-Type"] = "application/json-rpc"
|
|
223
|
+
request["Accept"] = "application/json"
|
|
224
|
+
request["Authorization"] = "Bearer #{bearer_token}" if bearer_token
|
|
225
|
+
request.basic_auth(@options[:http_user], @options[:http_password]) if @options[:http_user]
|
|
226
|
+
request.body = body
|
|
227
|
+
request
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# 安全结束连接并清理当前进程的会话状态。
|
|
231
|
+
def disconnect
|
|
232
|
+
@http.finish if @pid == Process.pid && @http&.started?
|
|
233
|
+
rescue *NETWORK_ERRORS
|
|
234
|
+
nil
|
|
235
|
+
ensure
|
|
236
|
+
@http = nil
|
|
237
|
+
@pid = nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "active_support/parameter_filter"
|
|
5
|
+
|
|
6
|
+
class ZabbixManager
|
|
7
|
+
# 日志与诊断数据的脱敏边界;过滤已知敏感字段、认证头和 URL 凭据。
|
|
8
|
+
# 自由文本过滤不能识别任意秘密,因此客户端不记录请求参数或服务端原始错误文本。
|
|
9
|
+
module LogSanitizer
|
|
10
|
+
REDACTED = "[FILTERED]"
|
|
11
|
+
MAX_STRING_LENGTH = 2_000
|
|
12
|
+
SENSITIVE_KEY_NAMES = "auth|authorization|api_token|community|cookie|http_password|password|passwd|" \
|
|
13
|
+
"privatekey|secret|sessionid|snmp_community|snmpv3_authpassphrase|" \
|
|
14
|
+
"snmpv3_privpassphrase|tls_psk|tls_psk_identity|token"
|
|
15
|
+
SENSITIVE_KEYS = /\A(?:#{SENSITIVE_KEY_NAMES})\z/i
|
|
16
|
+
FILTER = ActiveSupport::ParameterFilter.new(
|
|
17
|
+
[SENSITIVE_KEYS, ->(_key, value) { value.replace(sanitize_string(value)) if value.is_a?(String) }]
|
|
18
|
+
).freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# 递归脱敏结构化对象,并限制字符串长度。
|
|
23
|
+
def sanitize(value)
|
|
24
|
+
FILTER.filter_param("value", value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 移除 URL 中的用户名、密码、查询串和片段。
|
|
28
|
+
def sanitize_url(value)
|
|
29
|
+
uri = URI.parse(value.to_s)
|
|
30
|
+
uri.user = nil
|
|
31
|
+
uri.password = nil
|
|
32
|
+
uri.query = nil
|
|
33
|
+
uri.fragment = nil
|
|
34
|
+
uri.to_s
|
|
35
|
+
rescue URI::InvalidURIError
|
|
36
|
+
REDACTED
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 清理字符串中的认证头和常见敏感键值。
|
|
40
|
+
def sanitize_string(value)
|
|
41
|
+
sanitized = value.dup
|
|
42
|
+
sanitized.gsub!(/\b(Bearer|Basic)\s+[^\s,;]+/i, "\\1 #{REDACTED}")
|
|
43
|
+
sanitized.gsub!(
|
|
44
|
+
/((?:authorization)["']?\s*(?:=>|:|=)\s*)[^,;}\]]+/i,
|
|
45
|
+
"\\1#{REDACTED}"
|
|
46
|
+
)
|
|
47
|
+
sanitized.gsub!(
|
|
48
|
+
/((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*)(["'])(.*?)\2/i,
|
|
49
|
+
"\\1\\2#{REDACTED}\\2"
|
|
50
|
+
)
|
|
51
|
+
sanitized.gsub!(
|
|
52
|
+
/((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*)(?!["'])[^,;}\]]+/i,
|
|
53
|
+
"\\1#{REDACTED}"
|
|
54
|
+
)
|
|
55
|
+
sanitized.length > MAX_STRING_LENGTH ? "#{sanitized[0, MAX_STRING_LENGTH]}...[TRUNCATED]" : sanitized
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
class Monitoring
|
|
5
|
+
# 通过原生 API 编排设备;调用方保存 managed 回执,本对象不持久化任何状态。
|
|
6
|
+
class Device
|
|
7
|
+
ROUTING_FIELDS = %i[monitored_by proxyid proxy_hostid proxy_groupid].freeze
|
|
8
|
+
RECEIPT_FIELDS = %i[group_ids template_ids tag_names].freeze
|
|
9
|
+
|
|
10
|
+
# @param attributes [Hash] 原生主机字段及 enabled、proxy、proxy_group、snmp、managed
|
|
11
|
+
# @option attributes [Array<String, Hash>] groups 群组名称或包含 groupid 的 Hash
|
|
12
|
+
# @option attributes [Array<String, Hash>] templates 模板技术名或包含 templateid 的 Hash
|
|
13
|
+
# @option attributes [Hash] snmp v1/v2c 的 ip 或 dns、community 及可选 port、macro、interfaceid
|
|
14
|
+
# @option attributes [Hash] managed 上次回执中的 group_ids、template_ids、tag_names。
|
|
15
|
+
# 显式期望的成员进入本次管理范围;同名但值不同的未管理标签会拒绝覆盖。
|
|
16
|
+
# @raise [Invalid] 输入不完整、冲突或类型错误;此阶段不访问远端
|
|
17
|
+
def initialize(attributes)
|
|
18
|
+
@attributes = Validation.hash!(attributes, "device").deep_dup
|
|
19
|
+
Validation.text!(@attributes[:host], "device.host")
|
|
20
|
+
unless Validation::HOST_NAME.match?(@attributes[:host])
|
|
21
|
+
raise Invalid, "device.host contains unsupported characters"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Validation.text!(@attributes[:name], "device.name") if @attributes.key?(:name)
|
|
25
|
+
enum!(@attributes[:inventory_mode], [-1, 0, 1], "inventory_mode") if @attributes.key?(:inventory_mode)
|
|
26
|
+
validate_status!
|
|
27
|
+
@managed = normalize_managed(@attributes.key?(:managed) ? @attributes.delete(:managed) : {})
|
|
28
|
+
validate_collections!
|
|
29
|
+
validate_routing!
|
|
30
|
+
prepare_snmp!
|
|
31
|
+
validate_interfaces!
|
|
32
|
+
validate_macros!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] 设备的稳定技术主机名
|
|
36
|
+
def identity
|
|
37
|
+
@attributes.fetch(:host).dup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash] 可安全放入批量结果的设备身份,不含接口和宏凭据
|
|
41
|
+
def safe_identity
|
|
42
|
+
@attributes.slice(:host, :name).compact.deep_dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] 不包含凭据的调试标识
|
|
46
|
+
def inspect
|
|
47
|
+
"#<#{self.class} #{safe_identity.inspect}>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 先完成名称解析、版本与接口归属校验,再执行写入。多次 API 写入不是远端事务。
|
|
51
|
+
# 失败的写入不重试、不回滚;调用方应保留异常并重新读取远端状态。
|
|
52
|
+
# @param manager [ZabbixManager] 已认证的共享连接
|
|
53
|
+
# @return [Hash] hostid、enabled 及 managed(包含 group_ids、template_ids 和 tag_names)
|
|
54
|
+
# 停用且主机不存在时 hostid 为 nil,且不执行创建。
|
|
55
|
+
def reconcile(manager)
|
|
56
|
+
existing = find_existing(manager)
|
|
57
|
+
unless @enabled
|
|
58
|
+
manager.hosts.set_status([existing.fetch("hostid")], enabled: false) if existing
|
|
59
|
+
return receipt(existing&.fetch("hostid"), @managed)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
validate_version!(manager.client.api_version)
|
|
63
|
+
attributes, managed = resolve_attributes(manager, existing)
|
|
64
|
+
unless existing
|
|
65
|
+
validate_creation!(manager, attributes)
|
|
66
|
+
# 仅协调创建;不嵌套接口锁,也不承诺跨进程或远端事务隔离。
|
|
67
|
+
outcome, value = manager.client.with_upsert_lock("host-create:#{identity}") do
|
|
68
|
+
current = find_existing(manager)
|
|
69
|
+
current ? [:existing, current] : [:created, manager.hosts.create(attributes)]
|
|
70
|
+
end
|
|
71
|
+
return receipt(value, managed) if outcome == :created
|
|
72
|
+
|
|
73
|
+
existing = value
|
|
74
|
+
attributes, managed = resolve_attributes(manager, existing)
|
|
75
|
+
end
|
|
76
|
+
update_existing(manager, existing, attributes)
|
|
77
|
+
receipt(existing.fetch("hostid"), managed)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def validate_status!
|
|
83
|
+
supplied = @attributes.key?(:enabled)
|
|
84
|
+
enabled = @attributes.delete(:enabled)
|
|
85
|
+
if supplied && enabled != true && enabled != false
|
|
86
|
+
raise Invalid, "device.enabled must be true or false"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if @attributes.key?(:status)
|
|
90
|
+
status = enum!(@attributes[:status], [0, 1], "device.status")
|
|
91
|
+
unless enabled.nil? || enabled == status.zero?
|
|
92
|
+
raise Invalid, "device.enabled and status disagree"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
enabled = status.zero?
|
|
96
|
+
end
|
|
97
|
+
@enabled = enabled.nil? ? true : enabled
|
|
98
|
+
@attributes[:status] = @enabled ? 0 : 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_managed(value)
|
|
102
|
+
managed = Validation.hash!(value, "managed")
|
|
103
|
+
raise Invalid, "unknown managed field" if (managed.keys - RECEIPT_FIELDS).any?
|
|
104
|
+
|
|
105
|
+
RECEIPT_FIELDS.to_h do |field|
|
|
106
|
+
values = Validation.array!(managed.fetch(field, []), "managed.#{field}").map do |item|
|
|
107
|
+
field == :tag_names ? Validation.text!(item, "managed.tag_names") :
|
|
108
|
+
Validation.positive_id!(item, "managed.#{field}").to_s
|
|
109
|
+
end
|
|
110
|
+
[field, values.uniq]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_collections!
|
|
115
|
+
{ groups: :groupid, templates: :templateid }.each do |field, id|
|
|
116
|
+
next unless @attributes.key?(field)
|
|
117
|
+
|
|
118
|
+
@attributes[field] = Validation.array!(@attributes[field], field).map do |reference|
|
|
119
|
+
if reference.is_a?(String)
|
|
120
|
+
Validation.text!(reference, field)
|
|
121
|
+
else
|
|
122
|
+
value = Validation.hash!(reference, field)
|
|
123
|
+
raise Invalid, "#{field} references require only #{id}" unless value.keys == [id]
|
|
124
|
+
|
|
125
|
+
{ id => Validation.positive_id!(value[id], id).to_s }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
if @attributes.key?(:inventory)
|
|
130
|
+
inventory = Validation.hash!(@attributes[:inventory], "inventory")
|
|
131
|
+
raise Invalid, "inventory values must be strings" unless inventory.values.all? { |value| value.is_a?(String) }
|
|
132
|
+
end
|
|
133
|
+
return unless @attributes.key?(:tags)
|
|
134
|
+
|
|
135
|
+
@attributes[:tags] = Validation.array!(@attributes[:tags], "tags").map do |tag|
|
|
136
|
+
value = Validation.hash!(tag, "tag")
|
|
137
|
+
raise Invalid, "tags accept only tag and value" if (value.keys - %i[tag value]).any?
|
|
138
|
+
|
|
139
|
+
Validation.text!(value[:tag], "tag.tag")
|
|
140
|
+
raise Invalid, "tag.value must be a string" unless value.fetch(:value, "").is_a?(String)
|
|
141
|
+
|
|
142
|
+
{ tag: value[:tag], value: value.fetch(:value, "") }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_routing!
|
|
147
|
+
convenience = @attributes.keys & %i[proxy proxy_group]
|
|
148
|
+
if convenience.length > 1 || (convenience.any? && (@attributes.keys & ROUTING_FIELDS).any?)
|
|
149
|
+
raise Invalid, "choose one proxy routing reference"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
convenience.each do |field|
|
|
153
|
+
value = @attributes[field]
|
|
154
|
+
next Validation.text!(value, field) if value.is_a?(String)
|
|
155
|
+
|
|
156
|
+
id = field == :proxy ? :proxyid : :proxy_groupid
|
|
157
|
+
reference = Validation.hash!(value, field)
|
|
158
|
+
raise Invalid, "#{field} requires only #{id}" unless reference.keys == [id]
|
|
159
|
+
|
|
160
|
+
Validation.positive_id!(reference[id], id)
|
|
161
|
+
@attributes[field] = reference
|
|
162
|
+
end
|
|
163
|
+
(%i[proxyid proxy_hostid proxy_groupid] & @attributes.keys).each do |id|
|
|
164
|
+
Validation.positive_id!(@attributes[id], id)
|
|
165
|
+
end
|
|
166
|
+
return unless @attributes.key?(:monitored_by)
|
|
167
|
+
|
|
168
|
+
mode = enum!(@attributes[:monitored_by], [0, 1, 2], "monitored_by")
|
|
169
|
+
required = { 1 => :proxyid, 2 => :proxy_groupid }[mode]
|
|
170
|
+
Validation.positive_id!(@attributes[required], required) if required
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def prepare_snmp!
|
|
174
|
+
return unless @attributes.key?(:snmp)
|
|
175
|
+
raise Invalid, "snmp and interfaces cannot be combined" if @attributes.key?(:interfaces)
|
|
176
|
+
|
|
177
|
+
snmp = Validation.hash!(@attributes.delete(:snmp), "snmp")
|
|
178
|
+
allowed = %i[ip dns community macro port version bulk interfaceid]
|
|
179
|
+
raise Invalid, "unknown snmp field" if (snmp.keys - allowed).any?
|
|
180
|
+
raise Invalid, "snmp requires exactly one of ip or dns" unless (snmp.keys & %i[ip dns]).one?
|
|
181
|
+
|
|
182
|
+
endpoint = snmp.key?(:ip) ? :ip : :dns
|
|
183
|
+
Validation.text!(snmp[endpoint], "snmp.#{endpoint}")
|
|
184
|
+
Validation.text!(snmp[:community], "snmp.community")
|
|
185
|
+
macro = snmp.fetch(:macro, "{$SNMP_COMMUNITY}")
|
|
186
|
+
@snmp = {
|
|
187
|
+
type: 2, main: 1, useip: endpoint == :ip ? 1 : 0,
|
|
188
|
+
ip: snmp.fetch(:ip, ""), dns: snmp.fetch(:dns, ""), port: snmp.fetch(:port, "161").to_s,
|
|
189
|
+
details: { version: enum!(snmp.fetch(:version, 2), [1, 2], "snmp.version"),
|
|
190
|
+
bulk: enum!(snmp.fetch(:bulk, 1), [0, 1], "snmp.bulk"), community: macro }
|
|
191
|
+
}
|
|
192
|
+
if snmp.key?(:interfaceid)
|
|
193
|
+
@snmp[:interfaceid] = Validation.positive_id!(snmp[:interfaceid], "snmp.interfaceid")
|
|
194
|
+
end
|
|
195
|
+
@attributes[:interfaces] = [@snmp]
|
|
196
|
+
macros = Validation.array!(@attributes.fetch(:macros, []), "macros")
|
|
197
|
+
@attributes[:macros] = macros + [{ macro: macro, value: snmp[:community], type: 1 }]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_interfaces!
|
|
201
|
+
return unless @attributes.key?(:interfaces)
|
|
202
|
+
|
|
203
|
+
interfaces = Validation.array!(@attributes[:interfaces], "interfaces").map do |interface|
|
|
204
|
+
value = Validation.hash!(interface, "interface")
|
|
205
|
+
Validation.positive_id!(value[:interfaceid], "interfaceid") if value.key?(:interfaceid)
|
|
206
|
+
value
|
|
207
|
+
end
|
|
208
|
+
@attributes[:interfaces] = HostInterfaces.new(nil).validate(interfaces)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_macros!
|
|
212
|
+
return unless @attributes.key?(:macros)
|
|
213
|
+
|
|
214
|
+
@attributes[:macros] = Validation.array!(@attributes[:macros], "macros").map do |macro|
|
|
215
|
+
value = Validation.hash!(macro, "macro")
|
|
216
|
+
raise Invalid, "macros accept macro, value, type and description" if
|
|
217
|
+
(value.keys - %i[macro value type description]).any?
|
|
218
|
+
unless value[:macro].is_a?(String) && value[:macro].match?(/\A\{\$[^{}\r\n]+\}\z/)
|
|
219
|
+
raise Invalid, "macro name must use {$NAME} syntax"
|
|
220
|
+
end
|
|
221
|
+
raise Invalid, "macro value must be a string" unless value[:value].is_a?(String)
|
|
222
|
+
|
|
223
|
+
enum!(value.fetch(:type, 0), [0, 1, 2], "macro.type")
|
|
224
|
+
value
|
|
225
|
+
end
|
|
226
|
+
names = @attributes[:macros].pluck(:macro)
|
|
227
|
+
raise Invalid, "duplicate macro name" unless names.uniq == names
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def enum!(value, allowed, name)
|
|
231
|
+
unless (value.is_a?(String) || value.is_a?(Integer)) && allowed.map(&:to_s).include?(value.to_s)
|
|
232
|
+
raise Invalid, "#{name} must be one of #{allowed.join(', ')}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
value.to_i
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def validate_version!(version)
|
|
239
|
+
version = Gem::Version.new(version)
|
|
240
|
+
modern = version >= Gem::Version.new("7.0")
|
|
241
|
+
unsupported = modern ? [:proxy_hostid] : %i[proxy_group proxy_groupid proxyid monitored_by]
|
|
242
|
+
raise Invalid, "proxy routing is unsupported by this Zabbix version" if (@attributes.keys & unsupported).any?
|
|
243
|
+
|
|
244
|
+
if modern && (@attributes.keys & %i[proxyid proxy_groupid]).any?
|
|
245
|
+
mode = @attributes[:monitored_by].to_s
|
|
246
|
+
expected = { "1" => :proxyid, "2" => :proxy_groupid }[mode]
|
|
247
|
+
actual = @attributes.keys & %i[proxyid proxy_groupid]
|
|
248
|
+
raise Invalid, "proxy ID must match monitored_by" unless actual == [expected]
|
|
249
|
+
end
|
|
250
|
+
Array(@attributes[:macros]).each do |macro|
|
|
251
|
+
minimum = { 1 => "5.0", 2 => "5.2" }[macro.fetch(:type, 0).to_i]
|
|
252
|
+
if minimum && version < Gem::Version.new(minimum)
|
|
253
|
+
raise Invalid, "macro type requires Zabbix #{minimum} or newer"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def find_existing(manager)
|
|
259
|
+
modern = Gem::Version.new(manager.client.api_version) >= Gem::Version.new("7.0")
|
|
260
|
+
group_field = modern ? :selectHostGroups : :selectGroups
|
|
261
|
+
hosts = manager.hosts.get_raw(
|
|
262
|
+
filter: { host: identity }, output: %w[hostid host], group_field => ["groupid"],
|
|
263
|
+
selectParentTemplates: ["templateid"], selectTags: "extend", selectInterfaces: "extend"
|
|
264
|
+
)
|
|
265
|
+
unless hosts.is_a?(Array) && hosts.all? { |host| host.is_a?(Hash) && host["host"] == identity }
|
|
266
|
+
raise ProtocolError, "invalid device lookup response"
|
|
267
|
+
end
|
|
268
|
+
raise Conflict, "multiple hosts match device identity" if hosts.length > 1
|
|
269
|
+
|
|
270
|
+
hosts.first&.tap do |host|
|
|
271
|
+
remote_id!(host["hostid"], "hostid")
|
|
272
|
+
host["groups"] = host.fetch("hostgroups") if modern && host.key?("hostgroups")
|
|
273
|
+
%w[groups parentTemplates tags interfaces].each do |field|
|
|
274
|
+
unless host[field].is_a?(Array) && host[field].all? { |entry| entry.is_a?(Hash) }
|
|
275
|
+
raise ProtocolError, "device response is missing #{field}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def resolve_attributes(manager, existing)
|
|
282
|
+
attributes = @attributes.except(:proxy, :proxy_group).deep_dup
|
|
283
|
+
managed = @managed.deep_dup
|
|
284
|
+
{ groups: [:host_groups, :groupid, :name, :group_ids, "groups"],
|
|
285
|
+
templates: [:templates, :templateid, :host, :template_ids, "parentTemplates"] }.each do |field, mapping|
|
|
286
|
+
next unless attributes.key?(field)
|
|
287
|
+
|
|
288
|
+
resource, id_key, name_key, receipt_key, remote_key = mapping
|
|
289
|
+
ids = attributes[field].map do |reference|
|
|
290
|
+
reference.is_a?(Hash) ? reference.fetch(id_key).to_s :
|
|
291
|
+
resolve_name(manager.public_send(resource), name_key, reference).to_s
|
|
292
|
+
end.uniq
|
|
293
|
+
current = existing ? existing.fetch(remote_key).map { |item| remote_id!(item[id_key.to_s], id_key) } : []
|
|
294
|
+
attributes[field] = ((current - @managed[receipt_key]) | ids).map { |id| { id_key => id } }
|
|
295
|
+
managed[receipt_key] = ids
|
|
296
|
+
end
|
|
297
|
+
if attributes.key?(:tags)
|
|
298
|
+
tags = existing ? existing.fetch("tags").map { |tag| remote_tag!(tag) } : []
|
|
299
|
+
desired = attributes[:tags]
|
|
300
|
+
unowned = tags.reject { |tag| @managed[:tag_names].include?(tag[:tag]) }
|
|
301
|
+
names = desired.pluck(:tag)
|
|
302
|
+
if unowned.any? { |tag| names.include?(tag[:tag]) && !desired.include?(tag) }
|
|
303
|
+
raise Conflict, "desired tag name has an unmanaged value"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
attributes[:tags] = unowned + desired
|
|
307
|
+
attributes[:tags].uniq!
|
|
308
|
+
managed[:tag_names] = @attributes[:tags].pluck(:tag).uniq
|
|
309
|
+
end
|
|
310
|
+
resolve_routing(manager, attributes)
|
|
311
|
+
if @snmp && existing && !@snmp.key?(:interfaceid)
|
|
312
|
+
main = existing.fetch("interfaces").select { |item| item["type"].to_s == "2" && item["main"].to_s == "1" }
|
|
313
|
+
raise Conflict, "multiple main SNMP interfaces; provide interfaceid" if main.length > 1
|
|
314
|
+
|
|
315
|
+
attributes[:interfaces].first[:interfaceid] =
|
|
316
|
+
remote_id!(main.first["interfaceid"], "interfaceid") if main.one?
|
|
317
|
+
end
|
|
318
|
+
raise Invalid, "groups cannot be empty" if attributes.key?(:groups) && attributes[:groups].empty?
|
|
319
|
+
|
|
320
|
+
[attributes, managed]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def resolve_name(resource, field, name)
|
|
324
|
+
resource.get_id(field => name) || raise(ApiError, "required #{resource.method_name} was not found")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def resolve_routing(manager, attributes)
|
|
328
|
+
%i[proxy proxy_group].each do |field|
|
|
329
|
+
next unless @attributes.key?(field)
|
|
330
|
+
|
|
331
|
+
reference = @attributes[field]
|
|
332
|
+
resource = field == :proxy ? manager.proxies : manager.proxy_groups
|
|
333
|
+
id_key = field == :proxy ? :proxyid : :proxy_groupid
|
|
334
|
+
id = if reference.is_a?(Hash)
|
|
335
|
+
reference.fetch(id_key)
|
|
336
|
+
else
|
|
337
|
+
resolve_name(resource, resource.identify.to_sym, reference)
|
|
338
|
+
end
|
|
339
|
+
if Gem::Version.new(manager.client.api_version) < Gem::Version.new("7.0")
|
|
340
|
+
attributes[:proxy_hostid] = id
|
|
341
|
+
else
|
|
342
|
+
attributes[:monitored_by] = field == :proxy ? 1 : 2
|
|
343
|
+
attributes[id_key] = id
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_creation!(manager, attributes)
|
|
349
|
+
raise Invalid, "groups are required when creating a device" if attributes.fetch(:groups, []).empty?
|
|
350
|
+
raise Invalid, "interfaces are required when creating a device" if attributes.fetch(:interfaces, []).empty?
|
|
351
|
+
if attributes[:interfaces].any? { |interface| interface.key?(:interfaceid) }
|
|
352
|
+
raise Invalid, "new devices cannot reference existing interface IDs"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
manager.host_interfaces.validate_for_create(attributes[:interfaces])
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def update_existing(manager, existing, attributes)
|
|
359
|
+
hostid = existing.fetch("hostid")
|
|
360
|
+
interfaces = attributes.delete(:interfaces)
|
|
361
|
+
macros = attributes.delete(:macros) || []
|
|
362
|
+
manager.host_interfaces.validate_for_host(hostid: hostid, interfaces: interfaces) if interfaces&.any?
|
|
363
|
+
macro_plan = macros.map do |macro|
|
|
364
|
+
[macro, manager.user_macros.get_id(hostid: hostid, macro: macro.fetch(:macro))]
|
|
365
|
+
end
|
|
366
|
+
macro_plan.each do |macro, id|
|
|
367
|
+
id ? manager.user_macros.update(macro.merge(hostmacroid: id)) :
|
|
368
|
+
manager.user_macros.create(macro.merge(hostid: hostid))
|
|
369
|
+
end
|
|
370
|
+
manager.host_interfaces.reconcile_for_host(hostid: hostid, interfaces: interfaces) if interfaces&.any?
|
|
371
|
+
manager.hosts.update(attributes.merge(hostid: hostid))
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def remote_id!(value, field)
|
|
375
|
+
Validation.positive_id!(value, field).to_s
|
|
376
|
+
rescue Invalid
|
|
377
|
+
raise ProtocolError, "invalid device response #{field}", cause: nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def remote_tag!(tag)
|
|
381
|
+
unless tag["tag"].is_a?(String) && tag["value"].is_a?(String)
|
|
382
|
+
raise ProtocolError, "invalid device tag response"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
{ tag: tag["tag"], value: tag["value"] }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def receipt(hostid, managed)
|
|
389
|
+
{ hostid: hostid&.to_i, enabled: @enabled, managed: managed.deep_dup }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|