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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -9
  3. data/LICENSE +1 -1
  4. data/README.md +241 -24
  5. data/lib/zabbix_manager/classes/actions.rb +19 -17
  6. data/lib/zabbix_manager/classes/applications.rb +11 -22
  7. data/lib/zabbix_manager/classes/configurations.rb +20 -10
  8. data/lib/zabbix_manager/classes/discovery_rules.rb +23 -0
  9. data/lib/zabbix_manager/classes/errors.rb +18 -27
  10. data/lib/zabbix_manager/classes/events.rb +8 -3
  11. data/lib/zabbix_manager/classes/graphs.rb +48 -37
  12. data/lib/zabbix_manager/classes/host_groups.rb +64 -0
  13. data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
  14. data/lib/zabbix_manager/classes/hosts.rb +232 -76
  15. data/lib/zabbix_manager/classes/http_tests.rb +32 -0
  16. data/lib/zabbix_manager/classes/items.rb +206 -72
  17. data/lib/zabbix_manager/classes/maintenance.rb +8 -3
  18. data/lib/zabbix_manager/classes/media_types.rb +18 -0
  19. data/lib/zabbix_manager/classes/problems.rb +51 -62
  20. data/lib/zabbix_manager/classes/proxies.rb +45 -14
  21. data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
  22. data/lib/zabbix_manager/classes/roles.rb +46 -41
  23. data/lib/zabbix_manager/classes/screens.rb +36 -24
  24. data/lib/zabbix_manager/classes/scripts.rb +16 -12
  25. data/lib/zabbix_manager/classes/server.rb +8 -1
  26. data/lib/zabbix_manager/classes/templates.rb +26 -51
  27. data/lib/zabbix_manager/classes/triggers.rb +251 -67
  28. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  29. data/lib/zabbix_manager/classes/user_macros.rb +110 -0
  30. data/lib/zabbix_manager/classes/users.rb +16 -23
  31. data/lib/zabbix_manager/classes/value_maps.rb +52 -0
  32. data/lib/zabbix_manager/client.rb +183 -137
  33. data/lib/zabbix_manager/configuration.rb +107 -0
  34. data/lib/zabbix_manager/http_transport.rb +240 -0
  35. data/lib/zabbix_manager/log_sanitizer.rb +58 -0
  36. data/lib/zabbix_manager/monitoring/device.rb +393 -0
  37. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  38. data/lib/zabbix_manager/monitoring/line.rb +175 -0
  39. data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
  40. data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
  41. data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
  42. data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
  43. data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
  44. data/lib/zabbix_manager/monitoring/validation.rb +92 -0
  45. data/lib/zabbix_manager/monitoring.rb +265 -0
  46. data/lib/zabbix_manager/resource.rb +235 -0
  47. data/lib/zabbix_manager/traffic.rb +238 -0
  48. data/lib/zabbix_manager/version.rb +1 -1
  49. data/lib/zabbix_manager.rb +110 -145
  50. data/zabbix_manager.gemspec +12 -15
  51. metadata +67 -30
  52. data/lib/zabbix_manager/basic/basic_alias.rb +0 -20
  53. data/lib/zabbix_manager/basic/basic_func.rb +0 -84
  54. data/lib/zabbix_manager/basic/basic_init.rb +0 -35
  55. data/lib/zabbix_manager/basic/basic_logic.rb +0 -195
  56. data/lib/zabbix_manager/classes/drules.rb +0 -41
  57. data/lib/zabbix_manager/classes/hostgroups.rb +0 -20
  58. data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -29
  59. data/lib/zabbix_manager/classes/httptests.rb +0 -40
  60. data/lib/zabbix_manager/classes/mediatypes.rb +0 -79
  61. data/lib/zabbix_manager/classes/unusable.rb +0 -11
  62. data/lib/zabbix_manager/classes/usergroups.rb +0 -53
  63. data/lib/zabbix_manager/classes/usermacros.rb +0 -136
  64. 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