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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
class Monitoring
|
|
5
|
+
# 根据 API 版本,将已校验的主机、监控项键和数值转换为触发器表达式。
|
|
6
|
+
# @api private
|
|
7
|
+
class Expressions
|
|
8
|
+
attr_reader :version
|
|
9
|
+
|
|
10
|
+
def initialize(client, host)
|
|
11
|
+
version = client.api_version.to_s
|
|
12
|
+
unless version.match?(/\A\d+\.\d+(?:\.\d+)?\z/)
|
|
13
|
+
raise Invalid, "Zabbix API version must be a numeric release version"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@host = host
|
|
17
|
+
@version = Gem::Version.new(version)
|
|
18
|
+
@modern = @version >= Gem::Version.new("5.4")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 只接受上层已校验的函数、运算符、窗口和主机;这里再次校验嵌入表达式的 item key。
|
|
22
|
+
# last 不带窗口;其他函数按 5.4 前后语法生成,数值不使用科学计数法。
|
|
23
|
+
# @return [String] 一个可组合的比较表达式
|
|
24
|
+
def compare(key, function, window, operator, value)
|
|
25
|
+
Validation.item_key!(key)
|
|
26
|
+
call = if @modern
|
|
27
|
+
source = "/#{@host}/#{key}"
|
|
28
|
+
function == "last" ? "last(#{source})" : "#{function}(#{source},#{window})"
|
|
29
|
+
else
|
|
30
|
+
"{#{@host}:#{key}.#{function}(#{function == 'last' ? '' : window})}"
|
|
31
|
+
end
|
|
32
|
+
"#{call}#{operator}#{Validation.format_number(value)}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
class ZabbixManager
|
|
6
|
+
class Monitoring
|
|
7
|
+
# 线路清单使用一套明确字段;外部文件列名应由导入方转换。
|
|
8
|
+
# @api private
|
|
9
|
+
class Line
|
|
10
|
+
FIELDS = %i[line_id host host_candidates interface_name capacity_mbps high_water recovery_water
|
|
11
|
+
problem_window recovery_window severity device isp description low_traffic status speed
|
|
12
|
+
reachability_target tags comments event_name opdata].freeze
|
|
13
|
+
METADATA_FIELDS = %i[comments event_name opdata].freeze
|
|
14
|
+
attr_reader :host_reference, :interface_name, :threshold, :low_traffic, :status, :speed,
|
|
15
|
+
:reachability_target, :metadata
|
|
16
|
+
|
|
17
|
+
# 仅解析本地定义,不访问服务器;status/speed 为开关或显式 itemid 选择器。
|
|
18
|
+
# @param attributes [Hash] 规范化后的单端点清单字段
|
|
19
|
+
# @raise [Invalid] 字段、阈值或目标格式不合法
|
|
20
|
+
def initialize(attributes)
|
|
21
|
+
@attributes = Validation.hash!(attributes, "line")
|
|
22
|
+
@attributes.assert_valid_keys(*FIELDS)
|
|
23
|
+
@interface_name = Validation.text!(@attributes[:interface_name], "line interface_name")
|
|
24
|
+
validate_identity!
|
|
25
|
+
@host_reference = build_host_reference
|
|
26
|
+
@threshold = build_threshold
|
|
27
|
+
Thresholds.validate_config!(:bandwidth, @threshold)
|
|
28
|
+
@low_traffic = build_low_traffic if @attributes.key?(:low_traffic)
|
|
29
|
+
@status = item_selection(:status)
|
|
30
|
+
@speed = item_selection(:speed)
|
|
31
|
+
@reachability_target = build_reachability_target if @attributes.key?(:reachability_target)
|
|
32
|
+
@metadata = @attributes.slice(*METADATA_FIELDS)
|
|
33
|
+
@metadata.each do |key, value|
|
|
34
|
+
raise Invalid, "line #{key} must be a string" unless value.is_a?(String)
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError => error
|
|
37
|
+
raise Invalid, error.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def identity
|
|
41
|
+
@attributes[:line_id].presence ||
|
|
42
|
+
[host_identity, TrafficItems.interface_identity(interface_name)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def interface(hostid:)
|
|
46
|
+
{ name: interface_name, line_id: resolved_id(hostid) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# 显式 line_id 优先;缺省身份由已解析 hostid 和规范接口名组成。
|
|
50
|
+
# @return [String] 用于受管标签的端点身份
|
|
51
|
+
def resolved_id(hostid)
|
|
52
|
+
@attributes[:line_id].presence || "#{hostid}:#{TrafficItems.interface_identity(interface_name)}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def safe_identity
|
|
56
|
+
@attributes.slice(:line_id, :device).merge(interface: interface_name).compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def host_identity
|
|
62
|
+
host_reference.is_a?(Hash) ? host_reference[:hostid].to_s : host_reference.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_identity!
|
|
66
|
+
line_id = @attributes[:line_id]
|
|
67
|
+
unless line_id.nil? || line_id.is_a?(String) || line_id.is_a?(Integer)
|
|
68
|
+
raise Invalid, "line_id must be a string or integer"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@attributes[:line_id] = line_id.to_s if line_id
|
|
72
|
+
%i[device isp description].each do |name|
|
|
73
|
+
next unless @attributes.key?(name)
|
|
74
|
+
|
|
75
|
+
Validation.text!(@attributes[name], "line #{name}")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_host_reference
|
|
80
|
+
if @attributes.key?(:host) && @attributes.key?(:host_candidates)
|
|
81
|
+
raise Invalid, "line must supply either host or host_candidates"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
host = @attributes[:host]
|
|
85
|
+
if host.is_a?(Hash)
|
|
86
|
+
Validation.host!(host)
|
|
87
|
+
return host.slice(:hostid, :host)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
candidates = @attributes.key?(:host_candidates) ?
|
|
91
|
+
Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
|
|
92
|
+
candidates = candidates.map { |value| Validation.text!(value, "line host candidate").strip }.uniq
|
|
93
|
+
raise Invalid, "line host candidate is required" if candidates.empty?
|
|
94
|
+
|
|
95
|
+
candidates
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_threshold
|
|
99
|
+
capacity = Validation.number!(@attributes[:capacity_mbps], "line capacity_mbps")
|
|
100
|
+
raise Invalid, "line capacity_mbps must be positive" unless capacity.positive?
|
|
101
|
+
|
|
102
|
+
high = water_ratio(@attributes.fetch(:high_water, 0.9), "high_water")
|
|
103
|
+
recovery = water_ratio(@attributes.fetch(:recovery_water, 0.8), "recovery_water")
|
|
104
|
+
raise Invalid, "recovery_water must be lower than high_water" unless recovery < high
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
capacity_bps: capacity * 1_000_000,
|
|
108
|
+
high_percent: high * 100, recovery_percent: recovery * 100,
|
|
109
|
+
window: @attributes.fetch(:problem_window, Thresholds::DEFAULT_WINDOW),
|
|
110
|
+
recovery_window: @attributes.fetch(:recovery_window, "15m"),
|
|
111
|
+
priority: @attributes.fetch(:severity, Thresholds::DEFAULT_PRIORITY),
|
|
112
|
+
description: ["专线流量超阈值", @attributes[:isp], @attributes[:description],
|
|
113
|
+
@attributes[:device], interface_name].compact.join(" | "),
|
|
114
|
+
tags: line_tags
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def line_tags
|
|
119
|
+
extra = Validation.array!(@attributes.fetch(:tags, []), "line tags").map do |tag|
|
|
120
|
+
tag = Validation.hash!(tag, "line tag")
|
|
121
|
+
tag.assert_valid_keys(:tag, :value)
|
|
122
|
+
Validation.text!(tag[:tag], "line tag name")
|
|
123
|
+
if (Thresholds::MANAGED_TAGS + ["line_id"]).include?(tag[:tag])
|
|
124
|
+
raise Invalid, "line tag #{tag[:tag]} is reserved"
|
|
125
|
+
end
|
|
126
|
+
raise Invalid, "line tag value must be a string" unless tag.fetch(:value, "").is_a?(String)
|
|
127
|
+
|
|
128
|
+
{ tag: tag[:tag], value: tag.fetch(:value, "") }
|
|
129
|
+
end
|
|
130
|
+
[{ tag: "category", value: "line_bandwidth" }] +
|
|
131
|
+
@attributes.slice(:isp, :description, :device).map { |name, value| { tag: name.to_s, value: value } } + extra
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_low_traffic
|
|
135
|
+
config = Validation.hash!(@attributes[:low_traffic], "low_traffic")
|
|
136
|
+
config.assert_valid_keys(:below_bps, :recovery_bps, :window, :recovery_window)
|
|
137
|
+
below = Validation.number!(config.fetch(:below_bps, 50), "low_traffic below_bps")
|
|
138
|
+
recovery = Validation.number!(config.fetch(:recovery_bps, 1000), "low_traffic recovery_bps")
|
|
139
|
+
unless below.positive? && recovery > below
|
|
140
|
+
raise Invalid, "low_traffic recovery_bps must be greater than positive below_bps"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
window = Validation.window!(config.fetch(:window, "5m"))
|
|
144
|
+
{ below_bps: below, recovery_bps: recovery, window: window,
|
|
145
|
+
recovery_window: Validation.window!(config.fetch(:recovery_window, window)) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def item_selection(name)
|
|
149
|
+
value = @attributes.fetch(name, false)
|
|
150
|
+
return value if value == true || value == false
|
|
151
|
+
|
|
152
|
+
selection = Validation.hash!(value, name.to_s)
|
|
153
|
+
selection.assert_valid_keys(:itemid)
|
|
154
|
+
Validation.positive_id!(selection[:itemid], "#{name} itemid")
|
|
155
|
+
selection
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_reachability_target
|
|
159
|
+
value = Validation.text!(@attributes[:reachability_target], "reachability_target")
|
|
160
|
+
raise Invalid, "reachability_target must be an IP address without a prefix" if value.include?("/")
|
|
161
|
+
|
|
162
|
+
IPAddr.new(value).to_s
|
|
163
|
+
rescue IPAddr::InvalidAddressError
|
|
164
|
+
raise Invalid, "reachability_target must be an IPv4 or IPv6 address"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def water_ratio(value, name)
|
|
168
|
+
ratio = Validation.number!(value, name)
|
|
169
|
+
raise Invalid, "#{name} must be greater than 0 and at most 1" unless ratio.positive? && ratio <= 1
|
|
170
|
+
|
|
171
|
+
ratio
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
class Monitoring
|
|
5
|
+
# 解析端点的数值监控项,并复用主机已有的共享 ICMP 检查。
|
|
6
|
+
# 不重命名、启用、删除或接管已有 ICMP 监控项。
|
|
7
|
+
# @api private
|
|
8
|
+
class LineItems
|
|
9
|
+
PATTERNS = {
|
|
10
|
+
speed: /net\.if\.speed|ifhighspeed|interface speed|接口速率|1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.15\./i,
|
|
11
|
+
status: /net\.if\.(?:status|oper)|ifoperstatus|operational status|接口状态|1\.3\.6\.1\.2\.1\.2\.2\.1\.8\./i
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(items)
|
|
15
|
+
@items = items
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 两个流量方向必须唯一、不同且属于目标主机;附加指标只在选择器启用时解析。
|
|
19
|
+
# @return [Hash{Symbol => Hash}] 流量及可选状态/速率监控项
|
|
20
|
+
# @raise [Invalid, Conflict, ProtocolError] 量纲、身份、归属或响应结构无法确认
|
|
21
|
+
def resolve(line, candidates, hostid:)
|
|
22
|
+
unless candidates.is_a?(Array) && candidates.all? { |item| item.is_a?(Hash) }
|
|
23
|
+
raise ProtocolError, "item.get must return an array of item objects"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
selected = TrafficItems.new(candidates).for_interface(line.interface_name)
|
|
27
|
+
%i[status speed].each do |kind|
|
|
28
|
+
selector = line.public_send(kind)
|
|
29
|
+
next unless selector
|
|
30
|
+
|
|
31
|
+
matches = candidates.select do |item|
|
|
32
|
+
if selector.is_a?(Hash)
|
|
33
|
+
item["itemid"].to_s == selector[:itemid].to_s
|
|
34
|
+
else
|
|
35
|
+
TrafficItems.interface_matches?(item, line.interface_name) &&
|
|
36
|
+
[item["key_"], item["name"], item["snmp_oid"]].join(" ").match?(PATTERNS.fetch(kind))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
raise Conflict,
|
|
40
|
+
"expected one #{kind} item for #{line.interface_name}, found #{matches.length}" unless matches.one?
|
|
41
|
+
|
|
42
|
+
selected[kind] = matches.first.deep_symbolize_keys
|
|
43
|
+
end
|
|
44
|
+
selected.each do |kind, item|
|
|
45
|
+
validate_numeric!(item, kind)
|
|
46
|
+
raise ProtocolError, "#{kind} item belongs to another host" unless item[:hostid].to_s == hostid.to_s
|
|
47
|
+
end
|
|
48
|
+
unless selected.values.map { |item| item[:itemid].to_s }.uniq.length == selected.length
|
|
49
|
+
raise Conflict, "line metrics must refer to distinct items"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
selected
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 查询稳定 ICMP key 并生成候选属性;缺失对象在执行阶段才创建。
|
|
56
|
+
# @return [Hash] attributes 及已有 itemid(不存在时为 nil)
|
|
57
|
+
def icmp_plan(hostid, target)
|
|
58
|
+
attributes = {
|
|
59
|
+
hostid: hostid, name: "ICMP #{target}", key_: "icmpping[#{target}]", type: 3,
|
|
60
|
+
value_type: 3, delay: "1m", history: "30d", trends: "365d", status: 0
|
|
61
|
+
}
|
|
62
|
+
current = find_icmp(attributes)
|
|
63
|
+
{ attributes: attributes, itemid: current&.fetch(:itemid) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 在调用方工作流互斥锁内再次查询,不覆盖已有共享监控项。
|
|
67
|
+
def ensure_icmp(plan)
|
|
68
|
+
current = find_icmp(plan.fetch(:attributes))
|
|
69
|
+
return current.fetch(:itemid).to_i if current
|
|
70
|
+
|
|
71
|
+
@items.create(plan.fetch(:attributes))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def find_icmp(attributes)
|
|
77
|
+
matches = @items.for_host(attributes[:hostid], keys: attributes[:key_])
|
|
78
|
+
unless matches.is_a?(Array) && matches.all? { |item| item.is_a?(Hash) }
|
|
79
|
+
raise ProtocolError, "item.get must return an array of item objects"
|
|
80
|
+
end
|
|
81
|
+
raise Conflict, "multiple items use ICMP key #{attributes[:key_]}" if matches.length > 1
|
|
82
|
+
return if matches.empty?
|
|
83
|
+
|
|
84
|
+
item = matches.first.deep_symbolize_keys
|
|
85
|
+
unless item[:hostid].to_s == attributes[:hostid].to_s && item[:key_] == attributes[:key_]
|
|
86
|
+
raise ProtocolError, "item.get returned an ICMP item outside the requested host and key"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
validate_numeric!(item, :reachability)
|
|
90
|
+
unless item[:type].to_s == "3" && item[:value_type].to_s == "3"
|
|
91
|
+
raise Conflict, "existing ICMP item must be an unsigned simple check"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
item
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_numeric!(item, kind)
|
|
98
|
+
Validation.positive_id!(item[:itemid], "#{kind} itemid")
|
|
99
|
+
Validation.item_key!(item[:key_])
|
|
100
|
+
unless %w[0 3].include?(item[:value_type].to_s)
|
|
101
|
+
raise Invalid, "#{kind} item must have a numeric value_type"
|
|
102
|
+
end
|
|
103
|
+
raise Conflict, "#{kind} item must be enabled" unless item[:status].to_s == "0"
|
|
104
|
+
return unless kind == :status && item[:value_type].to_s != "3"
|
|
105
|
+
|
|
106
|
+
raise Invalid, "status item must use unsigned integer values with 1 meaning up"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
class Monitoring
|
|
5
|
+
# 通过原生 API 核对端点触发器归属、停用旧检查并读取问题。
|
|
6
|
+
# 不保存持久状态;以已确认的标签和 ID 界定管理范围。
|
|
7
|
+
# @api private
|
|
8
|
+
class LineLifecycle
|
|
9
|
+
KINDS = %i[interface_status bandwidth low_traffic reachability].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(manager, hostid, line_id)
|
|
12
|
+
Validation.positive_id!(hostid, "hostid")
|
|
13
|
+
unless line_id.is_a?(String) || line_id.is_a?(Integer)
|
|
14
|
+
raise Invalid, "line_id must be a string or integer"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@line_id = Validation.text!(line_id.to_s, "line_id")
|
|
18
|
+
@prefix = "interface:#{@line_id}:"
|
|
19
|
+
@keys = KINDS.map { |kind| "#{@prefix}#{kind}" }
|
|
20
|
+
if @keys.any? { |key| key.length > Triggers::MAX_MANAGED_KEY_LENGTH }
|
|
21
|
+
raise Invalid, "managed_key is too long"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@manager, @hostid = manager, hostid
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 服务端前缀过滤后,再以完整受管键筛选并拒绝重复身份。
|
|
28
|
+
# @return [Array<Hash>] 该端点已知类型的原生触发器;所有者由 owned 继续筛选
|
|
29
|
+
def inventory
|
|
30
|
+
records = @manager.client.api_request(
|
|
31
|
+
method: "trigger.get",
|
|
32
|
+
params: {
|
|
33
|
+
hostids: @hostid, tags: [{ tag: "zabbix_manager_id", value: @prefix, operator: 0 }],
|
|
34
|
+
output: "extend", selectTags: "extend", selectItems: ["itemid", "name"], expandExpression: true
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
unless records.is_a?(Array) && records.all? { |record| record.is_a?(Hash) }
|
|
38
|
+
raise ProtocolError, "trigger.get must return an array of trigger objects"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
records.each do |record|
|
|
42
|
+
tags = record["tags"]
|
|
43
|
+
valid_tags = tags.is_a?(Array) && tags.all? do |tag|
|
|
44
|
+
tag.is_a?(Hash) && tag["tag"].is_a?(String) && tag["value"].is_a?(String)
|
|
45
|
+
end
|
|
46
|
+
raise ProtocolError, "trigger.get must include valid tags for ownership verification" unless valid_tags
|
|
47
|
+
end
|
|
48
|
+
selected = records.select { |record| managed_key(record) }
|
|
49
|
+
selected.each { |record| validate_receipt!(record) }
|
|
50
|
+
ids = selected.map { |record| record["triggerid"].to_s }
|
|
51
|
+
raise ProtocolError, "trigger.get returned duplicate trigger IDs" unless ids.uniq == ids
|
|
52
|
+
|
|
53
|
+
identities = selected.map { |record| managed_key(record) }
|
|
54
|
+
raise Conflict, "multiple triggers use the same line managed key" unless identities.uniq == identities
|
|
55
|
+
|
|
56
|
+
selected
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 兼容缺少 line_id 的既有受管标签,但拒绝其他管理方或矛盾的端点标签。
|
|
60
|
+
# @return [Array<Hash>] 当前库拥有的触发器
|
|
61
|
+
def owned(records = inventory)
|
|
62
|
+
records.select do |record|
|
|
63
|
+
owner = tag_values(record, "managed_by")
|
|
64
|
+
ids = tag_values(record, "line_id")
|
|
65
|
+
owner == ["zabbix_manager"] && (ids.empty? || ids == [@line_id])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_ownership!(records, desired_keys)
|
|
70
|
+
foreign = records - owned(records)
|
|
71
|
+
if foreign.any? { |record| desired_keys.include?(managed_key(record)) }
|
|
72
|
+
raise Conflict, "line managed key belongs to another owner"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def disable(records = owned)
|
|
77
|
+
return [] if records.empty?
|
|
78
|
+
|
|
79
|
+
@manager.triggers.set_status(hostid: @hostid, triggerids: records.map { |record| record.fetch("triggerid") },
|
|
80
|
+
enabled: false)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 仅停用本轮期望之外的受管类型;调用方必须先确认所有期望触发器写入成功。
|
|
84
|
+
# @return [Array<Integer>] 已确认停用的 ID;不删除对象
|
|
85
|
+
def retire(records, desired_keys)
|
|
86
|
+
disable(owned(records).reject { |record| desired_keys.include?(managed_key(record)) })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def problems(time_from:, time_till:, limit:)
|
|
90
|
+
from = timestamp(time_from, "time_from")
|
|
91
|
+
till = timestamp(time_till, "time_till")
|
|
92
|
+
raise Invalid, "time_from must not be after time_till" if from > till
|
|
93
|
+
unless limit.is_a?(Integer) && limit.between?(1, 1000)
|
|
94
|
+
raise Invalid, "limit must be an integer between 1 and 1000"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ids = owned.map { |record| record.fetch("triggerid").to_s }
|
|
98
|
+
records = if ids.empty?
|
|
99
|
+
[]
|
|
100
|
+
else
|
|
101
|
+
@manager.client.api_request(
|
|
102
|
+
method: "problem.get",
|
|
103
|
+
params: {
|
|
104
|
+
hostids: @hostid, objectids: ids, source: 0, object: 0, recent: true,
|
|
105
|
+
time_from: from, time_till: till, output: "extend", selectTags: "extend",
|
|
106
|
+
sortfield: "eventid", sortorder: "DESC", limit: limit + 1
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
unless records.is_a?(Array) && records.all? { |record| record.is_a?(Hash) }
|
|
111
|
+
raise ProtocolError, "problem.get must return an array of problem objects"
|
|
112
|
+
end
|
|
113
|
+
unless records.all? { |record| ids.include?(record["objectid"].to_s) }
|
|
114
|
+
raise ProtocolError, "problem.get returned a trigger outside the requested line"
|
|
115
|
+
end
|
|
116
|
+
raise ProtocolError, "problem.get exceeded the requested limit" if records.length > limit + 1
|
|
117
|
+
|
|
118
|
+
validate_problems!(records, from, till)
|
|
119
|
+
|
|
120
|
+
{ problems: records.first(limit), truncated: records.length > limit, time_from: from, time_till: till,
|
|
121
|
+
limit: limit }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def tag_values(record, name)
|
|
127
|
+
Array(record["tags"]).filter_map do |tag|
|
|
128
|
+
tag["value"] if tag.is_a?(Hash) && tag["tag"] == name
|
|
129
|
+
end.uniq
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def managed_key(record)
|
|
133
|
+
values = tag_values(record, "zabbix_manager_id")
|
|
134
|
+
matches = values & @keys
|
|
135
|
+
raise Conflict, "trigger has multiple line managed keys" if matches.any? && values.length > 1
|
|
136
|
+
|
|
137
|
+
matches.first
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_receipt!(record)
|
|
141
|
+
Validation.positive_id!(record["triggerid"], "triggerid")
|
|
142
|
+
rescue Invalid => error
|
|
143
|
+
raise ProtocolError, error.message
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def timestamp(value, name)
|
|
147
|
+
value = value.to_i if value.is_a?(Time)
|
|
148
|
+
raise Invalid, "#{name} must be a non-negative Integer or Time" unless value.is_a?(Integer) && value >= 0
|
|
149
|
+
|
|
150
|
+
value
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_problems!(records, from, till)
|
|
154
|
+
records.each do |record|
|
|
155
|
+
Validation.positive_id!(record["eventid"], "eventid")
|
|
156
|
+
clock = record["clock"]
|
|
157
|
+
unless (clock.is_a?(Integer) || clock.is_a?(String)) && clock.to_s.match?(/\A\d+\z/) &&
|
|
158
|
+
clock.to_i.between?(from, till)
|
|
159
|
+
raise ProtocolError, "problem.get returned an invalid or out-of-range clock"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
ids = records.map { |record| record["eventid"].to_s }
|
|
163
|
+
raise ProtocolError, "problem.get returned duplicate event IDs" unless ids.uniq == ids
|
|
164
|
+
rescue Invalid => error
|
|
165
|
+
raise ProtocolError, error.message
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
class Monitoring
|
|
5
|
+
# 统一生成触发器属性,供预览和实际执行复用。
|
|
6
|
+
# 状态触发器 ID 确认之前,依赖以指标类型名称表示。
|
|
7
|
+
# @api private
|
|
8
|
+
class LinePlan
|
|
9
|
+
def initialize(client, line, host, items, icmp_item)
|
|
10
|
+
@client, @line, @host, @items, @icmp_item = client, line, host, items, icmp_item
|
|
11
|
+
@expressions = Expressions.new(client, host[:host])
|
|
12
|
+
@line_id = line.resolved_id(host[:hostid])
|
|
13
|
+
validate_metadata_version!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 纯本地构造计划,不写远端;依赖保留为指标名称,执行时转换为已确认 triggerid。
|
|
17
|
+
# @return [Hash] 端点身份、监控项 ID、触发器属性及依赖计划
|
|
18
|
+
def to_h
|
|
19
|
+
triggers = {}
|
|
20
|
+
triggers[:interface_status] = status_trigger if @items[:status]
|
|
21
|
+
triggers[:bandwidth] = bandwidth_trigger
|
|
22
|
+
triggers[:low_traffic] = low_trigger if @line.low_traffic
|
|
23
|
+
triggers[:reachability] = reachability_trigger if @icmp_item
|
|
24
|
+
dependencies = triggers.keys.to_h do |kind|
|
|
25
|
+
[kind, @items[:status] && kind != :interface_status ? [:interface_status] : []]
|
|
26
|
+
end
|
|
27
|
+
itemids = @items.transform_values { |item| item.fetch(:itemid).to_i }
|
|
28
|
+
itemids[:reachability] = @icmp_item[:itemid].to_i if @icmp_item&.dig(:itemid)
|
|
29
|
+
{
|
|
30
|
+
hostid: @host[:hostid].to_i, host: @host[:host], interface: @line.interface_name, line_id: @line_id,
|
|
31
|
+
itemids: itemids, triggers: triggers, dependencies: dependencies, icmp_item: @icmp_item
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_metadata_version!
|
|
38
|
+
{ opdata: "4.4", event_name: "5.2" }.each do |field, minimum|
|
|
39
|
+
next unless @line.metadata.key?(field) && @expressions.version < Gem::Version.new(minimum)
|
|
40
|
+
|
|
41
|
+
raise Invalid, "#{field} requires Zabbix #{minimum} or newer"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bandwidth_trigger
|
|
46
|
+
attributes = Thresholds.new(@client).prepare_triggers(
|
|
47
|
+
host: @host, interface: @line.interface(hostid: @host[:hostid]),
|
|
48
|
+
items: { inbound_bps: @items[:inbound], outbound_bps: @items[:outbound] },
|
|
49
|
+
thresholds: { bandwidth: @line.threshold }
|
|
50
|
+
).fetch(:bandwidth)
|
|
51
|
+
attributes[:expression] = guarded(attributes[:expression])
|
|
52
|
+
attributes[:recovery_expression] = guarded(attributes[:recovery_expression])
|
|
53
|
+
decorate(attributes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def low_trigger
|
|
57
|
+
config = @line.low_traffic
|
|
58
|
+
problem = traffic_expression(config[:window], "<", config[:below_bps], "and")
|
|
59
|
+
recovery = traffic_expression(config[:recovery_window], ">=", config[:recovery_bps], "or")
|
|
60
|
+
trigger(:low_traffic, guarded(problem), guarded(recovery), "low traffic")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status_trigger
|
|
64
|
+
key = @items.fetch(:status).fetch(:key_)
|
|
65
|
+
trigger(:interface_status, compare(key, "last", nil, "<>", 1),
|
|
66
|
+
compare(key, "last", nil, "=", 1), "interface down")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reachability_trigger
|
|
70
|
+
key = @icmp_item.fetch(:attributes).fetch(:key_)
|
|
71
|
+
trigger(:reachability, compare(key, "max", "5m", "=", 0),
|
|
72
|
+
compare(key, "min", "2m", "=", 1), "ICMP unreachable")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def trigger(kind, problem, recovery, label)
|
|
76
|
+
key = "interface:#{@line_id}:#{kind}"
|
|
77
|
+
raise Invalid, "managed_key is too long" if key.length > Triggers::MAX_MANAGED_KEY_LENGTH
|
|
78
|
+
|
|
79
|
+
decorate(
|
|
80
|
+
hostid: @host[:hostid], managed_key: key,
|
|
81
|
+
description: "#{@line.interface_name} #{label}", expression: problem,
|
|
82
|
+
recovery_mode: 1, recovery_expression: recovery, priority: @line.threshold[:priority], manual_close: 1,
|
|
83
|
+
tags: [
|
|
84
|
+
{ tag: "managed_by", value: "zabbix_manager" }, { tag: "interface", value: @line.interface_name },
|
|
85
|
+
{ tag: "metric", value: kind.to_s }
|
|
86
|
+
] + @line.threshold[:tags]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def decorate(attributes)
|
|
91
|
+
attributes.merge(@line.metadata).merge(
|
|
92
|
+
status: 0, tags: (attributes[:tags] + [{ tag: "line_id", value: @line_id }]).uniq
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def guarded(expression)
|
|
97
|
+
return expression unless @items[:speed]
|
|
98
|
+
|
|
99
|
+
"(#{expression}) and #{compare(@items[:speed][:key_], 'last', nil, '>', 0)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def traffic_expression(window, operator, value, joiner)
|
|
103
|
+
%i[inbound outbound].map do |direction|
|
|
104
|
+
compare(@items.fetch(direction).fetch(:key_), "avg", window, operator, value)
|
|
105
|
+
end.join(" #{joiner} ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def compare(...)
|
|
109
|
+
@expressions.compare(...)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|