zabbix_manager 5.1.6 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/README.md +69 -46
  4. data/lib/zabbix_manager/classes/actions.rb +3 -11
  5. data/lib/zabbix_manager/classes/applications.rb +2 -8
  6. data/lib/zabbix_manager/classes/configurations.rb +4 -9
  7. data/lib/zabbix_manager/classes/{drules.rb → discovery_rules.rb} +2 -8
  8. data/lib/zabbix_manager/classes/errors.rb +3 -0
  9. data/lib/zabbix_manager/classes/events.rb +2 -1
  10. data/lib/zabbix_manager/classes/graphs.rb +19 -15
  11. data/lib/zabbix_manager/classes/{hostgroups.rb → host_groups.rb} +5 -11
  12. data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
  13. data/lib/zabbix_manager/classes/hosts.rb +135 -89
  14. data/lib/zabbix_manager/classes/{httptests.rb → http_tests.rb} +2 -8
  15. data/lib/zabbix_manager/classes/items.rb +91 -87
  16. data/lib/zabbix_manager/classes/maintenance.rb +2 -1
  17. data/lib/zabbix_manager/classes/{mediatypes.rb → media_types.rb} +2 -7
  18. data/lib/zabbix_manager/classes/problems.rb +11 -40
  19. data/lib/zabbix_manager/classes/proxies.rb +5 -15
  20. data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
  21. data/lib/zabbix_manager/classes/roles.rb +13 -19
  22. data/lib/zabbix_manager/classes/screens.rb +11 -30
  23. data/lib/zabbix_manager/classes/scripts.rb +2 -8
  24. data/lib/zabbix_manager/classes/server.rb +1 -0
  25. data/lib/zabbix_manager/classes/templates.rb +2 -65
  26. data/lib/zabbix_manager/classes/triggers.rb +42 -45
  27. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  28. data/lib/zabbix_manager/classes/user_macros.rb +110 -0
  29. data/lib/zabbix_manager/classes/users.rb +15 -54
  30. data/lib/zabbix_manager/classes/value_maps.rb +52 -0
  31. data/lib/zabbix_manager/client.rb +148 -277
  32. data/lib/zabbix_manager/configuration.rb +107 -0
  33. data/lib/zabbix_manager/http_transport.rb +145 -97
  34. data/lib/zabbix_manager/log_sanitizer.rb +9 -19
  35. data/lib/zabbix_manager/monitoring/device.rb +393 -0
  36. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  37. data/lib/zabbix_manager/monitoring/line.rb +175 -0
  38. data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
  39. data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
  40. data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
  41. data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
  42. data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
  43. data/lib/zabbix_manager/monitoring/validation.rb +92 -0
  44. data/lib/zabbix_manager/monitoring.rb +216 -642
  45. data/lib/zabbix_manager/resource.rb +235 -0
  46. data/lib/zabbix_manager/traffic.rb +238 -0
  47. data/lib/zabbix_manager/version.rb +1 -1
  48. data/lib/zabbix_manager.rb +66 -179
  49. data/zabbix_manager.gemspec +6 -22
  50. metadata +41 -190
  51. data/lib/zabbix_manager/basic/basic_alias.rb +0 -42
  52. data/lib/zabbix_manager/basic/basic_func.rb +0 -55
  53. data/lib/zabbix_manager/basic/basic_init.rb +0 -50
  54. data/lib/zabbix_manager/basic/basic_logic.rb +0 -239
  55. data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -189
  56. data/lib/zabbix_manager/classes/usergroups.rb +0 -75
  57. data/lib/zabbix_manager/classes/usermacros.rb +0 -222
  58. data/lib/zabbix_manager/classes/valuemaps.rb +0 -26
@@ -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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 根据 API 版本,将已校验的主机、监控项键和数值转换为触发器表达式。
6
+ # @api private
7
+ class Expressions
8
+ attr_reader :version
9
+
10
+ def initialize(client, host)
11
+ version = client.api_version.to_s
12
+ unless version.match?(/\A\d+\.\d+(?:\.\d+)?\z/)
13
+ raise Invalid, "Zabbix API version must be a numeric release version"
14
+ end
15
+
16
+ @host = host
17
+ @version = Gem::Version.new(version)
18
+ @modern = @version >= Gem::Version.new("5.4")
19
+ end
20
+
21
+ # 只接受上层已校验的函数、运算符、窗口和主机;这里再次校验嵌入表达式的 item key。
22
+ # last 不带窗口;其他函数按 5.4 前后语法生成,数值不使用科学计数法。
23
+ # @return [String] 一个可组合的比较表达式
24
+ def compare(key, function, window, operator, value)
25
+ Validation.item_key!(key)
26
+ call = if @modern
27
+ source = "/#{@host}/#{key}"
28
+ function == "last" ? "last(#{source})" : "#{function}(#{source},#{window})"
29
+ else
30
+ "{#{@host}:#{key}.#{function}(#{function == 'last' ? '' : window})}"
31
+ end
32
+ "#{call}#{operator}#{Validation.format_number(value)}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ class ZabbixManager
6
+ class Monitoring
7
+ # 线路清单使用一套明确字段;外部文件列名应由导入方转换。
8
+ # @api private
9
+ class Line
10
+ FIELDS = %i[line_id host host_candidates interface_name capacity_mbps high_water recovery_water
11
+ problem_window recovery_window severity device isp description low_traffic status speed
12
+ reachability_target tags comments event_name opdata].freeze
13
+ METADATA_FIELDS = %i[comments event_name opdata].freeze
14
+ attr_reader :host_reference, :interface_name, :threshold, :low_traffic, :status, :speed,
15
+ :reachability_target, :metadata
16
+
17
+ # 仅解析本地定义,不访问服务器;status/speed 为开关或显式 itemid 选择器。
18
+ # @param attributes [Hash] 规范化后的单端点清单字段
19
+ # @raise [Invalid] 字段、阈值或目标格式不合法
20
+ def initialize(attributes)
21
+ @attributes = Validation.hash!(attributes, "line")
22
+ @attributes.assert_valid_keys(*FIELDS)
23
+ @interface_name = Validation.text!(@attributes[:interface_name], "line interface_name")
24
+ validate_identity!
25
+ @host_reference = build_host_reference
26
+ @threshold = build_threshold
27
+ Thresholds.validate_config!(:bandwidth, @threshold)
28
+ @low_traffic = build_low_traffic if @attributes.key?(:low_traffic)
29
+ @status = item_selection(:status)
30
+ @speed = item_selection(:speed)
31
+ @reachability_target = build_reachability_target if @attributes.key?(:reachability_target)
32
+ @metadata = @attributes.slice(*METADATA_FIELDS)
33
+ @metadata.each do |key, value|
34
+ raise Invalid, "line #{key} must be a string" unless value.is_a?(String)
35
+ end
36
+ rescue ArgumentError => error
37
+ raise Invalid, error.message
38
+ end
39
+
40
+ def identity
41
+ @attributes[:line_id].presence ||
42
+ [host_identity, TrafficItems.interface_identity(interface_name)]
43
+ end
44
+
45
+ def interface(hostid:)
46
+ { name: interface_name, line_id: resolved_id(hostid) }
47
+ end
48
+
49
+ # 显式 line_id 优先;缺省身份由已解析 hostid 和规范接口名组成。
50
+ # @return [String] 用于受管标签的端点身份
51
+ def resolved_id(hostid)
52
+ @attributes[:line_id].presence || "#{hostid}:#{TrafficItems.interface_identity(interface_name)}"
53
+ end
54
+
55
+ def safe_identity
56
+ @attributes.slice(:line_id, :device).merge(interface: interface_name).compact
57
+ end
58
+
59
+ private
60
+
61
+ def host_identity
62
+ host_reference.is_a?(Hash) ? host_reference[:hostid].to_s : host_reference.sort
63
+ end
64
+
65
+ def validate_identity!
66
+ line_id = @attributes[:line_id]
67
+ unless line_id.nil? || line_id.is_a?(String) || line_id.is_a?(Integer)
68
+ raise Invalid, "line_id must be a string or integer"
69
+ end
70
+
71
+ @attributes[:line_id] = line_id.to_s if line_id
72
+ %i[device isp description].each do |name|
73
+ next unless @attributes.key?(name)
74
+
75
+ Validation.text!(@attributes[name], "line #{name}")
76
+ end
77
+ end
78
+
79
+ def build_host_reference
80
+ if @attributes.key?(:host) && @attributes.key?(:host_candidates)
81
+ raise Invalid, "line must supply either host or host_candidates"
82
+ end
83
+
84
+ host = @attributes[:host]
85
+ if host.is_a?(Hash)
86
+ Validation.host!(host)
87
+ return host.slice(:hostid, :host)
88
+ end
89
+
90
+ candidates = @attributes.key?(:host_candidates) ?
91
+ Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
92
+ candidates = candidates.map { |value| Validation.text!(value, "line host candidate").strip }.uniq
93
+ raise Invalid, "line host candidate is required" if candidates.empty?
94
+
95
+ candidates
96
+ end
97
+
98
+ def build_threshold
99
+ capacity = Validation.number!(@attributes[:capacity_mbps], "line capacity_mbps")
100
+ raise Invalid, "line capacity_mbps must be positive" unless capacity.positive?
101
+
102
+ high = water_ratio(@attributes.fetch(:high_water, 0.9), "high_water")
103
+ recovery = water_ratio(@attributes.fetch(:recovery_water, 0.8), "recovery_water")
104
+ raise Invalid, "recovery_water must be lower than high_water" unless recovery < high
105
+
106
+ {
107
+ capacity_bps: capacity * 1_000_000,
108
+ high_percent: high * 100, recovery_percent: recovery * 100,
109
+ window: @attributes.fetch(:problem_window, Thresholds::DEFAULT_WINDOW),
110
+ recovery_window: @attributes.fetch(:recovery_window, "15m"),
111
+ priority: @attributes.fetch(:severity, Thresholds::DEFAULT_PRIORITY),
112
+ description: ["专线流量超阈值", @attributes[:isp], @attributes[:description],
113
+ @attributes[:device], interface_name].compact.join(" | "),
114
+ tags: line_tags
115
+ }
116
+ end
117
+
118
+ def line_tags
119
+ extra = Validation.array!(@attributes.fetch(:tags, []), "line tags").map do |tag|
120
+ tag = Validation.hash!(tag, "line tag")
121
+ tag.assert_valid_keys(:tag, :value)
122
+ Validation.text!(tag[:tag], "line tag name")
123
+ if (Thresholds::MANAGED_TAGS + ["line_id"]).include?(tag[:tag])
124
+ raise Invalid, "line tag #{tag[:tag]} is reserved"
125
+ end
126
+ raise Invalid, "line tag value must be a string" unless tag.fetch(:value, "").is_a?(String)
127
+
128
+ { tag: tag[:tag], value: tag.fetch(:value, "") }
129
+ end
130
+ [{ tag: "category", value: "line_bandwidth" }] +
131
+ @attributes.slice(:isp, :description, :device).map { |name, value| { tag: name.to_s, value: value } } + extra
132
+ end
133
+
134
+ def build_low_traffic
135
+ config = Validation.hash!(@attributes[:low_traffic], "low_traffic")
136
+ config.assert_valid_keys(:below_bps, :recovery_bps, :window, :recovery_window)
137
+ below = Validation.number!(config.fetch(:below_bps, 50), "low_traffic below_bps")
138
+ recovery = Validation.number!(config.fetch(:recovery_bps, 1000), "low_traffic recovery_bps")
139
+ unless below.positive? && recovery > below
140
+ raise Invalid, "low_traffic recovery_bps must be greater than positive below_bps"
141
+ end
142
+
143
+ window = Validation.window!(config.fetch(:window, "5m"))
144
+ { below_bps: below, recovery_bps: recovery, window: window,
145
+ recovery_window: Validation.window!(config.fetch(:recovery_window, window)) }
146
+ end
147
+
148
+ def item_selection(name)
149
+ value = @attributes.fetch(name, false)
150
+ return value if value == true || value == false
151
+
152
+ selection = Validation.hash!(value, name.to_s)
153
+ selection.assert_valid_keys(:itemid)
154
+ Validation.positive_id!(selection[:itemid], "#{name} itemid")
155
+ selection
156
+ end
157
+
158
+ def build_reachability_target
159
+ value = Validation.text!(@attributes[:reachability_target], "reachability_target")
160
+ raise Invalid, "reachability_target must be an IP address without a prefix" if value.include?("/")
161
+
162
+ IPAddr.new(value).to_s
163
+ rescue IPAddr::InvalidAddressError
164
+ raise Invalid, "reachability_target must be an IPv4 or IPv6 address"
165
+ end
166
+
167
+ def water_ratio(value, name)
168
+ ratio = Validation.number!(value, name)
169
+ raise Invalid, "#{name} must be greater than 0 and at most 1" unless ratio.positive? && ratio <= 1
170
+
171
+ ratio
172
+ end
173
+ end
174
+ end
175
+ end