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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1f1aa8bb2e1ce2a457e99cde7439206c7f4b1c44ccd590db5b6eb330386a39d
4
- data.tar.gz: 4b29cb8d180a7428225badc8bedc90ac6887d574ad8eccbfd251159141f7bc2f
3
+ metadata.gz: d1feb1332558f1cad30b6bdb1b657c16cf3237d5ba975014204b320a94e0a84c
4
+ data.tar.gz: b7294d094cd32498f7c6e8539ecb40fbf227f9b4926772f93ccd63696b728b30
5
5
  SHA512:
6
- metadata.gz: f4743a08bf81e10e58e6475c33c7537bc87bc68b6f418d9badd3502382e4a35395af00a923923d64349f8499862b50f3ebb85f8d963f421348028fd7c3d7c978
7
- data.tar.gz: a23c96f292c6f69c56a2de6c0e62684f3d4d9b090d37e68d97315d8e9c3ecf22e7cc008776ca25fd74e0d284dd287031ed0b97431224c5e33e9dcba873c3edbf
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。默认 HTTP 使用 `read_body`,先检查实际接收字节再追加,不信任 Content-Length。每阶段缩短原生连接/读/写超时;总期限还覆盖慢速响应头。期限观察线程只关闭本次拥有的 HTTP 连接,防止 chunked 的收尾读取拖延返回,调用结束即唤醒并 join。默认 HTTP 不做隐式 GET 重试;这一机制不进入设备命令路径。
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 或调用栈。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
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 NotImplementedError
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 NotImplementedError, "#{self.class} must define login_prompt"
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 NotImplementedError, "#{self.class} must define command_prompt"
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 NotImplementedError, "#{self.class} must define running configuration commands"
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) { connector&.close }
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 NotImplementedError, "TFTP strategy must provide a transfer command"
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.known_type?(self.class.name)
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
- additions = File.binread(path).lines("\n") - @original.lines("\n")
35
- return if additions.empty?
36
-
37
- with_lock do
38
- current = read_shared
39
- Tempfile.create([".nc-known-hosts-merge-", ""], File.dirname(@destination)) do |file|
40
- file.write(current)
41
- file.flush
42
- reject_changed_key!(file.path) unless @replace
43
- remove_host(file.path) if @replace
44
- contents = File.binread(file.path)
45
- entries = contents.lines("\n")
46
- additions.each { |line| entries << line unless entries.include?(line) }
47
- File.open(file.path, "wb", 0o600) do |replacement|
48
- replacement.write(entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join(""))
49
- replacement.flush
50
- replacement.fsync
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 IOError, "known_hosts must be a regular file" unless file.stat.file?
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
- token = @api_key || authenticate(budget)
58
- rows = inventory(token, budget)
59
- budget.remaining
60
- rows
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
- return default_request(uri, request, budget) unless @requester
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
- # start 的块负责关闭连接;禁用隐式 GET 重试,所有阶段同时使用剩余的原生超时。
161
- def default_request(uri, request, budget)
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(uri.host, uri.port, use_ssl: uri.scheme == "https", max_retries: 0,
173
+ ::Net::HTTP.start(@url.host, @url.port, use_ssl: @url.scheme == "https", max_retries: 0,
164
174
  **http_timeouts(budget)) do |http|
165
- apply_timeouts(http, budget)
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
- FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
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
- Array(on_result).each do |callback|
27
+ callbacks.each do |callback|
27
28
  notify(callback, result, host: device.host, errors: callback_errors)
28
29
  end
29
30
  end
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.14.0"
6
+ VERSION = "0.14.1"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors