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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zabbix_manager/monitoring/expressions"
4
+
5
+ class ZabbixManager
6
+ class Monitoring
7
+ # 先完整验证监控项和阈值,再生成不含远端副作用的触发器定义。
8
+ # 不执行 item.get 或写请求;已有监控项的归属及创建必填字段由资源层继续核对。
9
+ # @api private
10
+ class Thresholds
11
+ DEFAULT_WINDOW = "5m"
12
+ DEFAULT_PRIORITY = 3
13
+ FUNCTIONS = %w[avg max min last].freeze
14
+ METRICS = { bandwidth: %i[inbound_bps outbound_bps], errors: %i[in_errors out_errors],
15
+ packet_loss: [:packet_loss] }.freeze
16
+ MANAGED_TAGS = %w[managed_by interface metric zabbix_manager_id].freeze
17
+ CONFIG_KEYS = %i[metrics capacity_bps high_percent recovery_percent high recovery window
18
+ recovery_window function description priority tags].freeze
19
+
20
+ def initialize(client)
21
+ @client = client
22
+ end
23
+
24
+ # 复制原生 item 定义并绑定主机/接口;省略字段保留给资源层默认值与校验处理。
25
+ # @return [Hash{Symbol => Hash}] 按业务指标名称分组的监控项属性
26
+ def prepare_items(host, interface, definitions)
27
+ items = Validation.hash!(definitions, "items").to_h do |metric, attributes|
28
+ attributes = Validation.hash!(attributes, "item #{metric}")
29
+ Validation.item_key!(attributes[:key_])
30
+ attributes[:hostid] = host[:hostid]
31
+ attributes[:interfaceid] = interface[:interfaceid] if interface[:interfaceid]
32
+ attributes[:name] = attributes.fetch(:name, "#{interface[:name]} #{metric.to_s.tr('_', ' ')}")
33
+ Validation.text!(attributes[:name], "item #{metric} name")
34
+ [metric, attributes]
35
+ end
36
+ if items.values.map { |item| item[:key_] }.uniq.length != items.length
37
+ raise Invalid, "item key_ values must be unique within an interface"
38
+ end
39
+
40
+ items
41
+ end
42
+
43
+ # 高阈值任一方向超限即可告警;所有方向低于或等于恢复值才恢复。
44
+ # 初次预检可只检查结构;合并远端配置后必须校验完整监控项。
45
+ # @return [Hash{Symbol => Hash}] 包含表达式、恢复表达式及受管身份的写入属性
46
+ def prepare_triggers(host:, interface:, items:, thresholds:, validate_items: true)
47
+ Validation.host!(host)
48
+ Validation.hash!(thresholds, "thresholds").to_h do |metric, attributes|
49
+ config = self.class.validate_config!(metric, attributes)
50
+ selected = select_items(metric, config, items, validate_items: validate_items)
51
+ high, recovery = self.class.values(metric, config)
52
+ function = config.fetch(:function, "avg")
53
+ window = config.fetch(:window, DEFAULT_WINDOW)
54
+ description = config.fetch(:description, "#{interface[:name]} #{metric.to_s.tr('_', ' ')} high")
55
+ Validation.text!(description, "threshold #{metric} description")
56
+ keys = selected.map { |item| Validation.item_key!(item[:key_]) }
57
+ [metric, {
58
+ hostid: host[:hostid], description: description,
59
+ expression: expression(host[:host], keys, function, window, ">", high, "or"),
60
+ recovery_mode: 1,
61
+ recovery_expression: expression(host[:host], keys, function,
62
+ config.fetch(:recovery_window, window), "<=", recovery, "and"),
63
+ priority: Validation.priority!(config.fetch(:priority, DEFAULT_PRIORITY)), manual_close: 1,
64
+ tags: managed_tags(interface[:name], metric, config.fetch(:tags, [])),
65
+ managed_key: managed_key(interface, metric)
66
+ }]
67
+ end
68
+ end
69
+
70
+ def self.validate_config!(metric, attributes)
71
+ raise Invalid, "unsupported threshold #{metric}" unless METRICS.key?(metric)
72
+
73
+ config = Validation.hash!(attributes, "threshold #{metric}")
74
+ config.assert_valid_keys(*CONFIG_KEYS)
75
+ Validation.window!(config.fetch(:window, DEFAULT_WINDOW))
76
+ Validation.window!(config.fetch(:recovery_window, config.fetch(:window, DEFAULT_WINDOW)))
77
+ Validation.priority!(config.fetch(:priority, DEFAULT_PRIORITY))
78
+ unless FUNCTIONS.include?(config.fetch(:function, "avg"))
79
+ raise Invalid, "function must be one of #{FUNCTIONS.join(', ')}"
80
+ end
81
+
82
+ high, recovery = values(metric, config)
83
+ unless high.finite? && recovery.finite? && high.positive? && recovery >= 0 && recovery < high
84
+ raise Invalid, "#{metric} thresholds must be finite with recovery non-negative and lower than high"
85
+ end
86
+ if metric == :packet_loss && high > 100
87
+ raise Invalid, "packet_loss high must be at most 100 percent"
88
+ end
89
+
90
+ config
91
+ rescue ArgumentError => error
92
+ raise Invalid, error.message
93
+ end
94
+
95
+ def self.values(metric, config)
96
+ if metric == :bandwidth
97
+ capacity = Validation.number!(config[:capacity_bps], "bandwidth capacity_bps")
98
+ high = Validation.number!(config[:high_percent], "bandwidth high_percent")
99
+ recovery = Validation.number!(config[:recovery_percent], "bandwidth recovery_percent")
100
+ unless capacity.positive? && high.between?(0, 100) && recovery.between?(0, 100)
101
+ raise Invalid, "bandwidth capacity must be positive and percentages must be between 0 and 100"
102
+ end
103
+
104
+ [capacity * (high / 100), capacity * (recovery / 100)]
105
+ else
106
+ [Validation.number!(config[:high], "#{metric} high"),
107
+ Validation.number!(config[:recovery], "#{metric} recovery")]
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def select_items(metric, config, items, validate_items:)
114
+ names = Validation.array!(config.fetch(:metrics, METRICS.fetch(metric)), "threshold metrics")
115
+ raise Invalid, "threshold #{metric} requires at least one item" if names.empty?
116
+
117
+ names.map do |name|
118
+ unless name.is_a?(String) || name.is_a?(Symbol)
119
+ raise Invalid, "threshold metric names must be strings or symbols"
120
+ end
121
+
122
+ item = items.fetch(name.to_sym) { raise Invalid, "threshold #{metric} requires item metric #{name}" }
123
+ validate_units!(metric, item) if validate_items
124
+ item
125
+ end
126
+ end
127
+
128
+ def validate_units!(metric, item)
129
+ if item.key?(:value_type) && !%w[0 3].include?(item[:value_type].to_s)
130
+ raise Invalid, "threshold #{metric} requires a numeric value_type"
131
+ end
132
+
133
+ case metric
134
+ when :bandwidth
135
+ TrafficItems.validate_bps!(item)
136
+ when :errors
137
+ steps = Validation.array!(item.fetch(:preprocessing, []), "error preprocessing")
138
+ .map { |step| Validation.hash!(step, "error preprocessing step") }
139
+ rate_step = steps.any? { |step| step[:type].to_s == "10" }
140
+ explicit_rate = [item[:key_], item[:name]].join(" ").match?(/(?:\brate\b|[._]rate\b|per[._ -]?second)/i)
141
+ unless rate_step || explicit_rate
142
+ raise Invalid, "error metric #{item[:key_]} must represent a per-second rate"
143
+ end
144
+ when :packet_loss
145
+ raise Invalid, "packet loss metric #{item[:key_]} must use % units" unless item[:units] == "%"
146
+ end
147
+ end
148
+
149
+ def expression(host, keys, function, window, operator, threshold, joiner)
150
+ expressions = Expressions.new(@client, host)
151
+ keys.map { |key| expressions.compare(key, function, window, operator, threshold) }.join(" #{joiner} ")
152
+ end
153
+
154
+ def managed_tags(interface_name, metric, extra_tags)
155
+ tags = [
156
+ { tag: "managed_by", value: "zabbix_manager" },
157
+ { tag: "interface", value: interface_name },
158
+ { tag: "metric", value: metric.to_s }
159
+ ]
160
+ extra = Validation.array!(extra_tags, "threshold tags").map do |tag|
161
+ tag = Validation.hash!(tag, "threshold tag")
162
+ tag.assert_valid_keys(:tag, :value)
163
+ Validation.text!(tag[:tag], "threshold tag name")
164
+ raise Invalid, "threshold tag #{tag[:tag]} is reserved" if MANAGED_TAGS.include?(tag[:tag])
165
+ raise Invalid, "threshold tag value must be a string" unless tag.fetch(:value, "").is_a?(String)
166
+
167
+ { tag: tag[:tag], value: tag.fetch(:value, "") }
168
+ end
169
+ (tags + extra).uniq
170
+ end
171
+
172
+ def managed_key(interface, metric)
173
+ identity = interface[:line_id].presence || interface[:interfaceid].presence ||
174
+ TrafficItems.interface_identity(interface[:name])
175
+ key = "interface:#{identity}:#{metric}"
176
+ raise Invalid, "managed_key is too long" if key.length > Triggers::MAX_MANAGED_KEY_LENGTH
177
+
178
+ key
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 精确匹配已发现接口,并证明流量项的方向与 bit/s 语义。
6
+ class TrafficItems
7
+ OCTET_OID = /\A\.?1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.(6|10)(?:\.\d+)*\z/
8
+ LEGACY_OCTET_OID = /\A\.?1\.3\.6\.1\.2\.1\.2\.2\.1\.(10|16)(?:\.\d+)*\z/
9
+ SYMBOLIC_OCTET_OID = /\A(?:IF-MIB::)?if(?:HC)?(In|Out)Octets(?:\.\d+)*\z/
10
+ INTERFACE_TOKEN = /(?<![A-Za-z0-9\/._-])(?:[A-Za-z][A-Za-z_-]*)?\d+(?:[\/.:]\d+)*(?![A-Za-z0-9\/._-]|:\d)/
11
+
12
+ def self.interface_identity(value)
13
+ # 只归一已知厂商前缀;未知接口名的大小写和分隔符属于身份。
14
+ aliases = { "te" => /\A(?:ten[-_ ]?gigabit[-_ ]?ethernet|te)(\d[\d\/.:]*)\z/i,
15
+ "gi" => /\A(?:gigabit[-_ ]?ethernet|gi)(\d[\d\/.:]*)\z/i,
16
+ "eth" => /\A(?:ethernet|eth)(\d[\d\/.:]*)\z/i }
17
+ aliases.each do |prefix, pattern|
18
+ match = pattern.match(value)
19
+ return "#{prefix}#{match[1]}" if match
20
+ end
21
+ value
22
+ end
23
+
24
+ def self.counter_direction(value)
25
+ oid = value.to_s
26
+ oid = oid.delete_prefix("get[").delete_suffix("]") if oid.start_with?("get[") && oid.end_with?("]")
27
+ match = OCTET_OID.match(oid)
28
+ return match[1] == "6" ? :inbound : :outbound if match
29
+
30
+ symbolic = SYMBOLIC_OCTET_OID.match(oid)
31
+ return symbolic[1] == "In" ? :inbound : :outbound if symbolic
32
+
33
+ legacy = LEGACY_OCTET_OID.match(oid)
34
+ return unless legacy
35
+
36
+ legacy[1] == "10" ? :inbound : :outbound
37
+ end
38
+
39
+ # 匹配完整接口名称标记,包括子接口及常见厂商缩写。
40
+ # @api private
41
+ def self.interface_matches?(item, interface_name)
42
+ identity = interface_identity(interface_name)
43
+ [item["name"], item["key_"], item["snmp_oid"]].compact.any? do |field|
44
+ field.to_s.scan(INTERFACE_TOKEN).any? { |token| interface_identity(token) == identity }
45
+ end
46
+ end
47
+
48
+ def initialize(items)
49
+ @items = items
50
+ end
51
+
52
+ # 拒绝零匹配、多匹配和同一 itemid 同时充当两个方向;返回项仍须由调用方核对主机。
53
+ # @return [Hash] inbound、outbound 对应的符号键监控项属性
54
+ def for_interface(interface_name)
55
+ identity = self.class.interface_identity(interface_name)
56
+ candidates = @items.select do |item|
57
+ traffic_item?(item) && interface_item?(item, identity)
58
+ end
59
+ selected = %i[inbound outbound].to_h do |direction|
60
+ matches = candidates.select { |item| direction_item?(item, direction) }
61
+ unless matches.one?
62
+ raise Conflict, "expected one #{direction} traffic item for #{interface_name}, found #{matches.length}"
63
+ end
64
+
65
+ item = matches.first.deep_symbolize_keys
66
+ Validation.positive_id!(item[:itemid], "traffic itemid")
67
+ Validation.item_key!(item[:key_])
68
+ self.class.validate_bps!(item)
69
+ [direction, item]
70
+ end
71
+ if selected[:inbound][:itemid].to_s == selected[:outbound][:itemid].to_s
72
+ raise Conflict, "inbound and outbound traffic items must have distinct itemids"
73
+ end
74
+
75
+ selected
76
+ end
77
+
78
+ # bps 标签不足以证明原始 octet 计数的量纲;同时检查速率转换和乘数 8。
79
+ # @raise [Invalid] 单位或预处理不足以证明 bit/s 语义
80
+ def self.validate_bps!(item)
81
+ unless item[:units] == "bps"
82
+ raise Invalid, "traffic item #{item[:itemid] || item[:key_]} must use bps units"
83
+ end
84
+ return unless raw_counter?(item)
85
+
86
+ steps = Validation.array!(item.fetch(:preprocessing, []), "traffic preprocessing")
87
+ .map { |step| Validation.hash!(step, "traffic preprocessing step") }
88
+ if steps.first&.dig(:type).to_s == "28"
89
+ oid, format = steps.first[:params].to_s.split("\n")
90
+ steps = steps.drop(1) if counter_direction(oid) && format == "0"
91
+ end
92
+ change_per_second = steps.count { |step| step[:type].to_s == "10" } == 1
93
+ multipliers = steps.select { |step| step[:type].to_s == "1" }
94
+ bits_multiplier = multipliers.one? && Validation.number!(multipliers.first[:params], "multiplier") == 8
95
+ return if change_per_second && bits_multiplier && steps.length == 2
96
+
97
+ raise Invalid,
98
+ "raw octet item #{item[:itemid] || item[:key_]} requires change-per-second and multiplier 8 preprocessing"
99
+ end
100
+
101
+ def self.raw_counter?(item)
102
+ counter_direction(item[:snmp_oid]) || item[:key_].to_s.match?(/if(?:hc)?(in|out)octets/i) ||
103
+ (%w[0 7].include?(item[:type].to_s) && item[:key_].match?(/\Anet\.if\.(in|out)\[/))
104
+ end
105
+ private_class_method :raw_counter?
106
+
107
+ private
108
+
109
+ def interface_item?(item, identity)
110
+ self.class.interface_matches?(item, identity)
111
+ end
112
+
113
+ def traffic_item?(item)
114
+ blob = [item["key_"], item["name"]].join(" ").downcase
115
+ return false if blob.match?(/error|discard|drop|packet|status|utilization|percentage|percent/)
116
+
117
+ self.class.counter_direction(item["snmp_oid"]) ||
118
+ blob.match?(/net\.if\.(in|out)(?:\[|\b)|ifhc(in|out)octets|inbound|outbound/)
119
+ end
120
+
121
+ def direction_item?(item, direction)
122
+ counter = self.class.counter_direction(item["snmp_oid"])
123
+ return counter == direction if counter
124
+
125
+ blob = [item["key_"], item["name"]].join(" ").downcase
126
+ inbound = blob.match?(/net\.if\.in\b|ifhcin|inbound/)
127
+ outbound = blob.match?(/net\.if\.out\b|ifhcout|outbound/)
128
+ direction == :inbound ? inbound && !outbound : outbound && !inbound
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ class ZabbixManager
6
+ class Monitoring
7
+ # 监控输入的形状、数值和表达式边界;不做静默类型强制转换。
8
+ # 校验容器不代表授权;数值允许可解析的十进制字符串,ID 不接受浮点或布尔值。
9
+ # @api private
10
+ module Validation
11
+ ITEM_KEY = /\A[A-Za-z0-9_.-]+(?:\[(?:[A-Za-z0-9_.:,\/\- {}\#$]|"(?:[^"\\\r\n]|\\.)*")*\])?\z/
12
+ HOST_NAME = /\A[A-Za-z0-9 ._-]+\z/
13
+
14
+ module_function
15
+
16
+ def hash!(value, name)
17
+ raise Invalid, "#{name} must be a hash" unless value.is_a?(Hash)
18
+
19
+ value.deep_symbolize_keys
20
+ end
21
+
22
+ def array!(value, name)
23
+ raise Invalid, "#{name} must be an array" unless value.is_a?(Array)
24
+
25
+ value
26
+ end
27
+
28
+ # 字符串 "false" 不能作为真值启用提前终止等控制分支。
29
+ def boolean!(value, name)
30
+ raise Invalid, "#{name} must be true or false" unless value == true || value == false
31
+
32
+ value
33
+ end
34
+
35
+ def text!(value, name)
36
+ raise Invalid, "#{name} must be a non-empty string" unless value.is_a?(String) && value.present?
37
+
38
+ value
39
+ end
40
+
41
+ def positive_id!(value, name)
42
+ unless value.to_s.match?(/\A[1-9]\d*\z/) && (value.is_a?(Integer) || value.is_a?(String))
43
+ raise Invalid, "#{name} must be a positive integer"
44
+ end
45
+
46
+ value
47
+ end
48
+
49
+ def host!(host)
50
+ positive_id!(host[:hostid], "host.hostid")
51
+ text!(host[:host], "host.host")
52
+ raise Invalid, "host.host contains unsupported characters" unless HOST_NAME.match?(host[:host])
53
+ end
54
+
55
+ def item_key!(key)
56
+ text!(key, "item key_")
57
+ raise Invalid, "item key_ contains unsupported expression characters" unless ITEM_KEY.match?(key)
58
+
59
+ key
60
+ end
61
+
62
+ def number!(value, name)
63
+ number = BigDecimal(value.to_s)
64
+ raise Invalid, "#{name} must be finite" unless number.finite?
65
+
66
+ number
67
+ rescue ArgumentError, TypeError
68
+ raise Invalid, "#{name} must be a finite number"
69
+ end
70
+
71
+ def window!(window)
72
+ unless window.is_a?(String) && window.match?(/\A[1-9]\d*[smhdw]\z/)
73
+ raise Invalid, "window must use a positive Zabbix duration such as 5m or 1h"
74
+ end
75
+
76
+ window
77
+ end
78
+
79
+ def priority!(priority)
80
+ unless (priority.is_a?(Integer) || priority.is_a?(String)) && priority.to_s.match?(/\A[0-5]\z/)
81
+ raise Invalid, "priority must be an integer between 0 and 5"
82
+ end
83
+
84
+ priority.to_i
85
+ end
86
+
87
+ def format_number(number)
88
+ number.to_i == number ? number.to_i.to_s : number.to_s("F")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zabbix_manager/monitoring/validation"
4
+ require "zabbix_manager/monitoring/traffic_items"
5
+ require "zabbix_manager/monitoring/thresholds"
6
+ require "zabbix_manager/monitoring/line"
7
+ require "zabbix_manager/monitoring/line_items"
8
+ require "zabbix_manager/monitoring/line_plan"
9
+ require "zabbix_manager/monitoring/line_lifecycle"
10
+ require "zabbix_manager/monitoring/device"
11
+
12
+ class ZabbixManager
13
+ # 网络设备、接口和线路监控编排。先验证整批输入,再按稳定身份执行远端操作。
14
+ class Monitoring
15
+ # @param manager [ZabbixManager] 共享认证客户端和资源对象
16
+ def initialize(manager)
17
+ @manager = manager
18
+ @thresholds = Thresholds.new(manager.client)
19
+ @workflow_mutex = Mutex.new
20
+ end
21
+
22
+ # 按技术主机名装配设备监控;名称引用在写入前通过 API 解析。
23
+ # 同一实例内串行执行工作流,远端写入没有数据库事务或跨进程原子性。
24
+ # @param device [Hash] 原生主机属性及 groups/templates、snmp、proxy_group、managed 等业务选项
25
+ # @return [Hash] hostid、enabled 和下次更新可传回的 managed 集合归属回执
26
+ # @raise [Invalid, Conflict] 输入无效、引用不唯一或不支持当前服务端版本
27
+ # @raise [ApiError, TransportError] 远端操作失败;超时后应先核查远端状态
28
+ def reconcile_device(device)
29
+ definition = Device.new(device)
30
+ @workflow_mutex.synchronize { definition.reconcile(@manager) }
31
+ end
32
+
33
+ # 先校验整批本地定义,再逐台对账;单台失败不掩盖其他设备的结果。
34
+ # @param devices [Array<Hash>] 设备定义,技术主机名必须唯一
35
+ # @param fail_fast [Boolean] true 时首个远端错误向上抛出
36
+ # @return [Array<Hash>] 每台设备的身份、status 和 result,或脱敏 error
37
+ # @raise [Invalid] 本地定义无效,此时整批尚未写入
38
+ def reconcile_devices(devices, fail_fast: false)
39
+ Validation.boolean!(fail_fast, "fail_fast")
40
+ reconcile_device_definitions(validate_devices!(devices), fail_fast: fail_fast)
41
+ end
42
+
43
+ # 线路预检在设备写入之前完成;线路发现依赖设备或模板产生的监控项。
44
+ # 模板/LLD 异步产生监控项,设备成功不代表对应线路已就绪。
45
+ # @param devices [Array<Hash>] 设备定义
46
+ # @param lines [Array<Hash>] 单端点线路定义;双端使用独立 line_id
47
+ # @param fail_fast [Boolean] true 时首个远端错误向上抛出
48
+ # @return [Hash] devices、lines 逐项结果和成功/失败/未知数量 summary
49
+ def reconcile_network(devices:, lines:, fail_fast: false)
50
+ Validation.boolean!(fail_fast, "fail_fast")
51
+ line_definitions = validate_lines!(lines)
52
+ device_definitions = validate_devices!(devices)
53
+ results = {
54
+ devices: reconcile_device_definitions(device_definitions, fail_fast: fail_fast),
55
+ lines: reconcile_line_definitions(line_definitions, fail_fast: fail_fast)
56
+ }
57
+ results.merge(summary: batch_summary(results))
58
+ end
59
+
60
+ # 只读解析主机和监控项,预览实际触发器属性及按类型引用的依赖。
61
+ # ICMP 是 Zabbix server/proxy 对目标的检查,不能证明专线两端的转发路径。
62
+ # @param attributes [Hash] 单端点 line_id、host、interface_name、capacity_mbps 及可选监控配置
63
+ # @return [Hash] hostid、line_id、itemids、triggers、dependencies 和可选 icmp_item
64
+ # @raise [Invalid, Conflict] 阈值、量纲、项目选择或管理归属无法确定
65
+ # @raise [ApiError, TransportError] 远端发现失败,此方法从不写入
66
+ def plan_line(attributes)
67
+ prepare_line(Line.new(attributes), {})
68
+ end
69
+
70
+ # 对账一个端点,确认所需触发器后停用该线路不再需要的受管类型。
71
+ # 同一实例串行执行;多步远端写入不具备事务回滚或跨进程原子性。
72
+ # @param attributes [Hash] 与 plan_line 相同的线路定义
73
+ # @return [Hash] 主机、接口、line_id、按指标分类的 itemids 和 triggerids
74
+ # @raise [Invalid, Conflict] 预检失败,此时没有线路写入
75
+ # @raise [ApiError, TransportError] 远端失败;已确认的步骤保留,传输失败可能已提交
76
+ def reconcile_line(attributes)
77
+ line = Line.new(attributes)
78
+ @workflow_mutex.synchronize { apply_line(prepare_line(line, {})) }
79
+ end
80
+
81
+ # 先预检整批本地输入和远端发现,再逐端点执行,保留失败与结果未知的区别。
82
+ # @param lines [Array<Hash>] line_id 唯一的端点定义;双端线路使用两个不同 line_id
83
+ # @param fail_fast [Boolean] 遇到首个远端失败时向上抛出
84
+ # @return [Array<Hash>] 每个端点的 status、result 或脱敏 error
85
+ # @raise [Invalid] 本地定义或解析后的目标重复
86
+ def reconcile_lines(lines, fail_fast: false)
87
+ Validation.boolean!(fail_fast, "fail_fast")
88
+ reconcile_line_definitions(validate_lines!(lines), fail_fast: fail_fast)
89
+ end
90
+
91
+ # 查询端点的受管触发器;人工触发器及其他管理方的同名对象不在结果中。
92
+ # @param hostid [Integer, String] 目标主机 ID
93
+ # @param line_id [String, Integer] 端点的稳定身份,或 plan_line 返回的自动身份
94
+ # @return [Array<Hash>] 原生 trigger.get 对象,包括 tags、items、状态及表达式
95
+ # @raise [Invalid, Conflict, ProtocolError] 输入无效或远端管理身份不唯一
96
+ def line_triggers(hostid:, line_id:)
97
+ LineLifecycle.new(@manager, hostid, line_id).owned
98
+ end
99
+
100
+ # 停用一个端点的全部受管触发器;不删除触发器或共享 ICMP 监控项。
101
+ # @param hostid [Integer, String] 目标主机 ID
102
+ # @param line_id [String, Integer] 端点的稳定身份
103
+ # @return [Array<Integer>] 已确认停用的触发器 ID;没有受管对象时为空
104
+ # @raise [ApiError, TransportError] 写入失败或结果尚未确认
105
+ def disable_line(hostid:, line_id:)
106
+ lifecycle = LineLifecycle.new(@manager, hostid, line_id)
107
+ @workflow_mutex.synchronize { lifecycle.disable }
108
+ end
109
+
110
+ # 按该端点的触发器 ID 在服务端过滤问题,再应用数量上限。
111
+ # Zabbix problem.get 的 recent 范围受服务器问题保留配置影响,不代表完整事件归档。
112
+ # @param hostid [Integer, String] 目标主机 ID
113
+ # @param line_id [String, Integer] 端点的稳定身份
114
+ # @param time_from [Integer, Time] 查询起点(Unix 秒或 Time)
115
+ # @param time_till [Integer, Time] 查询终点,默认为当前时间
116
+ # @param limit [Integer] 最大记录数,1..1000;多读取一条以判定截断
117
+ # @return [Hash] problems、truncated、time_from、time_till 和 limit
118
+ # @raise [Invalid, ProtocolError] 范围无效或响应超出目标线路
119
+ def line_problems(hostid:, line_id:, time_from:, time_till: Time.now, limit: 100)
120
+ LineLifecycle.new(@manager, hostid, line_id).problems(time_from: time_from, time_till: time_till, limit: limit)
121
+ end
122
+
123
+ # 接口更新只增改,不自动删除远端对象;所有本地校验在首个写入之前完成。
124
+ # @param attributes [Hash] host、interface、按名称分组的原生 items 和 thresholds
125
+ # @return [Hash] hostid、interface、itemids 和 triggerids
126
+ # @raise [Invalid, Conflict] 输入、量纲或资源归属无效
127
+ # @raise [ApiError, TransportError] 远端失败;已经确认的早期步骤不会被回滚
128
+ def reconcile_interface(attributes)
129
+ input = Validation.hash!(attributes, "interface monitoring")
130
+ input.assert_valid_keys(:host, :interface, :items, :thresholds)
131
+ supplied_host = Validation.hash!(input[:host], "host")
132
+ Validation.host!(supplied_host)
133
+ interface = Validation.hash!(input[:interface], "interface")
134
+ interface.assert_valid_keys(:name, :interfaceid)
135
+ Validation.text!(interface[:name], "interface.name")
136
+ Validation.positive_id!(interface[:interfaceid], "interface.interfaceid") if interface.key?(:interfaceid)
137
+ items = @thresholds.prepare_items(supplied_host, interface, input.fetch(:items, {}))
138
+ triggers = @thresholds.prepare_triggers(
139
+ host: supplied_host, interface: interface, items: items, thresholds: input.fetch(:thresholds, {}),
140
+ validate_items: false
141
+ )
142
+ host = resolve_host(supplied_host, {})
143
+ ids = @manager.items.upsert_many(items.values) do |effective|
144
+ # 读取已有配置后再次校验最终量纲和数值类型,仍在首个 item 写入之前。
145
+ triggers = @thresholds.prepare_triggers(
146
+ host: supplied_host, interface: interface, items: items.keys.zip(effective).to_h,
147
+ thresholds: input.fetch(:thresholds, {})
148
+ )
149
+ end
150
+ itemids = items.keys.zip(ids).to_h
151
+ triggerids = triggers.transform_values { |trigger| @manager.triggers.upsert_for_host(trigger) }
152
+ { hostid: host[:hostid].to_i, interface: interface[:name], itemids: itemids, triggerids: triggerids }
153
+ end
154
+
155
+ private
156
+
157
+ def validate_devices!(devices)
158
+ definitions = Validation.array!(devices, "devices").map { |device| Device.new(device) }
159
+ validate_unique!(definitions.map(&:identity), "device host")
160
+ definitions
161
+ end
162
+
163
+ def validate_lines!(lines)
164
+ definitions = Validation.array!(lines, "lines").map { |attributes| Line.new(attributes) }
165
+ validate_unique!(definitions.map(&:identity), "line_id or host/interface")
166
+ definitions
167
+ end
168
+
169
+ def validate_unique!(identities, name)
170
+ duplicate = identities.tally.find { |_identity, count| count > 1 }
171
+ raise Invalid, "duplicate #{name}" if duplicate
172
+ end
173
+
174
+ def reconcile_device_definitions(devices, fail_fast:)
175
+ devices.map do |device|
176
+ identity = device.safe_identity
177
+ result = @workflow_mutex.synchronize { device.reconcile(@manager) }
178
+ { status: :ok, device: identity, result: result }
179
+ rescue ApiError, TransportError, Invalid => error
180
+ raise if fail_fast
181
+
182
+ failure_result(:device, identity, error, writing: true)
183
+ end
184
+ end
185
+
186
+ # 先完成线路发现,防止两个不同候选主机名解析到同一触发器后互相覆盖。
187
+ def reconcile_line_definitions(lines, fail_fast:)
188
+ cache = {}
189
+ prepared = lines.map do |line|
190
+ { line: line, plan: prepare_line(line, cache) }
191
+ rescue ApiError, TransportError, Invalid => error
192
+ raise if fail_fast
193
+
194
+ { line: line, failure: failure_result(:line, line.safe_identity, error) }
195
+ end
196
+ identities = prepared.filter_map do |entry|
197
+ plan = entry[:plan]
198
+ [plan[:hostid], plan[:line_id]] if plan
199
+ end
200
+ validate_unique!(identities, "line target")
201
+ prepared.map do |entry|
202
+ next entry[:failure] if entry[:failure]
203
+
204
+ { status: :ok, result: @workflow_mutex.synchronize { apply_line(entry[:plan]) } }
205
+ rescue ApiError, TransportError, Invalid => error
206
+ raise if fail_fast
207
+
208
+ failure_result(:line, entry[:line].safe_identity, error, writing: true)
209
+ end
210
+ end
211
+
212
+ def prepare_line(line, cache)
213
+ host = resolve_host(line.host_reference, cache)
214
+ source_items = cache[[:items, host[:hostid].to_s]] ||=
215
+ @manager.items.monitored_traffic_candidates(host[:hostid])
216
+ resolver = LineItems.new(@manager.items)
217
+ selected = resolver.resolve(line, source_items, hostid: host[:hostid])
218
+ icmp_item = resolver.icmp_plan(host[:hostid], line.reachability_target) if line.reachability_target
219
+ plan = LinePlan.new(@manager.client, line, host, selected, icmp_item).to_h
220
+ lifecycle = LineLifecycle.new(@manager, plan[:hostid], plan[:line_id])
221
+ lifecycle.validate_ownership!(lifecycle.inventory, plan[:triggers].values.pluck(:managed_key))
222
+ plan
223
+ end
224
+
225
+ def apply_line(plan)
226
+ lifecycle = LineLifecycle.new(@manager, plan[:hostid], plan[:line_id])
227
+ existing = lifecycle.inventory
228
+ desired_keys = plan[:triggers].values.pluck(:managed_key)
229
+ lifecycle.validate_ownership!(existing, desired_keys)
230
+ itemids = plan[:itemids].dup
231
+ if plan[:icmp_item]
232
+ itemids[:reachability] = LineItems.new(@manager.items).ensure_icmp(plan[:icmp_item])
233
+ end
234
+ triggerids = {}
235
+ plan[:triggers].each do |kind, attributes|
236
+ dependencies = plan[:dependencies].fetch(kind).map { |dependency| { triggerid: triggerids.fetch(dependency) } }
237
+ triggerids[kind] = @manager.triggers.upsert_for_host(attributes.merge(dependencies: dependencies))
238
+ end
239
+ lifecycle.retire(existing, desired_keys)
240
+ plan.slice(:hostid, :host, :interface, :line_id).merge(itemids: itemids, triggerids: triggerids)
241
+ end
242
+
243
+ def resolve_host(reference, cache)
244
+ host = cache[[:host, reference]] ||= @manager.hosts.resolve(reference)
245
+ reference.is_a?(Hash) ? host.merge(hostid: reference[:hostid]) : host
246
+ end
247
+
248
+ def failure_result(kind, identity, error, writing: false)
249
+ unknown = error.is_a?(ResultUnknown) || (writing && error.is_a?(TransportError))
250
+ {
251
+ status: unknown ? :unknown : :error,
252
+ kind => identity,
253
+ error: { class: error.class.name, message: LogSanitizer.sanitize(error.message) }
254
+ }
255
+ end
256
+
257
+ def batch_summary(results)
258
+ results.transform_values do |entries|
259
+ statuses = entries.map { |entry| entry[:status] }.tally
260
+ { total: entries.length, succeeded: statuses.fetch(:ok, 0),
261
+ failed: statuses.fetch(:error, 0), unknown: statuses.fetch(:unknown, 0) }
262
+ end
263
+ end
264
+ end
265
+ end