zabbix_manager 5.1.6 → 6.0.1

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 +44 -1
  3. data/README.md +73 -45
  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 +139 -89
  14. data/lib/zabbix_manager/classes/{httptests.rb → http_tests.rb} +2 -8
  15. data/lib/zabbix_manager/classes/items.rb +139 -92
  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 +7 -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 +62 -53
  27. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  28. data/lib/zabbix_manager/classes/user_macros.rb +111 -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 +162 -279
  32. data/lib/zabbix_manager/configuration.rb +115 -0
  33. data/lib/zabbix_manager/http_transport.rb +145 -97
  34. data/lib/zabbix_manager/log_sanitizer.rb +18 -29
  35. data/lib/zabbix_manager/monitoring/device.rb +394 -0
  36. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  37. data/lib/zabbix_manager/monitoring/line.rb +176 -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 +133 -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 +262 -0
  46. data/lib/zabbix_manager/traffic.rb +239 -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,394 @@
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
+ if (value.keys - %i[macro value type description]).any?
217
+ raise Invalid, "macros accept macro, value, type and description"
218
+ end
219
+ unless value[:macro].is_a?(String) && value[:macro].match?(/\A\{\$[^{}\r\n]+\}\z/)
220
+ raise Invalid, "macro name must use {$NAME} syntax"
221
+ end
222
+ raise Invalid, "macro value must be a string" unless value[:value].is_a?(String)
223
+
224
+ enum!(value.fetch(:type, 0), [0, 1, 2], "macro.type")
225
+ value
226
+ end
227
+ names = @attributes[:macros].pluck(:macro)
228
+ raise Invalid, "duplicate macro name" unless names.uniq == names
229
+ end
230
+
231
+ def enum!(value, allowed, name)
232
+ unless (value.is_a?(String) || value.is_a?(Integer)) && allowed.map(&:to_s).include?(value.to_s)
233
+ raise Invalid, "#{name} must be one of #{allowed.join(', ')}"
234
+ end
235
+
236
+ value.to_i
237
+ end
238
+
239
+ def validate_version!(version)
240
+ version = Gem::Version.new(version)
241
+ modern = version >= Gem::Version.new("7.0")
242
+ unsupported = modern ? [:proxy_hostid] : %i[proxy_group proxy_groupid proxyid monitored_by]
243
+ raise Invalid, "proxy routing is unsupported by this Zabbix version" if (@attributes.keys & unsupported).any?
244
+
245
+ if modern && (@attributes.keys & %i[proxyid proxy_groupid]).any?
246
+ mode = @attributes[:monitored_by].to_s
247
+ expected = { "1" => :proxyid, "2" => :proxy_groupid }[mode]
248
+ actual = @attributes.keys & %i[proxyid proxy_groupid]
249
+ raise Invalid, "proxy ID must match monitored_by" unless actual == [expected]
250
+ end
251
+ Array(@attributes[:macros]).each do |macro|
252
+ minimum = { 1 => "5.0", 2 => "5.2" }[macro.fetch(:type, 0).to_i]
253
+ if minimum && version < Gem::Version.new(minimum)
254
+ raise Invalid, "macro type requires Zabbix #{minimum} or newer"
255
+ end
256
+ end
257
+ end
258
+
259
+ def find_existing(manager)
260
+ modern = Gem::Version.new(manager.client.api_version) >= Gem::Version.new("7.0")
261
+ group_field = modern ? :selectHostGroups : :selectGroups
262
+ hosts = manager.hosts.get_raw(
263
+ filter: { host: identity }, output: %w[hostid host], group_field => ["groupid"],
264
+ selectParentTemplates: ["templateid"], selectTags: "extend", selectInterfaces: "extend"
265
+ )
266
+ unless hosts.is_a?(Array) && hosts.all? { |host| host.is_a?(Hash) && host["host"] == identity }
267
+ raise ProtocolError, "invalid device lookup response"
268
+ end
269
+ raise Conflict, "multiple hosts match device identity" if hosts.length > 1
270
+
271
+ hosts.first&.tap do |host|
272
+ remote_id!(host["hostid"], "hostid")
273
+ host["groups"] = host.fetch("hostgroups") if modern && host.key?("hostgroups")
274
+ %w[groups parentTemplates tags interfaces].each do |field|
275
+ unless host[field].is_a?(Array) && host[field].all? { |entry| entry.is_a?(Hash) }
276
+ raise ProtocolError, "device response is missing #{field}"
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ def resolve_attributes(manager, existing)
283
+ attributes = @attributes.except(:proxy, :proxy_group).deep_dup
284
+ managed = @managed.deep_dup
285
+ { groups: [:host_groups, :groupid, :name, :group_ids, "groups"],
286
+ templates: [:templates, :templateid, :host, :template_ids, "parentTemplates"] }.each do |field, mapping|
287
+ next unless attributes.key?(field)
288
+
289
+ resource, id_key, name_key, receipt_key, remote_key = mapping
290
+ ids = attributes[field].map do |reference|
291
+ reference.is_a?(Hash) ? reference.fetch(id_key).to_s :
292
+ resolve_name(manager.public_send(resource), name_key, reference).to_s
293
+ end.uniq
294
+ current = existing ? existing.fetch(remote_key).map { |item| remote_id!(item[id_key.to_s], id_key) } : []
295
+ attributes[field] = ((current - @managed[receipt_key]) | ids).map { |id| { id_key => id } }
296
+ managed[receipt_key] = ids
297
+ end
298
+ if attributes.key?(:tags)
299
+ tags = existing ? existing.fetch("tags").map { |tag| remote_tag!(tag) } : []
300
+ desired = attributes[:tags]
301
+ unowned = tags.reject { |tag| @managed[:tag_names].include?(tag[:tag]) }
302
+ names = desired.pluck(:tag)
303
+ if unowned.any? { |tag| names.include?(tag[:tag]) && !desired.include?(tag) }
304
+ raise Conflict, "desired tag name has an unmanaged value"
305
+ end
306
+
307
+ attributes[:tags] = unowned + desired
308
+ attributes[:tags].uniq!
309
+ managed[:tag_names] = @attributes[:tags].pluck(:tag).uniq
310
+ end
311
+ resolve_routing(manager, attributes)
312
+ if @snmp && existing && !@snmp.key?(:interfaceid)
313
+ main = existing.fetch("interfaces").select { |item| item["type"].to_s == "2" && item["main"].to_s == "1" }
314
+ raise Conflict, "multiple main SNMP interfaces; provide interfaceid" if main.length > 1
315
+
316
+ attributes[:interfaces].first[:interfaceid] =
317
+ remote_id!(main.first["interfaceid"], "interfaceid") if main.one?
318
+ end
319
+ raise Invalid, "groups cannot be empty" if attributes.key?(:groups) && attributes[:groups].empty?
320
+
321
+ [attributes, managed]
322
+ end
323
+
324
+ def resolve_name(resource, field, name)
325
+ resource.get_id(field => name) || raise(ApiError, "required #{resource.method_name} was not found")
326
+ end
327
+
328
+ def resolve_routing(manager, attributes)
329
+ %i[proxy proxy_group].each do |field|
330
+ next unless @attributes.key?(field)
331
+
332
+ reference = @attributes[field]
333
+ resource = field == :proxy ? manager.proxies : manager.proxy_groups
334
+ id_key = field == :proxy ? :proxyid : :proxy_groupid
335
+ id = if reference.is_a?(Hash)
336
+ reference.fetch(id_key)
337
+ else
338
+ resolve_name(resource, resource.identify.to_sym, reference)
339
+ end
340
+ if Gem::Version.new(manager.client.api_version) < Gem::Version.new("7.0")
341
+ attributes[:proxy_hostid] = id
342
+ else
343
+ attributes[:monitored_by] = field == :proxy ? 1 : 2
344
+ attributes[id_key] = id
345
+ end
346
+ end
347
+ end
348
+
349
+ def validate_creation!(manager, attributes)
350
+ raise Invalid, "groups are required when creating a device" if attributes.fetch(:groups, []).empty?
351
+ raise Invalid, "interfaces are required when creating a device" if attributes.fetch(:interfaces, []).empty?
352
+ if attributes[:interfaces].any? { |interface| interface.key?(:interfaceid) }
353
+ raise Invalid, "new devices cannot reference existing interface IDs"
354
+ end
355
+
356
+ manager.host_interfaces.validate_for_create(attributes[:interfaces])
357
+ end
358
+
359
+ def update_existing(manager, existing, attributes)
360
+ hostid = existing.fetch("hostid")
361
+ interfaces = attributes.delete(:interfaces)
362
+ macros = attributes.delete(:macros) || []
363
+ manager.host_interfaces.validate_for_host(hostid: hostid, interfaces: interfaces) if interfaces&.any?
364
+ macro_plan = macros.map do |macro|
365
+ [macro, manager.user_macros.get_id(hostid: hostid, macro: macro.fetch(:macro))]
366
+ end
367
+ macro_plan.each do |macro, id|
368
+ id ? manager.user_macros.update(macro.merge(hostmacroid: id)) :
369
+ manager.user_macros.create(macro.merge(hostid: hostid))
370
+ end
371
+ manager.host_interfaces.reconcile_for_host(hostid: hostid, interfaces: interfaces) if interfaces&.any?
372
+ manager.hosts.update(attributes.merge(hostid: hostid))
373
+ end
374
+
375
+ def remote_id!(value, field)
376
+ Validation.positive_id!(value, field).to_s
377
+ rescue Invalid
378
+ raise ProtocolError, "invalid device response #{field}", cause: nil
379
+ end
380
+
381
+ def remote_tag!(tag)
382
+ unless tag["tag"].is_a?(String) && tag["value"].is_a?(String)
383
+ raise ProtocolError, "invalid device tag response"
384
+ end
385
+
386
+ { tag: tag["tag"], value: tag["value"] }
387
+ end
388
+
389
+ def receipt(hostid, managed)
390
+ { hostid: hostid&.to_i, enabled: @enabled, managed: managed.deep_dup }
391
+ end
392
+ end
393
+ end
394
+ 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,176 @@
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
+ # 保存调用方定义的独立快照;等待锁和远端发现期间不重新读取可变输入。
18
+ # 仅解析本地定义,不访问服务器;status/speed 为开关或显式 itemid 选择器。
19
+ # @param attributes [Hash] 规范化后的单端点清单字段
20
+ # @raise [Invalid] 字段、阈值或目标格式不合法
21
+ def initialize(attributes)
22
+ @attributes = Validation.hash!(attributes, "line").deep_dup
23
+ @attributes.assert_valid_keys(*FIELDS)
24
+ @interface_name = Validation.text!(@attributes[:interface_name], "line interface_name")
25
+ validate_identity!
26
+ @host_reference = build_host_reference
27
+ @threshold = build_threshold
28
+ Thresholds.validate_config!(:bandwidth, @threshold)
29
+ @low_traffic = build_low_traffic if @attributes.key?(:low_traffic)
30
+ @status = item_selection(:status)
31
+ @speed = item_selection(:speed)
32
+ @reachability_target = build_reachability_target if @attributes.key?(:reachability_target)
33
+ @metadata = @attributes.slice(*METADATA_FIELDS)
34
+ @metadata.each do |key, value|
35
+ raise Invalid, "line #{key} must be a string" unless value.is_a?(String)
36
+ end
37
+ rescue ArgumentError => error
38
+ raise Invalid, error.message
39
+ end
40
+
41
+ def identity
42
+ @attributes[:line_id].presence ||
43
+ [host_identity, TrafficItems.interface_identity(interface_name)]
44
+ end
45
+
46
+ def interface(hostid:)
47
+ { name: interface_name, line_id: resolved_id(hostid) }
48
+ end
49
+
50
+ # 显式 line_id 优先;缺省身份由已解析 hostid 和规范接口名组成。
51
+ # @return [String] 用于受管标签的端点身份
52
+ def resolved_id(hostid)
53
+ @attributes[:line_id].presence || "#{hostid}:#{TrafficItems.interface_identity(interface_name)}"
54
+ end
55
+
56
+ def safe_identity
57
+ @attributes.slice(:line_id, :device).merge(interface: interface_name).compact
58
+ end
59
+
60
+ private
61
+
62
+ def host_identity
63
+ host_reference.is_a?(Hash) ? host_reference[:hostid].to_s : host_reference.sort
64
+ end
65
+
66
+ def validate_identity!
67
+ line_id = @attributes[:line_id]
68
+ unless line_id.nil? || line_id.is_a?(String) || line_id.is_a?(Integer)
69
+ raise Invalid, "line_id must be a string or integer"
70
+ end
71
+
72
+ @attributes[:line_id] = line_id.to_s if line_id
73
+ %i[device isp description].each do |name|
74
+ next unless @attributes.key?(name)
75
+
76
+ Validation.text!(@attributes[name], "line #{name}")
77
+ end
78
+ end
79
+
80
+ def build_host_reference
81
+ if @attributes.key?(:host) && @attributes.key?(:host_candidates)
82
+ raise Invalid, "line must supply either host or host_candidates"
83
+ end
84
+
85
+ host = @attributes[:host]
86
+ if host.is_a?(Hash)
87
+ Validation.host!(host)
88
+ return host.slice(:hostid, :host)
89
+ end
90
+
91
+ candidates = @attributes.key?(:host_candidates) ?
92
+ Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
93
+ candidates = candidates.map { |value| Validation.text!(value, "line host candidate").strip }.uniq
94
+ raise Invalid, "line host candidate is required" if candidates.empty?
95
+
96
+ candidates
97
+ end
98
+
99
+ def build_threshold
100
+ capacity = Validation.number!(@attributes[:capacity_mbps], "line capacity_mbps")
101
+ raise Invalid, "line capacity_mbps must be positive" unless capacity.positive?
102
+
103
+ high = water_ratio(@attributes.fetch(:high_water, 0.9), "high_water")
104
+ recovery = water_ratio(@attributes.fetch(:recovery_water, 0.8), "recovery_water")
105
+ raise Invalid, "recovery_water must be lower than high_water" unless recovery < high
106
+
107
+ {
108
+ capacity_bps: capacity * 1_000_000,
109
+ high_percent: high * 100, recovery_percent: recovery * 100,
110
+ window: @attributes.fetch(:problem_window, Thresholds::DEFAULT_WINDOW),
111
+ recovery_window: @attributes.fetch(:recovery_window, "15m"),
112
+ priority: @attributes.fetch(:severity, Thresholds::DEFAULT_PRIORITY),
113
+ description: ["专线流量超阈值", @attributes[:isp], @attributes[:description],
114
+ @attributes[:device], interface_name].compact.join(" | "),
115
+ tags: line_tags
116
+ }
117
+ end
118
+
119
+ def line_tags
120
+ extra = Validation.array!(@attributes.fetch(:tags, []), "line tags").map do |tag|
121
+ tag = Validation.hash!(tag, "line tag")
122
+ tag.assert_valid_keys(:tag, :value)
123
+ Validation.text!(tag[:tag], "line tag name")
124
+ if (Thresholds::MANAGED_TAGS + ["line_id"]).include?(tag[:tag])
125
+ raise Invalid, "line tag #{tag[:tag]} is reserved"
126
+ end
127
+ raise Invalid, "line tag value must be a string" unless tag.fetch(:value, "").is_a?(String)
128
+
129
+ { tag: tag[:tag], value: tag.fetch(:value, "") }
130
+ end
131
+ [{ tag: "category", value: "line_bandwidth" }] +
132
+ @attributes.slice(:isp, :description, :device).map { |name, value| { tag: name.to_s, value: value } } + extra
133
+ end
134
+
135
+ def build_low_traffic
136
+ config = Validation.hash!(@attributes[:low_traffic], "low_traffic")
137
+ config.assert_valid_keys(:below_bps, :recovery_bps, :window, :recovery_window)
138
+ below = Validation.number!(config.fetch(:below_bps, 50), "low_traffic below_bps")
139
+ recovery = Validation.number!(config.fetch(:recovery_bps, 1000), "low_traffic recovery_bps")
140
+ unless below.positive? && recovery > below
141
+ raise Invalid, "low_traffic recovery_bps must be greater than positive below_bps"
142
+ end
143
+
144
+ window = Validation.window!(config.fetch(:window, "5m"))
145
+ { below_bps: below, recovery_bps: recovery, window: window,
146
+ recovery_window: Validation.window!(config.fetch(:recovery_window, window)) }
147
+ end
148
+
149
+ def item_selection(name)
150
+ value = @attributes.fetch(name, false)
151
+ return value if value == true || value == false
152
+
153
+ selection = Validation.hash!(value, name.to_s)
154
+ selection.assert_valid_keys(:itemid)
155
+ Validation.positive_id!(selection[:itemid], "#{name} itemid")
156
+ selection
157
+ end
158
+
159
+ def build_reachability_target
160
+ value = Validation.text!(@attributes[:reachability_target], "reachability_target")
161
+ raise Invalid, "reachability_target must be an IP address without a prefix" if value.include?("/")
162
+
163
+ IPAddr.new(value).to_s
164
+ rescue IPAddr::InvalidAddressError
165
+ raise Invalid, "reachability_target must be an IPv4 or IPv6 address"
166
+ end
167
+
168
+ def water_ratio(value, name)
169
+ ratio = Validation.number!(value, name)
170
+ raise Invalid, "#{name} must be greater than 0 and at most 1" unless ratio.positive? && ratio <= 1
171
+
172
+ ratio
173
+ end
174
+ end
175
+ end
176
+ end