zabbix_manager 5.1.5 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -9
- data/LICENSE +1 -1
- data/README.md +241 -24
- data/lib/zabbix_manager/classes/actions.rb +19 -17
- data/lib/zabbix_manager/classes/applications.rb +11 -22
- data/lib/zabbix_manager/classes/configurations.rb +20 -10
- data/lib/zabbix_manager/classes/discovery_rules.rb +23 -0
- data/lib/zabbix_manager/classes/errors.rb +18 -27
- data/lib/zabbix_manager/classes/events.rb +8 -3
- data/lib/zabbix_manager/classes/graphs.rb +48 -37
- data/lib/zabbix_manager/classes/host_groups.rb +64 -0
- data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
- data/lib/zabbix_manager/classes/hosts.rb +232 -76
- data/lib/zabbix_manager/classes/http_tests.rb +32 -0
- data/lib/zabbix_manager/classes/items.rb +206 -72
- data/lib/zabbix_manager/classes/maintenance.rb +8 -3
- data/lib/zabbix_manager/classes/media_types.rb +18 -0
- data/lib/zabbix_manager/classes/problems.rb +51 -62
- data/lib/zabbix_manager/classes/proxies.rb +45 -14
- data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
- data/lib/zabbix_manager/classes/roles.rb +46 -41
- data/lib/zabbix_manager/classes/screens.rb +36 -24
- data/lib/zabbix_manager/classes/scripts.rb +16 -12
- data/lib/zabbix_manager/classes/server.rb +8 -1
- data/lib/zabbix_manager/classes/templates.rb +26 -51
- data/lib/zabbix_manager/classes/triggers.rb +251 -67
- data/lib/zabbix_manager/classes/user_groups.rb +63 -0
- data/lib/zabbix_manager/classes/user_macros.rb +110 -0
- data/lib/zabbix_manager/classes/users.rb +16 -23
- data/lib/zabbix_manager/classes/value_maps.rb +52 -0
- data/lib/zabbix_manager/client.rb +183 -137
- data/lib/zabbix_manager/configuration.rb +107 -0
- data/lib/zabbix_manager/http_transport.rb +240 -0
- data/lib/zabbix_manager/log_sanitizer.rb +58 -0
- data/lib/zabbix_manager/monitoring/device.rb +393 -0
- data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
- data/lib/zabbix_manager/monitoring/line.rb +175 -0
- data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
- data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
- data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
- data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
- data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
- data/lib/zabbix_manager/monitoring/validation.rb +92 -0
- data/lib/zabbix_manager/monitoring.rb +265 -0
- data/lib/zabbix_manager/resource.rb +235 -0
- data/lib/zabbix_manager/traffic.rb +238 -0
- data/lib/zabbix_manager/version.rb +1 -1
- data/lib/zabbix_manager.rb +110 -145
- data/zabbix_manager.gemspec +12 -15
- metadata +67 -30
- data/lib/zabbix_manager/basic/basic_alias.rb +0 -20
- data/lib/zabbix_manager/basic/basic_func.rb +0 -84
- data/lib/zabbix_manager/basic/basic_init.rb +0 -35
- data/lib/zabbix_manager/basic/basic_logic.rb +0 -195
- data/lib/zabbix_manager/classes/drules.rb +0 -41
- data/lib/zabbix_manager/classes/hostgroups.rb +0 -20
- data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -29
- data/lib/zabbix_manager/classes/httptests.rb +0 -40
- data/lib/zabbix_manager/classes/mediatypes.rb +0 -79
- data/lib/zabbix_manager/classes/unusable.rb +0 -11
- data/lib/zabbix_manager/classes/usergroups.rb +0 -53
- data/lib/zabbix_manager/classes/usermacros.rb +0 -136
- data/lib/zabbix_manager/classes/valuemaps.rb +0 -32
|
@@ -0,0 +1,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
|