zabbix_manager 6.0.1 → 6.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad8094e06c6d99c1ff334dd2cf9d86c40bb9f45467d811e53be92d1ffcf05bb3
4
- data.tar.gz: 90375dfe1473875e17e2b8e82b3f947818076239b1ceb3d2f868001b600f4c12
3
+ metadata.gz: 7bdb7262133718703b6cdd1a245914b33a3efa6d989b6a22877576ad18c83302
4
+ data.tar.gz: c5786d77070a1d5da5d34afc488feb21c0b7d1a2f7d91cdb808bdcb1c78469ef
5
5
  SHA512:
6
- metadata.gz: 629f1278b1f79497c95de696db6c46169b75995c34e69993508c12672227b2ebf3f8264a9ba228e001c83a68f2c352f3aa80a771ff729effd95b7d50e6ca0e2f
7
- data.tar.gz: d0d7377ba225db508bcc46bf177a65518dcdade2beb6190b92ed2d5bb1b17367748f2f2f3a005daf46af329437c8ec4d80f3e452f626bda440bd96b60a442945
6
+ metadata.gz: abb93fb5caaabef8b9e5ddc44c9c8d7aeddfa872490c7aedfb952d985395e57ef985d1486c1ae351786a14844c7b4c01cc783fcd98b9ab889aea7ab3ff96a5b6
7
+ data.tar.gz: 600d3d5797909d4ce7c453536336700303a9b3bea45e7b4b8ca1538b5125c5a6df08db176de233c8dc457efb60357fc83d10c32af2a9c604aed74db758025ccf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 6.0.3 (2026-10-11)
4
+
5
+ * Enable HTTPS certificate verification by default; use `ca_file:` for private certificate authorities.
6
+ * Freeze the negotiated API version and parse its major component once so authentication cannot drift between requests.
7
+ * Canonicalize resource IDs before queries, writes, receipt checks and upsert locking; deduplicate equivalent user targets.
8
+ * Snapshot interface monitoring definitions and serialize their execution with other monitoring workflows.
9
+ * Verify managed trigger identities and ownership before updates, including interface preflight before item writes; reject contradictory line tags during local validation.
10
+ * Validate complete group lookup responses before creating missing groups and verify host/global macro lookup identities.
11
+
12
+ ## 6.0.2 (2026-10-05)
13
+
14
+ * Use the repository account name for Gem author metadata.
15
+
3
16
  ## 6.0.1 (2026-10-05)
4
17
 
5
18
  * Discover batch item keys once per host and validate returned ownership, identities and duplicate targets before writes.
data/README.md CHANGED
@@ -93,7 +93,7 @@ Logging uses `ActiveSupport::Logger` and `ActiveSupport::TaggedLogging`; structu
93
93
 
94
94
  Passwords, API tokens, SNMPv3 authentication/privacy passphrases, authorization values, cookies, and session IDs are filtered. Quoted diagnostic credentials are filtered as complete values, including escaped quotes and backslashes. Request parameters and response bodies are not logged. API exceptions expose the server error code and request ID, without remote messages or data that might echo arbitrary secrets. Malformed or mismatched JSON-RPC responses and invalid mutation ID receipts raise `ProtocolError < TransportError`: a write may already have happened, so it must not be blindly replayed.
95
95
 
96
- HTTPS certificate verification is disabled by default as required by this project. Set `verify_ssl: true` (and optionally `ca_file:`) to enable peer verification.
96
+ HTTPS certificate verification is enabled by default. Use `ca_file:` for a private certificate authority. An explicit `verify_ssl: false` disables verification and emits a warning when logging is enabled.
97
97
 
98
98
  Zabbix 7 API-token requests need the `Authorization` header, so they cannot share that header with HTTP Basic authentication. The client rejects that combination instead of silently overwriting either credential.
99
99
 
@@ -123,7 +123,7 @@ zabbix.monitoring.reconcile_line(
123
123
  )
124
124
  ```
125
125
 
126
- The lookup accepts full and abbreviated interface names such as `Ten-GigabitEthernet1/0/49` and `Te1/0/49`. It refuses zero or multiple direction matches instead of selecting an item by response order. Trigger ownership comes from the managed identity tag; matching descriptions alone never adopt unrelated triggers.
126
+ The lookup accepts full and abbreviated interface names such as `Ten-GigabitEthernet1/0/49` and `Te1/0/49`. It refuses zero or multiple direction matches instead of selecting an item by response order. Trigger ownership requires the requested managed identity and a unique matching `managed_by` tag. Contradictory `line_id` tags are rejected. Interface workflows check ownership before item writes, and trigger upserts check it again before updates; matching descriptions alone never adopt unrelated triggers.
127
127
 
128
128
  Use `reconcile_lines(lines)` for imports. It reuses host and item discovery results within the batch, avoiding a full `item.get` scan for every line.
129
129
 
@@ -232,7 +232,7 @@ Invalid caller input raises `ZabbixManager::Invalid`, ambiguous remote ownership
232
232
 
233
233
  `traffic.series` reads explicit item IDs; `traffic.for_interface` discovers both interface directions. Both expose missing data and query truncation, preserve numeric precision, and keep raw history separate from hourly trends. They never substitute zero or a stale last value for missing samples.
234
234
 
235
- Device and line definitions are copied before validation and discovery. Changes to caller-owned strings or nested fields during reconciliation do not alter the validated definition.
235
+ Device, line and interface definitions are copied before validation and discovery. Changes to caller-owned strings or nested fields during reconciliation do not alter the validated definition.
236
236
 
237
237
  Multiword resource accessors use Ruby snake_case: `host_groups`, `host_interfaces`, `http_tests`, `media_types`, `proxy_groups`, `user_groups`, `user_macros`, `value_maps`, and `discovery_rules`.
238
238
 
@@ -248,7 +248,7 @@ The focused modules expose explicit operations:
248
248
 
249
249
  * `triggers.for_host`, `triggers.upsert_for_host`, `triggers.add_dependencies`, `triggers.replace_dependencies`, `triggers.set_status`, `triggers.delete_many`
250
250
 
251
- `items.upsert_many` discovers existing keys once per host before any write, then preserves input write order. Item and interface batch definitions are copied before discovery, including nested strings. Single-host helpers require a positive integer `hostid` or its decimal string representation. Identity lookup and batch helpers reject malformed query containers or required IDs with `ProtocolError`; `get_raw` preserves native response pass-through.
251
+ `items.upsert_many` discovers existing keys once per host before any write, then preserves input write order. Item and interface batch definitions are copied before discovery, including nested strings. Single-host helpers require a positive integer `hostid` or its decimal string representation. Equivalent resource IDs are normalized before locking, queries, writes and receipt checks. Identity lookup and batch helpers reject malformed query containers or required IDs with `ProtocolError`; `get_raw` preserves native response pass-through.
252
252
 
253
253
 
254
254
  ## Supported Ruby Versions
@@ -15,7 +15,7 @@ class ZabbixManager
15
15
  # @return [Hash] 应用集唯一查询条件
16
16
  def identity_filter(data)
17
17
  attributes = data.deep_symbolize_keys
18
- { name: attributes.fetch(:name), hostid: attributes.fetch(:hostid) }
18
+ { name: attributes.fetch(:name), hostid: normalized_id_attribute(attributes.fetch(:hostid), "hostid") }
19
19
  end
20
20
  end
21
21
  end
@@ -76,7 +76,7 @@ class ZabbixManager
76
76
  # @return [Hash] 图形唯一查询条件
77
77
  def identity_filter(data)
78
78
  attributes = data.deep_symbolize_keys
79
- { name: attributes.fetch(:name), hostid: attributes.fetch(:hostid) }
79
+ { name: attributes.fetch(:name), hostid: normalized_id_attribute(attributes.fetch(:hostid), "hostid") }
80
80
  end
81
81
 
82
82
  # 图形所有者由 gitems 确定,hostid 仅用于调用方的身份查询。
@@ -19,41 +19,60 @@ class ZabbixManager
19
19
 
20
20
  # 批量解析主机群组名称并返回对应的群组 ID。
21
21
  # @param data [Array<String>, String] 待查询的群组名称
22
+ # @raise [ProtocolError] 远端群组身份或 ID 无效
23
+ # @raise [Conflict] 远端群组名称不唯一
22
24
  # @return [Array<Hash>, nil] 群组 ID 列表,未命中时返回 nil
23
25
  def get_hostgroup_ids(data)
24
26
  names = normalized_names(data)
25
27
  return nil if names.empty?
26
28
 
27
- groups = @client.api_request(
28
- method: "hostgroup.get",
29
- params: {
30
- output: %w[groupid name],
31
- filter: { name: names }
32
- }
33
- )
34
-
35
- groups.empty? ? nil : groups.map { |group| { groupid: group.fetch("groupid") } }
29
+ groups = groups_by_name(names)
30
+ groups.empty? ? nil : groups.values.map { |id| { groupid: id } }
36
31
  end
37
32
 
38
33
  # 批量查询并创建缺失的主机群组,避免逐名称重复查询。
39
34
  # @param data [Array<String>, String] 待确保存在的群组名称
35
+ # @raise [ProtocolError] 远端群组身份或 ID 无效
36
+ # @raise [Conflict] 远端群组名称不唯一
40
37
  # @return [Array<Hash>] 群组 ID 列表
41
38
  def get_or_create_host_groups(data)
42
39
  names = normalized_names(data)
43
- existing = @client.api_request(
44
- method: "hostgroup.get",
45
- params: { output: %w[groupid name], filter: { name: names } }
46
- ).index_by { |group| group.fetch("name") }
40
+ return [] if names.empty?
41
+
42
+ existing = groups_by_name(names)
47
43
 
48
44
  names.map do |name|
49
- group = existing[name]
50
- next({ groupid: response_identifier(group["groupid"]).to_s }) if group
45
+ id = existing[name]
46
+ next({ groupid: id }) if id
51
47
 
52
48
  result = @client.api_request(method: "hostgroup.create", params: { name: name })
53
49
  { groupid: response_id(result).to_s }
54
50
  end
55
51
  end
56
52
 
53
+ # 全部查询结果先核对身份和 ID,损坏或重名响应不能触发部分创建。
54
+ private def groups_by_name(names)
55
+ rows = response_objects(@client.api_request(
56
+ method: "hostgroup.get",
57
+ params: { output: %w[groupid name], filter: { name: names } }
58
+ ))
59
+ requested = names.to_h { |name| [name, true] }
60
+ ids = {}
61
+ rows.each_with_object({}) do |group, result|
62
+ name = group["name"]
63
+ unless name.is_a?(String) && requested.key?(name)
64
+ raise ProtocolError, "hostgroup.get returned a group outside the requested names"
65
+ end
66
+
67
+ id = response_identifier(group["groupid"]).to_s
68
+ raise Conflict, "multiple host groups share the same name" if result.key?(name)
69
+ raise ProtocolError, "hostgroup.get returned duplicate group IDs" if ids.key?(id)
70
+
71
+ ids[id] = true
72
+ result[name] = id
73
+ end
74
+ end
75
+
57
76
  # 清理、去空并去重主机群组名称。
58
77
  # @param data [Array<String>, String] 原始群组名称
59
78
  # @return [Array<String>] 规范化后的群组名称
@@ -24,7 +24,7 @@ class ZabbixManager
24
24
  # 查询指定主机的全部接口,可选择同时返回关联监控项。
25
25
  # @return [Array<Hash>]
26
26
  def for_host(hostid, select_items: nil)
27
- positive_id_attribute(hostid, "hostid")
27
+ hostid = normalized_id_attribute(hostid, "hostid")
28
28
 
29
29
  params = { hostids: hostid, output: "extend" }
30
30
  params[:selectItems] = select_items if select_items
@@ -56,6 +56,7 @@ class ZabbixManager
56
56
  # 幂等创建或更新一个主机的接口集合。
57
57
  # @return [Array<Integer>]
58
58
  def reconcile_for_host(hostid:, interfaces:)
59
+ hostid = normalized_id_attribute(hostid, "hostid")
59
60
  @client.with_upsert_lock("host-interfaces:#{hostid}") do
60
61
  plan = plan_for_host(hostid: hostid, interfaces: interfaces)
61
62
  apply_plan(hostid: hostid, plan: plan)
@@ -105,6 +106,9 @@ class ZabbixManager
105
106
  raise Invalid, "interface must be a hash" unless interface.is_a?(Hash)
106
107
 
107
108
  attributes = interface.deep_symbolize_keys.deep_dup
109
+ if attributes.key?(:interfaceid)
110
+ attributes[:interfaceid] = normalized_id_attribute(attributes[:interfaceid], "interfaceid")
111
+ end
108
112
  if attributes.key?(:type)
109
113
  type = integer_attribute(attributes[:type], "interface type")
110
114
  raise Invalid, "interface type must be 1, 2, 3, or 4" unless [1, 2, 3, 4].include?(type)
@@ -232,7 +236,8 @@ class ZabbixManager
232
236
  endpoint_field = values[:useip].to_i == 1 ? :ip : :dns
233
237
  raise Invalid, "interface #{endpoint_field} is required" if values[endpoint_field].blank?
234
238
 
235
- "endpoint:#{values[:type]}:#{values[:useip]}:#{values[endpoint_field]}:#{values[:port]}"
239
+ "endpoint:#{integer_attribute(values[:type], 'interface type')}:" \
240
+ "#{integer_attribute(values[:useip], 'interface useip')}:#{values[endpoint_field]}:#{values[:port]}"
236
241
  end
237
242
 
238
243
  # 更新已属于目标主机的接口。
@@ -23,10 +23,12 @@ class ZabbixManager
23
23
 
24
24
  # 按主机 ID 查询完整主机及接口、群组和模板。
25
25
  def dump_by_id(data)
26
+ attributes = data.deep_symbolize_keys
27
+ id = normalized_id_attribute(attributes[key.to_sym], key)
26
28
  @client.api_request(
27
29
  method: "host.get",
28
30
  params: {
29
- filter: { key.to_sym => data[key.to_sym] },
31
+ filter: { key.to_sym => id },
30
32
  output: "extend",
31
33
  selectGroups: "extend",
32
34
  selectInterfaces: "extend",
@@ -145,7 +147,7 @@ class ZabbixManager
145
147
  # 按主机 ID 查询唯一主机,并返回表达式需要的技术名称。
146
148
  # @return [Hash, nil]
147
149
  def find_by_id(hostid)
148
- positive_id_attribute(hostid, "hostid")
150
+ hostid = normalized_id_attribute(hostid, "hostid")
149
151
  find_host_by_filter(hostid: hostid)
150
152
  end
151
153
 
@@ -26,7 +26,7 @@ class ZabbixManager
26
26
  # @return [Hash] Web 场景唯一查询条件
27
27
  def identity_filter(data)
28
28
  attributes = data.deep_symbolize_keys
29
- { name: attributes.fetch(:name), hostid: attributes.fetch(:hostid) }
29
+ { name: attributes.fetch(:name), hostid: normalized_id_attribute(attributes.fetch(:hostid), "hostid") }
30
30
  end
31
31
  end
32
32
  end
@@ -35,13 +35,13 @@ class ZabbixManager
35
35
  # @return [Hash] 名称和所属主机组成的精确过滤条件
36
36
  def identity_filter(data)
37
37
  attributes = data.deep_symbolize_keys
38
- { name: attributes.fetch(:name), hostid: attributes.fetch(:hostid) }
38
+ { name: attributes.fetch(:name), hostid: normalized_id_attribute(attributes.fetch(:hostid), "hostid") }
39
39
  end
40
40
 
41
41
  # 查询主机监控项,可按稳定 key 集合过滤并选择关联对象。
42
42
  # @return [Array<Hash>]
43
43
  def for_host(hostid, keys: nil, output: "extend", select_preprocessing: nil, select_tags: nil)
44
- positive_id_attribute(hostid, "hostid")
44
+ hostid = normalized_id_attribute(hostid, "hostid")
45
45
 
46
46
  params = { hostids: hostid, output: output }
47
47
  params[:filter] = { key_: keys } if keys.present?
@@ -68,13 +68,13 @@ class ZabbixManager
68
68
  # @return [Array<Integer>]
69
69
  def upsert_many(collection)
70
70
  items = Array(collection).map { |data| validate_item!(data) }
71
- identities = items.map { |item| [positive_id_attribute(item[:hostid], "hostid").to_s, item[:key_]] }
71
+ identities = items.map { |item| [item[:hostid].to_s, item[:key_]] }
72
72
  duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
73
73
  raise Invalid, "duplicate hostid + key_ identity #{duplicate.join(":")}" if duplicate
74
74
 
75
75
  existing = batch_items(items)
76
76
  plans = items.map do |item|
77
- identity = [positive_id_attribute(item[:hostid], "hostid").to_s, item[:key_]]
77
+ identity = [item[:hostid].to_s, item[:key_]]
78
78
  plan_item(item, existing[identity])
79
79
  end
80
80
  if block_given?
@@ -116,7 +116,7 @@ class ZabbixManager
116
116
  # 方向和接口精确匹配由 Monitoring 业务层负责。
117
117
  # @return [Array<Hash>]
118
118
  def monitored_traffic_candidates(hostid)
119
- positive_id_attribute(hostid, "hostid")
119
+ hostid = normalized_id_attribute(hostid, "hostid")
120
120
  @client.api_request(
121
121
  method: "item.get",
122
122
  params: {
@@ -155,7 +155,7 @@ class ZabbixManager
155
155
 
156
156
  # 每个主机只读取一次期望 key 集合,全部发现和校验完成后才开始写入。
157
157
  def batch_items(items)
158
- groups = items.group_by { |item| positive_id_attribute(item[:hostid], "hostid").to_s }
158
+ groups = items.group_by { |item| item[:hostid].to_s }
159
159
  ids = {}
160
160
  groups.each_with_object({}) do |(owner, definitions), result|
161
161
  hostid = definitions.first[:hostid]
@@ -218,7 +218,7 @@ class ZabbixManager
218
218
  raise Invalid, "item must be a hash" unless data.is_a?(Hash)
219
219
 
220
220
  attributes = data.deep_symbolize_keys.deep_dup
221
- positive_id_attribute(attributes[:hostid], "hostid")
221
+ attributes[:hostid] = normalized_id_attribute(attributes[:hostid], "hostid")
222
222
  %i[key_ name].each do |field|
223
223
  unless attributes[field].is_a?(String) && attributes[field].present?
224
224
  raise Invalid, "#{field} must be a non-empty string"
@@ -259,7 +259,7 @@ class ZabbixManager
259
259
 
260
260
  # 回查监控项归属,阻止共享高权限令牌跨主机修改。
261
261
  def verify_host_ownership!(hostid, ids)
262
- positive_id_attribute(hostid, "hostid")
262
+ hostid = normalized_id_attribute(hostid, "hostid")
263
263
 
264
264
  result = @client.api_request(
265
265
  method: "item.get", params: { hostids: hostid, itemids: ids, output: ["itemid"] }
@@ -21,7 +21,7 @@ class ZabbixManager
21
21
  raise Invalid, "roleid is required" if attributes[:roleid].blank?
22
22
  raise Invalid, "rules is required" if attributes[:rules].blank?
23
23
 
24
- normalized_ids([attributes[:roleid]])
24
+ attributes[:roleid] = normalized_id_attribute(attributes[:roleid], "roleid")
25
25
 
26
26
  result = @client.api_request(
27
27
  method: "role.update",
@@ -26,11 +26,13 @@ class ZabbixManager
26
26
  # @raise [TransportError] HTTP 或网络请求失败,可能无法确认远端结果
27
27
  # @return [Array<Hash>] 匹配的触发器及其关联对象
28
28
  def dump_by_id(data)
29
+ attributes = data.deep_symbolize_keys
30
+ id = normalized_id_attribute(attributes[key.to_sym], key)
29
31
  @client.api_request(
30
32
  method: "trigger.get",
31
33
  params: {
32
34
  filter: {
33
- key.to_sym => data[key.to_sym]
35
+ key.to_sym => id
34
36
  },
35
37
  output: "extend",
36
38
  selectItems: "extend",
@@ -43,7 +45,7 @@ class ZabbixManager
43
45
  # @return [Array<Hash>]
44
46
  def for_host(hostid, output: "extend", select_items: nil, select_functions: nil,
45
47
  select_dependencies: nil, select_tags: nil, filter: nil)
46
- positive_id_attribute(hostid, "hostid")
48
+ hostid = normalized_id_attribute(hostid, "hostid")
47
49
 
48
50
  params = { hostids: hostid, output: output }
49
51
  params[:selectItems] = select_items if select_items
@@ -57,7 +59,7 @@ class ZabbixManager
57
59
  # 按主机、描述和可选标签查询唯一触发器。
58
60
  # @return [Hash, nil]
59
61
  def find_for_host(hostid:, description:, tags: nil)
60
- positive_id_attribute(hostid, "hostid")
62
+ hostid = normalized_id_attribute(hostid, "hostid")
61
63
  params = {
62
64
  hostids: hostid,
63
65
  filter: { description: description },
@@ -80,9 +82,13 @@ class ZabbixManager
80
82
  # 展开表达式并读取依赖,供不确定写入后的完整期望状态核对。
81
83
  # @param hostid [Integer, String] 触发器所属主机
82
84
  # @param managed_key [String] 稳定、非秘密的受管身份
85
+ # @param managed_by [String, nil] 指定时必须与远端唯一管理方一致
86
+ # @param line_id [String, nil] 指定时拒绝矛盾的远端线路标签;兼容未标记的旧线路
87
+ # @raise [ProtocolError] 返回标签格式无效或受管身份不匹配
88
+ # @raise [Conflict] 受管身份不唯一或归属另一管理方、线路
83
89
  # @return [Hash, nil]
84
- def find_managed_for_host(hostid:, managed_key:)
85
- positive_id_attribute(hostid, "hostid")
90
+ def find_managed_for_host(hostid:, managed_key:, managed_by: nil, line_id: nil)
91
+ hostid = normalized_id_attribute(hostid, "hostid")
86
92
  result = @client.api_request(
87
93
  method: "trigger.get",
88
94
  params: {
@@ -95,7 +101,10 @@ class ZabbixManager
95
101
  }
96
102
  )
97
103
  response_objects(result)
98
- result.each { |trigger| response_identifier(trigger["triggerid"]) }
104
+ result.each do |trigger|
105
+ response_identifier(trigger["triggerid"])
106
+ validate_managed_identity!(trigger, managed_key: managed_key, managed_by: managed_by, line_id: line_id)
107
+ end
99
108
  raise Conflict, "multiple triggers use managed key #{managed_key}" if result.length > 1
100
109
 
101
110
  result.first
@@ -123,7 +132,7 @@ class ZabbixManager
123
132
  # @return [Integer]
124
133
  def upsert_for_host(data)
125
134
  attributes = data.deep_symbolize_keys.deep_dup
126
- positive_id_attribute(attributes[:hostid], "hostid")
135
+ attributes[:hostid] = normalized_id_attribute(attributes[:hostid], "hostid")
127
136
  lock_key = "trigger:#{attributes[:hostid]}:#{attributes[:managed_key] || attributes[:description]}"
128
137
  @client.with_upsert_lock(lock_key) { perform_upsert_for_host(attributes) }
129
138
  end
@@ -197,7 +206,7 @@ class ZabbixManager
197
206
  attributes = data.deep_symbolize_keys
198
207
  hostid = attributes.delete(:hostid)
199
208
  managed_key = attributes.delete(:managed_key)&.to_s
200
- positive_id_attribute(hostid, "hostid")
209
+ hostid = normalized_id_attribute(hostid, "hostid")
201
210
  raise Invalid, "description is required" if attributes[:description].blank?
202
211
  raise Invalid, "expression is required" if attributes[:expression].blank?
203
212
 
@@ -208,7 +217,10 @@ class ZabbixManager
208
217
  tag[:tag].to_s == "zabbix_manager_id" || tag["tag"].to_s == "zabbix_manager_id"
209
218
  end
210
219
  attributes[:tags] << { tag: "zabbix_manager_id", value: managed_key }
211
- current = find_managed_for_host(hostid: hostid, managed_key: managed_key)
220
+ current = find_managed_for_host(
221
+ hostid: hostid, managed_key: managed_key,
222
+ managed_by: desired_tag_value(attributes, "managed_by"), line_id: desired_tag_value(attributes, "line_id")
223
+ )
212
224
  else
213
225
  ownership_tags = Array(attributes[:tags]).select do |tag|
214
226
  tag[:tag].to_s == "managed_by" || tag["tag"].to_s == "managed_by"
@@ -250,7 +262,10 @@ class ZabbixManager
250
262
  delays = @client.options.fetch(:uncertain_write_delays, DEFAULT_UNCERTAIN_WRITE_DELAYS)
251
263
  delays.each do |delay|
252
264
  sleep(delay) if delay.positive?
253
- current = find_managed_for_host(hostid: hostid, managed_key: managed_key)
265
+ current = find_managed_for_host(
266
+ hostid: hostid, managed_key: managed_key,
267
+ managed_by: desired_tag_value(attributes, "managed_by"), line_id: desired_tag_value(attributes, "line_id")
268
+ )
254
269
  if current && recovered_trigger_matches?(current, attributes)
255
270
  response_identifier(current["triggerid"])
256
271
  return current
@@ -261,6 +276,34 @@ class ZabbixManager
261
276
  nil
262
277
  end
263
278
 
279
+ # 原生标签过滤器不能代替本地身份和管理权校验。
280
+ private def validate_managed_identity!(trigger, managed_key:, managed_by:, line_id:)
281
+ tags = trigger["tags"]
282
+ unless tags.is_a?(Array) && tags.all? do |tag|
283
+ tag.is_a?(Hash) && tag["tag"].is_a?(String) && tag["value"].is_a?(String)
284
+ end
285
+ raise ProtocolError, "invalid trigger response: tags must contain string tag and value fields"
286
+ end
287
+
288
+ values = ->(name) { tags.filter_map { |tag| tag["value"] if tag["tag"] == name } }
289
+ unless values.call("zabbix_manager_id") == [managed_key]
290
+ raise ProtocolError, "trigger.get returned an unexpected managed key"
291
+ end
292
+ if managed_by && values.call("managed_by") != [managed_by]
293
+ raise Conflict, "trigger managed key belongs to another owner"
294
+ end
295
+
296
+ lines = values.call("line_id")
297
+ raise Conflict, "trigger managed key belongs to another line" if line_id && lines.any? && lines != [line_id]
298
+ end
299
+
300
+ private def desired_tag_value(attributes, name)
301
+ values = Array(attributes[:tags]).filter_map { |tag| tag[:value] if tag[:tag] == name }
302
+ raise Invalid, "#{name} must have one string value" if values.length > 1 || values.any? { |v| !v.is_a?(String) }
303
+
304
+ values.first
305
+ end
306
+
264
307
  private def recovered_trigger_matches?(current, attributes)
265
308
  desired_tags = Array(attributes[:tags]).map(&:deep_symbolize_keys)
266
309
  actual_tags = Array(current["tags"]).map(&:deep_symbolize_keys)
@@ -281,7 +324,7 @@ class ZabbixManager
281
324
 
282
325
  # 回查触发器归属,阻止共享高权限令牌跨主机修改。
283
326
  private def verify_host_ownership!(hostid, ids)
284
- positive_id_attribute(hostid, "hostid")
327
+ hostid = normalized_id_attribute(hostid, "hostid")
285
328
 
286
329
  result = @client.api_request(
287
330
  method: "trigger.get", params: { hostids: hostid, triggerids: ids, output: ["triggerid"] }
@@ -21,26 +21,33 @@ class ZabbixManager
21
21
  # @return [Hash] 主机宏复合身份
22
22
  def identity_filter(data)
23
23
  attributes = data.deep_symbolize_keys
24
- { macro: attributes.fetch(:macro), hostid: attributes.fetch(:hostid) }
24
+ { macro: attributes.fetch(:macro), hostid: normalized_id_attribute(attributes.fetch(:hostid), "hostid") }
25
25
  end
26
26
 
27
+ # @raise [ProtocolError] 远端宏名称、归属或 ID 无效
28
+ # @raise [Conflict] 宏身份不唯一
27
29
  # @return [Integer, nil] 唯一主机宏 ID
28
30
  def get_id(data)
29
31
  attributes = identity_filter(data)
30
- unique_macro_id(get_full_data(attributes), "hostmacroid")
32
+ unique_macro_id(get_full_data(attributes), "hostmacroid", attributes)
31
33
  end
32
34
 
35
+ # @raise [ProtocolError] 远端宏名称或 ID 无效
36
+ # @raise [Conflict] 宏身份不唯一
33
37
  # @return [Integer, nil] 唯一全局宏 ID
34
38
  def get_id_global(data)
35
39
  attributes = data.deep_symbolize_keys
36
- unique_macro_id(get_full_data_global(macro: attributes.fetch(:macro)), "globalmacroid")
40
+ identity = { macro: attributes.fetch(:macro) }
41
+ unique_macro_id(get_full_data_global(identity), "globalmacroid", identity)
37
42
  end
38
43
 
39
44
  # @return [Array<Hash>] 主机宏查询结果
40
45
  def get_full_data(data)
41
46
  attributes = data.deep_symbolize_keys
42
47
  params = { filter: attributes.except(:hostid) }
43
- params[:hostids] = attributes[:hostid] if attributes.key?(:hostid)
48
+ if attributes.key?(:hostid)
49
+ params[:hostids] = normalized_id_attribute(attributes[:hostid], "hostid")
50
+ end
44
51
  get_raw(params)
45
52
  end
46
53
 
@@ -73,12 +80,12 @@ class ZabbixManager
73
80
 
74
81
  # @return [Integer, nil] 更新主机宏 ID
75
82
  def update(data)
76
- write_macro("update", data, "hostmacroids", expected: [data.with_indifferent_access[:hostmacroid]])
83
+ update_macro("update", data, "hostmacroid")
77
84
  end
78
85
 
79
86
  # @return [Integer, nil] 更新全局宏 ID
80
87
  def update_global(data)
81
- write_macro("updateglobal", data, "globalmacroids", expected: [data.with_indifferent_access[:globalmacroid]])
88
+ update_macro("updateglobal", data, "globalmacroid")
82
89
  end
83
90
 
84
91
  # @return [Integer, nil] 已存在或新建全局宏 ID
@@ -95,8 +102,25 @@ class ZabbixManager
95
102
 
96
103
  private
97
104
 
98
- def unique_macro_id(result, id_field)
99
- response_objects(result)
105
+ def update_macro(operation, data, id_field)
106
+ attributes = data.deep_symbolize_keys
107
+ id = attributes[id_field.to_sym] = normalized_id_attribute(attributes[id_field.to_sym], id_field)
108
+ write_macro(operation, attributes, "#{id_field}s", expected: [id])
109
+ end
110
+
111
+ # 名称过滤器和 hostids 只是远端查询条件,不能代替本地身份核对。
112
+ def unique_macro_id(result, id_field, identity)
113
+ response_objects(result).each do |macro|
114
+ response_identifier(macro[id_field], id_field)
115
+ unless macro["macro"].is_a?(String) && macro["macro"] == identity[:macro]
116
+ raise ProtocolError, "usermacro.get returned an unexpected macro identity"
117
+ end
118
+ next unless identity.key?(:hostid)
119
+
120
+ unless response_identifier(macro["hostid"], "hostid") == identity[:hostid].to_i
121
+ raise ProtocolError, "usermacro.get returned a macro outside the requested host"
122
+ end
123
+ end
100
124
  raise Conflict, "multiple macros match the requested identity" if result.length > 1
101
125
 
102
126
  response_identifier(result.first[id_field], id_field) if result.first
@@ -18,11 +18,13 @@ class ZabbixManager
18
18
  # @return [Integer, nil] 首个更新用户的 ID
19
19
  def update_medias(data)
20
20
  attributes = data.deep_symbolize_keys
21
- ids = normalized_ids(attributes.fetch(:userids))
21
+ ids = Array.wrap(attributes.fetch(:userids)).map { |id| normalized_id_attribute(id, "userid") }
22
+ normalized_ids(ids)
23
+ ids.uniq!(&:to_s)
22
24
  media_field = Gem::Version.new(@client.api_version) >= Gem::Version.new("5.4") ? :medias : :user_medias
23
25
  result = @client.api_request(
24
26
  method: "user.update",
25
- params: attributes.fetch(:userids).map do |userid|
27
+ params: ids.map do |userid|
26
28
  { userid: userid, media_field => attributes.fetch(:media) }
27
29
  end
28
30
  )
@@ -16,7 +16,10 @@ class ZabbixManager
16
16
  error: Logger::ERROR, fatal: Logger::FATAL, unknown: Logger::UNKNOWN }.freeze
17
17
  private_constant :LOG_LEVELS
18
18
 
19
- attr_reader :options, :logger, :api_version
19
+ attr_reader :options, :logger
20
+
21
+ # @return [String] 不可变的已协商版本,认证策略在连接期间保持稳定
22
+ attr_reader :api_version
20
23
 
21
24
  # 校验配置后立即查询服务器版本并完成认证。
22
25
  # 初始化失败时关闭连接;成功后的客户端生命周期由调用方负责。
@@ -199,7 +202,11 @@ class ZabbixManager
199
202
  def validate_api_version!
200
203
  match = @api_version.is_a?(String) && @api_version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
201
204
  raise ProtocolError, "Invalid API version response" unless match
202
- return if SUPPORTED_MAJOR_VERSIONS.cover?(match[1].to_i)
205
+
206
+ # 版本只在初始化时解析,后续请求不重复拆分字符串或改变认证策略。
207
+ @api_version = @api_version.dup.freeze
208
+ @api_major_version = match[1].to_i
209
+ return if SUPPORTED_MAJOR_VERSIONS.cover?(@api_major_version)
203
210
  raise ApiError, "Zabbix API version is not supported" unless @settings[:ignore_version]
204
211
 
205
212
  log(:warn, "version.unsupported", version: @api_version)
@@ -222,11 +229,11 @@ class ZabbixManager
222
229
  end
223
230
 
224
231
  def insecure_https?
225
- @transport.uri.scheme == "https" && @settings[:verify_ssl] != true
232
+ @transport.uri.scheme == "https" && @settings[:verify_ssl] == false
226
233
  end
227
234
 
228
235
  def api_major_version
229
- @api_version.to_s.split(".", 2).first.to_i
236
+ @api_major_version
230
237
  end
231
238
 
232
239
  def bearer_token_for(method)
@@ -203,12 +203,12 @@ class ZabbixManager
203
203
  ]
204
204
  end
205
205
 
206
- # 配置 HTTPS;证书校验默认关闭,按调用方选项开启。
206
+ # 配置 HTTPS;证书校验默认开启,仅在显式传入 false 时关闭。
207
207
  def configure_tls(http)
208
208
  return unless @uri.scheme == "https"
209
209
 
210
210
  http.use_ssl = true
211
- http.verify_mode = @options[:verify_ssl] == true ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
211
+ http.verify_mode = @options[:verify_ssl] == false ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
212
212
  http.ca_file = @options[:ca_file] if @options[:ca_file]
213
213
  end
214
214
 
@@ -166,6 +166,10 @@ class ZabbixManager
166
166
 
167
167
  { tag: tag[:tag], value: tag.fetch(:value, "") }
168
168
  end
169
+ # line_id 参与资源层的归属校验,矛盾值必须在首个远端写入之前拒绝。
170
+ line_ids = extra.filter_map { |tag| tag[:value] if tag[:tag] == "line_id" }.uniq
171
+ raise Invalid, "line_id must have one string value" if line_ids.length > 1
172
+
169
173
  (tags + extra).uniq
170
174
  end
171
175
 
@@ -121,12 +121,19 @@ class ZabbixManager
121
121
  end
122
122
 
123
123
  # 接口更新只增改,不自动删除远端对象;所有本地校验在首个写入之前完成。
124
+ # 快照调用方输入,再与设备和线路工作流共享实例互斥锁。
124
125
  # @param attributes [Hash] host、interface、按名称分组的原生 items 和 thresholds
125
126
  # @return [Hash] hostid、interface、itemids 和 triggerids
126
127
  # @raise [Invalid, Conflict] 输入、量纲或资源归属无效
127
128
  # @raise [ApiError, TransportError] 远端失败;已经确认的早期步骤不会被回滚
128
129
  def reconcile_interface(attributes)
129
- input = Validation.hash!(attributes, "interface monitoring")
130
+ input = Validation.hash!(attributes, "interface monitoring").deep_dup
131
+ @workflow_mutex.synchronize { apply_interface(input) }
132
+ end
133
+
134
+ private
135
+
136
+ def apply_interface(input)
130
137
  input.assert_valid_keys(:host, :interface, :items, :thresholds)
131
138
  supplied_host = Validation.hash!(input[:host], "host")
132
139
  Validation.host!(supplied_host)
@@ -140,6 +147,14 @@ class ZabbixManager
140
147
  validate_items: false
141
148
  )
142
149
  host = resolve_host(supplied_host, {})
150
+ triggers.each_value do |trigger|
151
+ ownership = { managed_by: "zabbix_manager" }
152
+ line_tag = trigger[:tags].find { |tag| tag[:tag] == "line_id" }
153
+ ownership[:line_id] = line_tag[:value] if line_tag
154
+ @manager.triggers.find_managed_for_host(
155
+ hostid: host[:hostid], managed_key: trigger[:managed_key], **ownership
156
+ )
157
+ end
143
158
  ids = @manager.items.upsert_many(items.values) do |effective|
144
159
  # 读取已有配置后再次校验最终量纲和数值类型,仍在首个 item 写入之前。
145
160
  triggers = @thresholds.prepare_triggers(
@@ -152,8 +167,6 @@ class ZabbixManager
152
167
  { hostid: host[:hostid].to_i, interface: interface[:name], itemids: itemids, triggerids: triggerids }
153
168
  end
154
169
 
155
- private
156
-
157
170
  def validate_devices!(devices)
158
171
  definitions = Validation.array!(devices, "devices").map { |device| Device.new(device) }
159
172
  validate_unique!(definitions.map(&:identity), "device host")
@@ -71,7 +71,7 @@ class ZabbixManager
71
71
  id = attributes[key.to_sym]
72
72
  raise Invalid, "#{key} is required" if id.blank?
73
73
 
74
- normalized_ids([id])
74
+ id = attributes[key.to_sym] = normalized_id_attribute(id, key)
75
75
 
76
76
  current = response_objects(dump_by_id(key.to_sym => id)).find do |item|
77
77
  response_identifier(item[key]) == id.to_i
@@ -96,6 +96,7 @@ class ZabbixManager
96
96
  id = attributes[key.to_sym]
97
97
  raise Invalid, "#{key} is required" if id.blank?
98
98
 
99
+ id = normalized_id_attribute(id, key)
99
100
  response_objects(get_raw(filter: { key.to_sym => id }, output: "extend"))
100
101
  end
101
102
 
@@ -190,6 +191,12 @@ class ZabbixManager
190
191
  id
191
192
  end
192
193
 
194
+ # 同一 ID 的正号和前导零只在输入边界出现,查询、写入和锁使用统一身份。
195
+ def normalized_id_attribute(value, name)
196
+ id = positive_id_attribute(value, name)
197
+ value.is_a?(Integer) ? id : id.to_s
198
+ end
199
+
193
200
  def validate_boolean!(value, name)
194
201
  raise Invalid, "#{name} must be true or false" unless value == true || value == false
195
202
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ZabbixManager
4
- VERSION = "6.0.1"
4
+ VERSION = "6.0.3"
5
5
  end
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
 
12
12
  spec.name = "zabbix_manager"
13
13
  spec.version = ZabbixManager::VERSION
14
- spec.authors = ["WENWU YAN"]
14
+ spec.authors = ["gatework"]
15
15
  spec.email = ["careline@foxmail.com"]
16
16
 
17
17
  spec.summary = "Ruby client and monitoring workflows for the Zabbix 4.0 through 7.x API"
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zabbix_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.1
4
+ version: 6.0.3
5
5
  platform: ruby
6
6
  authors:
7
- - WENWU YAN
7
+ - gatework
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z