net-connector 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/docs/VERIFICATION.md +9 -0
- data/docs/architecture.md +4 -2
- data/lib/net/connector/device/base.rb +3 -3
- data/lib/net/connector/device/running_config.rb +1 -1
- data/lib/net/connector/device/scope.rb +5 -1
- data/lib/net/connector/device/tftp/strategy.rb +1 -1
- data/lib/net/connector/engine/error/metadata.rb +10 -2
- data/lib/net/connector/engine/errors.rb +4 -2
- data/lib/net/connector/engine/known_hosts.rb +40 -21
- data/lib/net/connector/netdisco/inventory/http.rb +35 -21
- data/lib/net/connector/netdisco/worker.rb +2 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1feb1332558f1cad30b6bdb1b657c16cf3237d5ba975014204b320a94e0a84c
|
|
4
|
+
data.tar.gz: b7294d094cd32498f7c6e8539ecb40fbf227f9b4926772f93ccd63696b728b30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c4700d752016b6b448c5f302791b7ad2cb87904024fc267c81aaa0cec2d2398799bae6f0662fa70719fcdf5958b49919c1a6e0f40cca8ad78f35cdb37a94947
|
|
7
|
+
data.tar.gz: 972e0acaa88aa59bf1e2d5b7447976c2fbceeb18e360b905c228b0919488e6c1facbf0f2491a141010650da281f0206770f257ce27c7774fac4c08a054cd1c6c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.14.1 - 2026-10-18
|
|
4
|
+
|
|
5
|
+
- HTTP 清单在每次调用内复用认证和分页的连接及期限观察线程,保留总预算、逐块限额和禁用隐式请求重试的规则。
|
|
6
|
+
- known_hosts 合并以条目索引避免重复扫描,并复用原子写入和父目录同步;持久化未确认及不安全文件使用明确错误码。
|
|
7
|
+
- 修复设备作用域中同步关闭故障被延迟取消覆盖、callable 结果回调被隐式数组转换忽略,以及错误构造冻结调用方输出缓冲的问题。
|
|
8
|
+
- 必需提示符或抽象设备命令缺失时抛出 `ConfigurationError`,普通批次故障隔离可继续处理其他设备。
|
|
9
|
+
- 诊断复制校验内置错误的类身份,防止自定义异常伪装类名后保留未脱敏的私有字段。
|
|
10
|
+
|
|
3
11
|
## 0.14.0 - 2026-10-18
|
|
4
12
|
|
|
5
13
|
- 不兼容调整:Netdisco 配置与策略入口整理为 `Config`、`Config::Initializer`、`Device::Policy`、`Topology::Options`,原常量和加载路径已移除,直接引用者需更新。
|
data/docs/VERIFICATION.md
CHANGED
|
@@ -255,6 +255,15 @@ bundle exec ruby script/benchmarks/report_workload.rb 100000 20
|
|
|
255
255
|
|
|
256
256
|
脚本分别输出批次构造、首次统计及重复统计的单调时钟耗时与对象分配数;合成结果的创建不计入测量。比较两个源码版本时,在同一 Ruby 与主机上各运行至少三次。批次构造会复制结果容器并累计状态,重复统计复用计数;不读取报告的调用方也承担构造成本。TFTP 文件核验与逐设备明细仍有线性成本,此基准不测网络吞吐或现场设备性能。
|
|
257
257
|
|
|
258
|
+
## 清单连接与信任文件合并基准
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
bundle exec ruby script/benchmarks/http_workload.rb 200 5
|
|
262
|
+
bundle exec ruby script/benchmarks/known_hosts_workload.rb 30000 3000 5
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
HTTP 基准使用本地 TCP 服务返回 200 页和空结束页,记录总耗时、分配数和连接数,并验证清单内容与顺序;known_hosts 基准包含真实 ssh-keygen 检查和文件提交,验证旧条目及新增条目完整保序。两者均不包含设备登录或现场网络吞吐,比较时须保持相同 Ruby、主机、输入和运行次数。`test/netdisco_budget_test.rb` 另覆盖认证和重连共用总期限、慢速响应头、超额正文及取消后的 socket/观察线程回收。
|
|
266
|
+
|
|
258
267
|
## 厂商能力与模板一致性
|
|
259
268
|
|
|
260
269
|
`test/capabilities_test.rb` 离线核对全部已注册平台的能力;`test/vendor_topology_parity_test.rb` 覆盖 Huawei LLDP 简表与接口描述、FortiOS 顶层接口描述/嵌套表隔离,以及 Junos 物理接口和逻辑单元描述。`test/textfsm_test.rb` 同时检查 H3C 两种列序在通用命令解析入口中的语义、内置厂商/命令的完整匹配,以及各配置方言的索引入口。完整性拒绝路径覆盖缺失表头、残缺记录、未闭合接口表/引号、VDOM 作用域不明和没有值的 Junos description。
|
data/docs/architecture.md
CHANGED
|
@@ -79,6 +79,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
|
|
|
79
79
|
|
|
80
80
|
`Storage::AtomicFile.write` 统一返回 `Receipt`,执行临时文件写入、flush/fsync、rename、父目录 fsync,并跟踪 not_committed/committed/durable 和出错阶段。rename 前失败保留旧文件;之后失败保留新文件。目录 fsync 的 EINVAL/ENOSYS/ENOTSUP/EOPNOTSUPP/NotImplementedError 单独表示不支持;其他错误不能降级为成功。durable 只表示同步调用成功,未变内容不补验过去写入,断电恢复也未在本地测试中验证。
|
|
81
81
|
|
|
82
|
+
`KnownHosts` 在跨进程锁内重读和合并已认证的会话密钥,条目索引保留原顺序及已有重复行,新增行只登记一次。提交复用 `AtomicFile`;目录同步失败保留已替换文件并报告 `file_persistence_unconfirmed`,不能触发再次登记。不安全的非普通共享信任文件报告 `known_hosts_unsafe`,strict 模式仍由 OpenSSH 管理。写入器仅在实际提交时加载,纯会话引擎加载不引入存储层。
|
|
83
|
+
|
|
82
84
|
提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
|
|
83
85
|
|
|
84
86
|
直接备份在路径锁退出前保存完成元数据,锁关闭的普通故障转换为 `BackupFinalizeError`,其中 `backup` 保留结果,`underlying_type` 经过类型白名单。该错误只确认备份操作已完成,不制造文件同步回执;未变文件的历史持久性仍未补验。Fleet 的外层路径锁及 TFTP 归档锁同样保存最新结果,退出故障转为 saved_with_error 或 reported_with_error,已有设备诊断优先,中断继续传播。
|
|
@@ -135,7 +137,7 @@ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态
|
|
|
135
137
|
|
|
136
138
|
`CLI::Backup` 在创建批次目录和读取凭据前固定并验证策略。已固定的 `Settings#with_overrides` 只复制非敏感快照上的覆盖值,不重新读取环境中的策略字段;后续 `snapshot(mode:)` 继续按当前业务模式校验,离线导出不会隐式转成网络备份校验。原凭据来源仍保持动态。
|
|
137
139
|
|
|
138
|
-
`Inventory::HTTP#devices` 每次持有独立 `Inventory::Budget`,认证、分页、QueryRequired 查询共同累计正文字节和去重前记录数,并共享单调时钟 deadline
|
|
140
|
+
`Inventory::HTTP#devices` 每次持有独立 `Inventory::Budget`,认证、分页、QueryRequired 查询共同累计正文字节和去重前记录数,并共享单调时钟 deadline。每次调用独占一个 HTTP 连接作用域和期限观察线程,认证与分页复用连接;服务端关闭 keep-alive 后,后续请求可以重新建连,但失败请求不自动重试。连接不存入来源实例,并发调用不共用传输状态。默认 HTTP 使用 `read_body`,先检查实际接收字节再追加,不信任 Content-Length。每阶段缩短原生连接/读/写超时;总期限还覆盖慢速响应头。期限观察线程只关闭本次拥有的 HTTP 连接,防止 chunked 的收尾读取拖延返回,调用结束即唤醒并 join。默认 HTTP 不做隐式 GET 重试;这一机制不进入设备命令路径。
|
|
139
141
|
|
|
140
142
|
注入的 `requester` 接收 URI 和 request。它返回后才接受长度和期限检查,不在任意用户 Ruby 回调中注入异步异常。解析和集合操作完成后也检查期限,但这不构成任意 CPU 回调的抢占保证。预算失败仅报告错误码与安全类型,没有部分清单、响应正文或底层 cause。HTTPS 的标准证书检查不变;默认允许明文 HTTP,可通过显式策略禁止。
|
|
141
143
|
|
|
@@ -409,7 +411,7 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
|
|
|
409
411
|
|
|
410
412
|
业务执行与完成事件的异常范围分开:完成事件只尝试一次,观察者失败不能触发第二次相同通知。已有登录、命令或业务错误优先于失败日志的错误;脚本最终处理已生成的 Result.config 与 steps 不因日志收尾丢失。日志上下文刷新和传输接线恢复仍执行清理,但保留正在传播的主错误及 Interrupt。TFTP 的失败证据同样先决定错误码,再尝试记录失败事件。
|
|
411
413
|
|
|
412
|
-
错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause
|
|
414
|
+
错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause 或调用栈。复制还要求实际类与白名单常量身份一致,不能只凭可重写的类名取得信任。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。错误输出持有独立冻结副本,不冻结调用方的原始缓冲。缺少必需设备提示符或抽象命令抛出 `ConfigurationError`,属于可隔离的普通故障。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
|
|
413
415
|
|
|
414
416
|
单条命令的完整响应在输出日志和完成事件之前保存;即使这些日志处理失败,Session 仍在脱敏作用域退出前把响应交给 Execution,保留已完成步骤与输出预算。TFTP 可从失败结果的完成步骤构造设备回执,再报告日志错误;不会据此重放命令。
|
|
415
417
|
|
|
@@ -111,7 +111,7 @@ module Net
|
|
|
111
111
|
else
|
|
112
112
|
false
|
|
113
113
|
end
|
|
114
|
-
rescue
|
|
114
|
+
rescue ConfigurationError
|
|
115
115
|
false
|
|
116
116
|
end
|
|
117
117
|
|
|
@@ -200,7 +200,7 @@ module Net
|
|
|
200
200
|
prompt = profile.login_prompt
|
|
201
201
|
return prompt if prompt
|
|
202
202
|
|
|
203
|
-
raise
|
|
203
|
+
raise ConfigurationError, "#{self.class} must define login_prompt"
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
# 返回普通命令提示;厂商必须实现。
|
|
@@ -208,7 +208,7 @@ module Net
|
|
|
208
208
|
prompt = profile.command_prompt
|
|
209
209
|
return prompt if prompt
|
|
210
210
|
|
|
211
|
-
raise
|
|
211
|
+
raise ConfigurationError, "#{self.class} must define command_prompt"
|
|
212
212
|
end
|
|
213
213
|
|
|
214
214
|
# 登录成功后执行厂商初始化钩子。
|
|
@@ -18,7 +18,7 @@ module Net
|
|
|
18
18
|
commands = profile.config_commands
|
|
19
19
|
return commands if commands
|
|
20
20
|
|
|
21
|
-
raise
|
|
21
|
+
raise ConfigurationError, "#{self.class} must define running configuration commands"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# 子类可覆盖并调用 super,复用本次响应检查积累的策略状态。
|
|
@@ -29,7 +29,11 @@ module Net
|
|
|
29
29
|
ensure
|
|
30
30
|
begin
|
|
31
31
|
# 二次异步取消须等待关闭完成,不能在收尾中再次丢失资源所有权。
|
|
32
|
-
Thread.handle_interrupt(Exception => :never)
|
|
32
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
33
|
+
connector&.close
|
|
34
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 在恢复交付待处理中断前保存同步关闭故障。
|
|
35
|
+
failure ||= error
|
|
36
|
+
end
|
|
33
37
|
rescue Exception => error # rubocop:disable Lint/RescueException -- 关闭故障不能覆盖原操作故障,包括 Interrupt。
|
|
34
38
|
failure ||= error
|
|
35
39
|
end
|
|
@@ -54,7 +54,7 @@ module Net
|
|
|
54
54
|
private
|
|
55
55
|
|
|
56
56
|
def transfer_command(_target, **)
|
|
57
|
-
raise
|
|
57
|
+
raise ConfigurationError, "TFTP strategy must provide a transfer command"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def transfer_interactions(_target, **) = []
|
|
@@ -8,7 +8,7 @@ module Net
|
|
|
8
8
|
# 日志与报告共用固定诊断词表;未知元数据可能来自配置正文,不能作为自由文本输出。
|
|
9
9
|
module Metadata
|
|
10
10
|
CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
|
|
11
|
-
PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
|
|
11
|
+
PromptError ConnectionClosed TransportError DeviceError ConfigurationError ScriptError InternalError
|
|
12
12
|
OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
|
|
13
13
|
BackupPersistenceError BackupFinalizeError TftpCompletionError].freeze
|
|
14
14
|
TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
|
|
@@ -24,7 +24,7 @@ module Net
|
|
|
24
24
|
Net::Connector::Netdisco::Tftp::Archive::Unavailable Net::Connector::Netdisco::Tftp::Archive::Failure]).freeze
|
|
25
25
|
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
26
26
|
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
27
|
-
connection_timeout host_key_changed host_key_untrusted known_hosts_busy rsa_too_small unsupported_cipher
|
|
27
|
+
connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe rsa_too_small unsupported_cipher
|
|
28
28
|
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
29
29
|
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
30
30
|
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
@@ -46,6 +46,14 @@ module Net
|
|
|
46
46
|
|
|
47
47
|
def known_type?(name) = name.instance_of?(String) && TYPES.include?(name)
|
|
48
48
|
|
|
49
|
+
# 显示名白名单不授予复制私有字段的权限;外部子类可以重写 Class.name。
|
|
50
|
+
def trusted_error_class?(type)
|
|
51
|
+
name = type.name
|
|
52
|
+
known_type?(name) && Object.const_get(name).equal?(type)
|
|
53
|
+
rescue NameError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
49
57
|
def type(name)
|
|
50
58
|
return if name.nil?
|
|
51
59
|
|
|
@@ -16,7 +16,7 @@ module Net
|
|
|
16
16
|
@host = host
|
|
17
17
|
@phase = phase
|
|
18
18
|
@command = command
|
|
19
|
-
@output = output.freeze
|
|
19
|
+
@output = output.dup.freeze
|
|
20
20
|
@source = source
|
|
21
21
|
@line = line
|
|
22
22
|
@underlying = underlying
|
|
@@ -27,7 +27,7 @@ module Net
|
|
|
27
27
|
def with_diagnostics(message:, **context)
|
|
28
28
|
context = { code: code, host: host, phase: phase, command: command, output: output,
|
|
29
29
|
source: source, line: line, underlying: underlying }.merge(context)
|
|
30
|
-
return self.class.new(message, **context) unless Error::Metadata.
|
|
30
|
+
return self.class.new(message, **context) unless Error::Metadata.trusted_error_class?(self.class)
|
|
31
31
|
|
|
32
32
|
copy = self.class.allocate
|
|
33
33
|
instance_variables.each { |name| copy.instance_variable_set(name, instance_variable_get(name)) }
|
|
@@ -57,6 +57,8 @@ module Net
|
|
|
57
57
|
|
|
58
58
|
class DeviceError < Error; end
|
|
59
59
|
|
|
60
|
+
class ConfigurationError < Error; end
|
|
61
|
+
|
|
60
62
|
class ScriptError < Error; end
|
|
61
63
|
|
|
62
64
|
class InternalError < Error; end
|
|
@@ -31,28 +31,28 @@ module Net
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def commit
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
34
|
+
# 纯会话引擎加载不触及文件业务;实际登记信任时才加载持久化协议。
|
|
35
|
+
require_relative "../storage/atomic_file"
|
|
36
|
+
begin
|
|
37
|
+
additions = File.binread(path).lines("\n") - @original.lines("\n")
|
|
38
|
+
return if additions.empty?
|
|
39
|
+
|
|
40
|
+
with_lock do
|
|
41
|
+
current = read_shared
|
|
42
|
+
Tempfile.create([".nc-known-hosts-merge-", ""], File.dirname(@destination)) do |file|
|
|
43
|
+
file.write(current)
|
|
44
|
+
file.flush
|
|
45
|
+
reject_changed_key!(file.path) unless @replace
|
|
46
|
+
remove_host(file.path) if @replace
|
|
47
|
+
contents = merge_entries(File.binread(file.path), additions)
|
|
48
|
+
Storage::AtomicFile.write(@destination, contents)
|
|
49
|
+
ensure
|
|
50
|
+
File.unlink(file.path + ".old") if file && File.exist?(file.path + ".old")
|
|
51
51
|
end
|
|
52
|
-
File.rename(file.path, @destination)
|
|
53
|
-
ensure
|
|
54
|
-
File.unlink(file.path + ".old") if file && File.exist?(file.path + ".old")
|
|
55
52
|
end
|
|
53
|
+
rescue Storage::AtomicFile::WriteError => error
|
|
54
|
+
# 已替换但目录未同步时报告完成边界,不能把新信任误当作尚未写入。
|
|
55
|
+
raise ConnectionError.new(error.message, code: error.code, host: @host, phase: :login), cause: nil
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -71,6 +71,19 @@ module Net
|
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
+
# 保留已有条目和顺序;新增条目只登记一次,避免每条新增密钥扫描整份清单。
|
|
75
|
+
def merge_entries(contents, additions)
|
|
76
|
+
entries = contents.lines("\n")
|
|
77
|
+
present = entries.each_with_object({}) { |line, index| index[line.freeze] = true }
|
|
78
|
+
additions.each do |line|
|
|
79
|
+
next if present.key?(line)
|
|
80
|
+
|
|
81
|
+
entries << line
|
|
82
|
+
present[line.freeze] = true
|
|
83
|
+
end
|
|
84
|
+
entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join("")
|
|
85
|
+
end
|
|
86
|
+
|
|
74
87
|
# 临时文件返回后先登记所有权,阻塞构造期间仍允许取消。
|
|
75
88
|
def create_snapshot
|
|
76
89
|
failure = nil
|
|
@@ -110,12 +123,18 @@ module Net
|
|
|
110
123
|
|
|
111
124
|
def read_shared
|
|
112
125
|
File.open(@destination, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
113
|
-
raise
|
|
126
|
+
raise unsafe_file_error, cause: nil unless file.stat.file?
|
|
114
127
|
|
|
115
128
|
file.read
|
|
116
129
|
end
|
|
117
130
|
rescue Errno::ENOENT
|
|
118
131
|
""
|
|
132
|
+
rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
|
|
133
|
+
raise unsafe_file_error, cause: nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def unsafe_file_error
|
|
137
|
+
ConnectionError.new("known_hosts must be a managed regular file", code: :known_hosts_unsafe, host: @host, phase: :login)
|
|
119
138
|
end
|
|
120
139
|
|
|
121
140
|
def reject_changed_key!(current_path)
|
|
@@ -18,6 +18,8 @@ module Net
|
|
|
18
18
|
private_constant :QueryRequired
|
|
19
19
|
|
|
20
20
|
DEFAULTS = Inventory::DEFAULTS.merge(allow_insecure_http: true).freeze
|
|
21
|
+
OPTIONAL_FIELDS = FIELDS.drop(1).freeze
|
|
22
|
+
private_constant :OPTIONAL_FIELDS
|
|
21
23
|
|
|
22
24
|
def self.options(**values)
|
|
23
25
|
raise ArgumentError, "unknown HTTP inventory options" unless (values.keys - DEFAULTS.keys).empty?
|
|
@@ -54,24 +56,26 @@ module Net
|
|
|
54
56
|
|
|
55
57
|
def devices
|
|
56
58
|
budget = Inventory::Budget.new(@options, clock: method(:monotonic))
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
with_connection(budget) do |http|
|
|
60
|
+
token = @api_key || authenticate(budget, http)
|
|
61
|
+
rows = inventory(token, budget, http)
|
|
62
|
+
budget.remaining
|
|
63
|
+
rows
|
|
64
|
+
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
|
|
64
68
|
|
|
65
69
|
private
|
|
66
70
|
|
|
67
|
-
def inventory(token, budget)
|
|
71
|
+
def inventory(token, budget, http)
|
|
68
72
|
rows = []
|
|
69
73
|
seen_ips = {}
|
|
70
74
|
@options.fetch(:max_pages).times do |index|
|
|
71
75
|
uri = endpoint("api/v1/search/device")
|
|
72
76
|
uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @options.fetch(:page_size),
|
|
73
77
|
offset: index * @options.fetch(:page_size))
|
|
74
|
-
page = request_json(uri, authorized_get(uri, token), budget, allow_empty: true, query_fallback: true)
|
|
78
|
+
page = request_json(uri, authorized_get(uri, token), budget, http, allow_empty: true, query_fallback: true)
|
|
75
79
|
validate_rows!(page, budget)
|
|
76
80
|
return rows.uniq if page.empty?
|
|
77
81
|
|
|
@@ -83,22 +87,22 @@ module Net
|
|
|
83
87
|
end
|
|
84
88
|
raise Error.new("Netdisco inventory exceeded max_pages", code: :max_pages), cause: nil
|
|
85
89
|
rescue QueryRequired
|
|
86
|
-
query_required_devices(token, budget)
|
|
90
|
+
query_required_devices(token, budget, http)
|
|
87
91
|
end
|
|
88
92
|
|
|
89
|
-
def query_required_devices(token, budget)
|
|
93
|
+
def query_required_devices(token, budget, http)
|
|
90
94
|
uri = endpoint("api/v1/search/device")
|
|
91
95
|
uri.query = URI.encode_www_form(q: "%", seeallcolumns: true)
|
|
92
|
-
rows = request_json(uri, authorized_get(uri, token), budget, allow_empty: true)
|
|
96
|
+
rows = request_json(uri, authorized_get(uri, token), budget, http, allow_empty: true)
|
|
93
97
|
validate_rows!(rows, budget)
|
|
94
98
|
rows.map { |row| row.slice(*FIELDS) }.uniq
|
|
95
99
|
end
|
|
96
100
|
|
|
97
|
-
def authenticate(budget)
|
|
101
|
+
def authenticate(budget, http)
|
|
98
102
|
uri = endpoint("login")
|
|
99
103
|
request = ::Net::HTTP::Post.new(uri)
|
|
100
104
|
request.basic_auth(@username, @password)
|
|
101
|
-
data = request_json(uri, request, budget)
|
|
105
|
+
data = request_json(uri, request, budget, http)
|
|
102
106
|
token = data["api_key"] if data.is_a?(Hash)
|
|
103
107
|
raise Error, "Netdisco login did not return an API key" unless present?(token) && !token.match?(/[\r\n\x00]/)
|
|
104
108
|
|
|
@@ -114,10 +118,10 @@ module Net
|
|
|
114
118
|
end
|
|
115
119
|
|
|
116
120
|
# 只保留稳定错误码及类型,不将服务正文、URI 或底层异常 cause 带进报告。
|
|
117
|
-
def request_json(uri, request, budget, allow_empty: false, query_fallback: false)
|
|
121
|
+
def request_json(uri, request, budget, http, allow_empty: false, query_fallback: false)
|
|
118
122
|
request["Accept"] = "application/json"
|
|
119
123
|
budget.remaining
|
|
120
|
-
response, body = request_body(uri, request, budget)
|
|
124
|
+
response, body = request_body(uri, request, budget, http)
|
|
121
125
|
if query_fallback && response.code == "400"
|
|
122
126
|
parsed = JSON.parse(body)
|
|
123
127
|
raise QueryRequired, "Netdisco requires a search query" if parsed.is_a?(Hash) && parsed["error"] == "Missing query"
|
|
@@ -138,8 +142,11 @@ module Net
|
|
|
138
142
|
raise Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
|
|
139
143
|
end
|
|
140
144
|
|
|
141
|
-
def request_body(uri, request, budget)
|
|
142
|
-
|
|
145
|
+
def request_body(uri, request, budget, http)
|
|
146
|
+
unless @requester
|
|
147
|
+
apply_timeouts(http, budget)
|
|
148
|
+
return read_response(http, request, budget)
|
|
149
|
+
end
|
|
143
150
|
|
|
144
151
|
response = injected_request(uri, request, budget)
|
|
145
152
|
budget.remaining
|
|
@@ -157,15 +164,22 @@ module Net
|
|
|
157
164
|
end
|
|
158
165
|
|
|
159
166
|
# 仅对自有 HTTP 传输施加总期限,覆盖响应头的持续慢速输入;不异步中断用户回调。
|
|
160
|
-
#
|
|
161
|
-
|
|
167
|
+
# 一次调用独占连接与观察器,认证和分页共享预算;不把连接存入实例供并发调用共用。
|
|
168
|
+
# start 的块负责关闭连接;禁用隐式 GET 重试,服务端主动断开时允许新请求正常重连。
|
|
169
|
+
def with_connection(budget)
|
|
170
|
+
return yield nil if @requester
|
|
171
|
+
|
|
162
172
|
::Timeout.timeout(budget.remaining, Inventory::Timeout) do
|
|
163
|
-
::Net::HTTP.start(
|
|
173
|
+
::Net::HTTP.start(@url.host, @url.port, use_ssl: @url.scheme == "https", max_retries: 0,
|
|
164
174
|
**http_timeouts(budget)) do |http|
|
|
165
|
-
|
|
166
|
-
with_http_deadline(http, budget) { read_response(http, request, budget) }
|
|
175
|
+
with_http_deadline(http, budget) { yield http }
|
|
167
176
|
end
|
|
168
177
|
end
|
|
178
|
+
rescue Inventory::Error => error
|
|
179
|
+
raise error, cause: nil
|
|
180
|
+
rescue StandardError => error
|
|
181
|
+
budget.remaining
|
|
182
|
+
raise Inventory::Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
|
|
169
183
|
end
|
|
170
184
|
|
|
171
185
|
# 异步超时展开栈时,Net::HTTP 的 chunked ensure 可能再次等待读取。
|
|
@@ -228,7 +242,7 @@ module Net
|
|
|
228
242
|
budget.consume_devices(rows.size)
|
|
229
243
|
valid = rows.all? do |row|
|
|
230
244
|
row.is_a?(Hash) && present?(row["ip"]) &&
|
|
231
|
-
|
|
245
|
+
OPTIONAL_FIELDS.all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
232
246
|
end
|
|
233
247
|
return if valid
|
|
234
248
|
|
|
@@ -20,10 +20,11 @@ module Net
|
|
|
20
20
|
# 按清单顺序收集结果,并隔离设备及回调异常。
|
|
21
21
|
def run(tasks, outcomes:, on_error:, on_start: nil, on_result: nil)
|
|
22
22
|
callback_errors = Queue.new
|
|
23
|
+
callbacks = (on_result.is_a?(Array) ? on_result : [on_result]).compact
|
|
23
24
|
Concurrency::Pool.new(concurrency: @concurrency).map(tasks) do |index, device|
|
|
24
25
|
result = run_task(device, on_error, on_start, callback_errors) { yield device }
|
|
25
26
|
outcomes[index] = result
|
|
26
|
-
|
|
27
|
+
callbacks.each do |callback|
|
|
27
28
|
notify(callback, result, host: device.host, errors: callback_errors)
|
|
28
29
|
end
|
|
29
30
|
end
|