net-connector 0.9.0 → 0.10.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -1
  3. data/docs/VERIFICATION.md +1 -1
  4. data/docs/architecture.md +16 -5
  5. data/docs/netdisco.md +54 -1
  6. data/docs/netdisco.zh-CN.md +46 -2
  7. data/docs/usage.md +24 -7
  8. data/docs/usage.zh-CN.md +22 -8
  9. data/examples/backup.yml +1 -1
  10. data/lib/net/connector/background_task.rb +69 -0
  11. data/lib/net/connector/device/topology/interface_description.rb +3 -3
  12. data/lib/net/connector/device/topology/interface_name.rb +10 -19
  13. data/lib/net/connector/device/topology/stages.rb +50 -0
  14. data/lib/net/connector/device/topology.rb +65 -24
  15. data/lib/net/connector/device_session.rb +38 -0
  16. data/lib/net/connector/engine/transport.rb +15 -5
  17. data/lib/net/connector/execution_pool.rb +108 -0
  18. data/lib/net/connector/netdisco/backup_run.rb +14 -2
  19. data/lib/net/connector/netdisco/cli/options.rb +1 -0
  20. data/lib/net/connector/netdisco/cli.rb +18 -7
  21. data/lib/net/connector/netdisco/client.rb +5 -6
  22. data/lib/net/connector/netdisco/config_file.rb +5 -2
  23. data/lib/net/connector/netdisco/database_client.rb +33 -15
  24. data/lib/net/connector/netdisco/fleet.rb +68 -36
  25. data/lib/net/connector/netdisco/inventory_cache.rb +218 -0
  26. data/lib/net/connector/netdisco/operation_batch.rb +154 -0
  27. data/lib/net/connector/netdisco/operations.rb +268 -0
  28. data/lib/net/connector/netdisco/planner.rb +26 -17
  29. data/lib/net/connector/netdisco/progress.rb +22 -10
  30. data/lib/net/connector/netdisco/settings.rb +36 -6
  31. data/lib/net/connector/netdisco/tftp_archive.rb +2 -1
  32. data/lib/net/connector/netdisco/tftp_verification.rb +36 -11
  33. data/lib/net/connector/netdisco/worker.rb +7 -46
  34. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  35. data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
  36. data/lib/net/connector/version.rb +1 -1
  37. metadata +8 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "topology/strategy"
4
+ require_relative "topology/stages"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -14,6 +15,11 @@ module Net
14
15
  # 读取运行配置中的接口描述或端口名称。
15
16
  def interface_descriptions = Topology.new(self).interface_descriptions
16
17
 
18
+ # 显式同步请求在同一会话中完成发现、修改、读回与保存,无须调用方传递计划。
19
+ def sync_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
20
+ Topology.new(self).sync_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
21
+ end
22
+
17
23
  # 以邻居和现有配置为证据,生成待确认的接口描述变更计划。
18
24
  def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
19
25
  Topology.new(self).plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
@@ -29,6 +35,50 @@ module Net
29
35
  Change = Data.define(:interface, :old_description, :new_description, :neighbor)
30
36
  Plan = Data.define(:host, :vendor, :evidence, :changes, :commands)
31
37
 
38
+ # 仅承载厂商档案的离线上下文,不能连接、查询或下发设备命令。
39
+ PlanningContext = Data.define(:host, :vendor, :profile) do
40
+ def config_commands = profile.config_commands
41
+ end
42
+ private_constant :PlanningContext
43
+
44
+ # 全批应用前拒绝可离线判定的能力或命令问题;现场证据仍由单设备操作重验。
45
+ def self.validate_plan!(plan, host:, vendor:)
46
+ unless plan.is_a?(Plan) && plan.host == host && plan.vendor == vendor
47
+ raise ArgumentError, "plan must match its target device"
48
+ end
49
+ if plan.changes.empty?
50
+ raise ArgumentError, "empty topology plan cannot contain commands" unless plan.commands.empty?
51
+
52
+ return plan
53
+ end
54
+ strategy = planning_strategy(host: host, vendor: vendor)
55
+ validate_change_support!(strategy, host: host, phase: :apply)
56
+ stages = Stages.build(strategy: strategy, host: host, changes: plan.changes)
57
+ unless stages.values.flat_map { |script| script.map(&:text) } == plan.commands
58
+ raise ArgumentError, "plan commands were modified or use an older execution sequence; regenerate the plan"
59
+ end
60
+ plan
61
+ end
62
+
63
+ # 批次规划在建立任一会话前检查整个目标集合的厂商写入能力。
64
+ def self.validate_vendor_support!(host:, vendor:, phase:)
65
+ validate_change_support!(planning_strategy(host: host, vendor: vendor), host: host, phase: phase)
66
+ end
67
+
68
+ def self.planning_strategy(host:, vendor:)
69
+ profile = Net::Connector.vendor_class(vendor).profile
70
+ (profile.topology_strategy || Strategy).new(PlanningContext.new(host: host, vendor: vendor, profile: profile))
71
+ end
72
+ private_class_method :planning_strategy
73
+
74
+ # 能力声明不需要会话;规划、批次预检和现场应用使用同一厂商规则。
75
+ def self.validate_change_support!(strategy, host:, phase:)
76
+ return if strategy.supports?(:interface_description_changes)
77
+
78
+ raise UnsupportedOperation.new("automatic interface description changes are unavailable; use a verified manual workflow",
79
+ code: strategy.change_error_code, host: host, phase: phase)
80
+ end
81
+
32
82
  # 保存设备及其独立的 TextFSM 解析入口。
33
83
  def initialize(device, template_dir: nil)
34
84
  require_relative "../textfsm"
@@ -92,6 +142,18 @@ module Net
92
142
 
93
143
  private :read_interface_descriptions
94
144
 
145
+ # 调用即表达修改意图;同一租约覆盖发现到保存,外部变更仍须重验。
146
+ def sync_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
147
+ unless [abbreviate, lowercase].all? { |value| value == true || value == false }
148
+ raise ArgumentError, "description formatting options must be boolean"
149
+ end
150
+ validate_change_support!(:apply)
151
+ @device.with_operation(:apply) do
152
+ plan = plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
153
+ plan.changes.empty? ? Result.new : apply_plan(plan)
154
+ end
155
+ end
156
+
95
157
  # 比对邻居和现有描述,生成包含确切命令的只读变更计划。
96
158
  def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
97
159
  validate_change_support!(:plan) if @strategy.supports?(:neighbors)
@@ -210,21 +272,7 @@ module Net
210
272
 
211
273
  # 审批列表保留真正执行的读回命令,使旧版“先保存后读回”计划无法静默复用。
212
274
  def stage_scripts(changes)
213
- phases = %i[leave_configuration_commands verification_commands persistence_commands].map do |method|
214
- @strategy.public_send(method)
215
- end
216
- unless phases.all? { |commands| commands.is_a?(Array) && !commands.empty? }
217
- raise UnsupportedOperation.new("topology strategy must declare separate readback and persistence stages",
218
- code: :description_stages_unsupported, host: @device.host, phase: :plan)
219
- end
220
- leave, verify, persist = phases
221
- sequences = {
222
- change: [@strategy.enter_configuration_command] + changes.flat_map { |change| change_commands(change) } + leave,
223
- verify: verify, persist: persist
224
- }
225
- sequences.transform_values do |commands|
226
- Script.new(commands.map { |command| @strategy.script_command(command) }, name: "interface descriptions")
227
- end
275
+ Stages.build(strategy: @strategy, host: @device.host, changes: changes)
228
276
  end
229
277
 
230
278
  def execute_stage(script, steps)
@@ -242,10 +290,7 @@ module Net
242
290
 
243
291
  # 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
244
292
  def validate_change_support!(phase)
245
- return if @strategy.supports?(:interface_description_changes)
246
-
247
- raise UnsupportedOperation.new("automatic interface description changes are unavailable; use a verified manual workflow",
248
- code: @strategy.change_error_code, host: @device.host, phase: phase)
293
+ self.class.validate_change_support!(@strategy, host: @device.host, phase: phase)
249
294
  end
250
295
 
251
296
  # 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
@@ -267,11 +312,7 @@ module Net
267
312
  end.freeze
268
313
  end
269
314
 
270
- # 设备标识来自解析证据,仍需在生成命令前验证其安全性。
271
- def change_commands(change)
272
- InterfaceDescription.validate_interface!(change.interface)
273
- @strategy.change_commands(change)
274
- end
315
+
275
316
  end
276
317
  end
277
318
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 一个设备任务独占一个连接器;返回值与收尾故障分别保留,不能靠重试恢复写操作。
6
+ module DeviceSession
7
+ # 工厂负责构造,作用域负责关闭;中断仍向调用方传播且不会被第二个关闭故障覆盖。
8
+ def self.call(factory:)
9
+ raise ArgumentError, "factory must respond to call" unless factory.respond_to?(:call)
10
+ raise ArgumentError, "device session requires a block" unless block_given?
11
+
12
+ connector = value = failure = nil
13
+ completed = false
14
+ begin
15
+ # 构造器在阻塞等待中仍可取消;非阻塞的返回交接须先登记所有权再交付取消。
16
+ # 工厂尚未返回时的部分资源由工厂清理,成功返回后统一由本作用域关闭。
17
+ Thread.handle_interrupt(Exception => :on_blocking) { connector = factory.call }
18
+ value = yield connector
19
+ completed = true
20
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾后保留最先发生的中断,普通故障单独交付业务层。
21
+ failure = error
22
+ completed = true
23
+ ensure
24
+ begin
25
+ # 二次异步取消须等待关闭完成,不能在收尾中再次丢失资源所有权。
26
+ Thread.handle_interrupt(Exception => :never) { connector&.close }
27
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 关闭故障不能覆盖原操作故障,包括 Interrupt。
28
+ failure ||= error
29
+ end
30
+ # break 和非局部 return 跳过尾部返回值;收尾故障必须在展开作用域时交付。
31
+ raise failure if failure && (!completed || !failure.is_a?(StandardError))
32
+ end
33
+
34
+ [value, failure]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -80,11 +80,21 @@ module Net
80
80
 
81
81
  # 临时设置写入超时,完成写入后恢复原设置。
82
82
  def write(bytes, timeout: configuration.write_timeout)
83
- previous = @channel.write_timeout
84
- @channel.write_timeout = timeout
85
- @channel.write(bytes)
86
- ensure
87
- @channel.write_timeout = previous if @channel
83
+ channel = @channel
84
+ previous = channel.write_timeout
85
+ begin
86
+ channel.write_timeout = timeout
87
+ channel.write(bytes)
88
+ rescue Exception # rubocop:disable Lint/RescueException -- 恢复超时前保留写入的首个故障或中断。
89
+ failed = true
90
+ raise
91
+ ensure
92
+ begin
93
+ channel.write_timeout = previous
94
+ rescue Exception # rubocop:disable Lint/RescueException -- 收尾故障不能把中断转换成普通写入错误。
95
+ raise unless failed
96
+ end
97
+ end
88
98
  end
89
99
 
90
100
  # 判断信道尚未建立或已经关闭。
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 仅管理有限并发和线程所有权;业务层负责将普通任务故障转换为领域结果。
6
+ class ExecutionPool
7
+ DEFAULT_CONCURRENCY = 4
8
+ MAX_CONCURRENCY = 50
9
+
10
+ def self.validate_concurrency!(concurrency)
11
+ unless concurrency.is_a?(Integer) && (1..MAX_CONCURRENCY).cover?(concurrency)
12
+ raise ArgumentError, "concurrency must be an Integer in 1..#{MAX_CONCURRENCY}"
13
+ end
14
+
15
+ concurrency
16
+ end
17
+
18
+ # 环境只提供默认并发数;显式构造参数优先,非法值不创建线程。
19
+ def self.environment_concurrency(env: ENV)
20
+ value = env.fetch("NC_CONCURRENCY", DEFAULT_CONCURRENCY.to_s)
21
+ unless value.is_a?(String) && value.match?(/\A[0-9]+\z/)
22
+ raise ArgumentError, "NC_CONCURRENCY must be a positive integer"
23
+ end
24
+
25
+ validate_concurrency!(Integer(value, 10))
26
+ end
27
+
28
+ def initialize(concurrency: self.class.environment_concurrency)
29
+ @concurrency = self.class.validate_concurrency!(concurrency)
30
+ end
31
+
32
+ # 保序返回块的值;任何未处理异常均在调用线程抛出,停止并回收本次所有线程。
33
+ # 只复制任务数组,任务内容仍由业务层在下发前校验和固定,不自动重放块。
34
+ def map(items)
35
+ raise ArgumentError, "items must be an Array" unless items.is_a?(Array)
36
+ raise ArgumentError, "map requires a block" unless block_given?
37
+
38
+ items = items.dup
39
+ queue = Queue.new
40
+ items.each_with_index { |item, index| queue << [index, item] }
41
+ count = [items.size, @concurrency].min
42
+ count.times { queue << nil }
43
+ gate = Queue.new
44
+ finished = Queue.new
45
+ results = Array.new(items.size)
46
+ threads = []
47
+ completed = false
48
+ primary_failure = nil
49
+ begin
50
+ count.times do
51
+ # 仅延迟线程创建到登记的异步中断,避免尚未登记的工作线程失去所有者。
52
+ Thread.handle_interrupt(Exception => :never) do
53
+ threads << Thread.new do
54
+ failure = nil
55
+ begin
56
+ # 全部线程成功建立后才允许设备操作,创建失败不能留下已发送的变更。
57
+ Thread.handle_interrupt(Exception => :immediate) do
58
+ gate.pop
59
+ while (task = queue.pop)
60
+ index, item = task
61
+ results[index] = yield item
62
+ end
63
+ end
64
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 将中断同步交给调用线程,避免全局 abort_on_exception 波及其他线程。
65
+ failure = error
66
+ ensure
67
+ finished << [Thread.current, failure]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ count.times { gate << true }
73
+ count.times do
74
+ thread, failure = finished.pop
75
+ thread.join
76
+ raise failure if failure
77
+ end
78
+ completed = true
79
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 记录取消或原任务故障,收尾中再次取消不能覆盖它。
80
+ primary_failure = error
81
+ raise
82
+ ensure
83
+ stop_threads(threads, primary_failure) unless completed
84
+ end
85
+ results
86
+ end
87
+
88
+ private
89
+
90
+ # kill 运行任务自身的 ensure;次级关闭故障不能盖掉已观察到的原始异常。
91
+ def stop_threads(threads, primary_failure)
92
+ # 仅回收期间延迟异步取消;交付二次中断之前必须 kill 并 join 全部拥有线程。
93
+ Thread.handle_interrupt(Exception => :never) do
94
+ threads.each(&:kill)
95
+ threads.each do |thread|
96
+ begin
97
+ thread.join
98
+ rescue Exception # rubocop:disable Lint/RescueException -- 等待全部拥有线程收尾,保留调用方原始故障或中断。
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复交付时保留原故障,二次取消不改变批次失败原因。
104
+ raise primary_failure || error
105
+ end
106
+ end
107
+ end
108
+ end
@@ -33,11 +33,13 @@ module Net
33
33
  fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
34
34
  progress.reading_inventory
35
35
  limit = settings.limit_per_vendor
36
+ refresh = options[:force_refresh] ? { force_refresh: true } : {}
36
37
  plan = if @mode == :backup
37
- fleet.plan_backup(limit_per_vendor: limit)
38
+ fleet.plan_backup(limit_per_vendor: limit, **refresh)
38
39
  else
39
- fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?)
40
+ fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?, **refresh)
40
41
  end
42
+ inventory_warning(fleet)
41
43
  progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
42
44
  preview = plan_summary(plan)
43
45
  @output.puts JSON.generate(plan: preview) if options[:json]
@@ -59,6 +61,16 @@ module Net
59
61
 
60
62
  private
61
63
 
64
+ # 清单离线回退和本地保存故障单独提示,不覆盖设备操作结果。
65
+ def inventory_warning(fleet)
66
+ if fleet.inventory_status && fleet.inventory_status[:source] == :stale_cache
67
+ @error.puts "清单刷新失败,使用历史缓存(#{fleet.inventory_status[:refresh_error_code]})"
68
+ end
69
+ return unless fleet.inventory_status && fleet.inventory_status[:write_error_type]
70
+ @error.puts "清单缓存写入失败(#{fleet.inventory_status[:write_error_type]})"
71
+
72
+ end
73
+
62
74
  # 预检完成后才创建批次目录;补充日志路径时保持原策略快照与动态凭据来源。
63
75
  def prepare(options)
64
76
  settings = CLI::Options.settings(options, env: @env).for_run(mode: @mode)
@@ -47,6 +47,7 @@ module Net
47
47
 
48
48
  # 正式 CLI 与批次示例共用并发、抽样、核验和成功策略选项。
49
49
  def self.add_execution(opts, values)
50
+ opts.on("--refresh-inventory", "强制刷新清单;远端失败时回退已验证历史") { values[:force_refresh] = true }
50
51
  opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
51
52
  raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
52
53
 
@@ -27,8 +27,7 @@ module Net
27
27
  return 0 if options[:done]
28
28
 
29
29
  validate_options!(options)
30
- mode = options[:export] ? :export : (options[:tftp] ? :tftp : :backup)
31
- settings = settings_for(options).for_run(mode: mode)
30
+ settings = settings_for(options)
32
31
 
33
32
  if options[:show_config]
34
33
  @output.puts JSON.pretty_generate(settings.public_config)
@@ -44,6 +43,7 @@ module Net
44
43
 
45
44
  fleet = @fleet_factory.call(settings)
46
45
  plan = backup_plan(fleet, settings, options)
46
+ inventory_warning(fleet)
47
47
  if options[:plan]
48
48
  @output.puts JSON.pretty_generate(plan_summary(plan))
49
49
  return 0
@@ -71,9 +71,10 @@ module Net
71
71
 
72
72
  private
73
73
 
74
- # 公开 CLI 参数覆盖 YAML 与 ND_URL;设备凭据按环境或显式 CLI 参数解析。
74
+ # CLI 覆盖支持的环境变量与 YAML;设备凭据按环境或显式 CLI 参数解析。
75
75
  def settings_for(options)
76
- Options.settings(options, env: @env)
76
+ mode = options[:export] ? :export : (options[:tftp] ? :tftp : :backup)
77
+ Options.settings(options, env: @env).for_run(mode: mode)
77
78
  end
78
79
 
79
80
  # 导出已有配置不创建清单或设备连接。
@@ -86,10 +87,11 @@ module Net
86
87
  # 预览与执行共用计划和主机存在性检查。
87
88
  def backup_plan(fleet, settings, options)
88
89
  limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
90
+ refresh = options[:force_refresh] ? { force_refresh: true } : {}
89
91
  plan = if options[:tftp]
90
- fleet.plan_tftp_backup(limit_per_vendor: limit)
92
+ fleet.plan_tftp_backup(limit_per_vendor: limit, **refresh)
91
93
  else
92
- fleet.plan_backup(limit_per_vendor: limit)
94
+ fleet.plan_backup(limit_per_vendor: limit, **refresh)
93
95
  end
94
96
  if options[:host]
95
97
  host = IPAddr.new(options[:host]).to_s
@@ -98,6 +100,15 @@ module Net
98
100
  plan
99
101
  end
100
102
 
103
+ # 回退历史只描述清单来源,不把缓存命中误报为远端已恢复。
104
+ def inventory_warning(fleet)
105
+ status = fleet.inventory_status if fleet.respond_to?(:inventory_status)
106
+ if status && status[:source] == :stale_cache
107
+ @error.puts "net-backup: 清单刷新失败,使用历史缓存(#{status[:refresh_error_code]})"
108
+ end
109
+ @error.puts "net-backup: 清单缓存写入失败(#{status[:write_error_type]})" if status && status[:write_error_type]
110
+ end
111
+
101
112
  # 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
102
113
  def backup(fleet, plan, settings, options)
103
114
  reporting = options.slice(:success_policy)
@@ -158,7 +169,7 @@ module Net
158
169
  raise ArgumentError, "--export 不能与清单查询选项同时使用"
159
170
  end
160
171
  if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:environment].key?("NC_CONCURRENCY") || options[:host] ||
161
- options[:success_policy] || options[:tftp_root] || options[:max_script_output_bytes])
172
+ options[:success_policy] || options[:tftp_root] || options[:max_script_output_bytes] || options[:force_refresh])
162
173
  raise ArgumentError, "--export 不能与备份选项同时使用"
163
174
  end
164
175
  end
@@ -6,6 +6,7 @@ require "openssl"
6
6
  require "timeout"
7
7
  require "uri"
8
8
  require_relative "inventory_budget"
9
+ require_relative "../background_task"
9
10
 
10
11
  module Net
11
12
  module Connector
@@ -196,11 +197,11 @@ module Net
196
197
 
197
198
  # 异步超时展开栈时,Net::HTTP 的 chunked ensure 可能再次等待读取。
198
199
  # 只关闭本次自有连接来解除这类阻塞;结束时唤醒并 join 观察线程,不遗留后台任务。
199
- def with_http_deadline(http, budget)
200
+ def with_http_deadline(http, budget, &)
200
201
  mutex = Mutex.new
201
202
  changed = ConditionVariable.new
202
203
  finished = false
203
- timer = Thread.new do
204
+ task = lambda do
204
205
  begin
205
206
  mutex.synchronize { changed.wait(mutex, budget.remaining) until finished }
206
207
  rescue InventoryTimeout
@@ -212,10 +213,8 @@ module Net
212
213
  end
213
214
  end
214
215
  end
215
- yield
216
- ensure
217
- mutex&.synchronize { finished = true; changed.broadcast }
218
- timer&.join
216
+ stop = -> { mutex.synchronize { finished = true; changed.broadcast } }
217
+ BackgroundTask.run(task: task, stop: stop, &)
219
218
  end
220
219
 
221
220
  def read_response(http, request, budget)
@@ -6,10 +6,13 @@ require "yaml"
6
6
  module Net
7
7
  module Connector
8
8
  module Netdisco
9
- # 无凭据 YAML 配置;仅 ND_URL 允许从进程环境覆盖,其他策略由 CLI 覆盖。
9
+ # 无凭据 YAML 配置;ND_URL、ND_CACHE_TTL、NC_CONCURRENCY 允许环境覆盖,其余策略由 CLI 覆盖。
10
10
  class ConfigFile
11
11
  FIELDS = {
12
- "netdisco" => { "source" => ["ND_SOURCE", :string],
12
+ "netdisco" => { "cache_directory" => ["ND_CACHE_DIRECTORY", :string],
13
+ "cache_ttl" => ["ND_CACHE_TTL", :number],
14
+ "cache_enabled" => ["ND_CACHE_ENABLED", :boolean],
15
+ "source" => ["ND_SOURCE", :string],
13
16
  "query" => ["ND_QUERY", :string],
14
17
  "query_params" => ["ND_QUERY_PARAMS", :json_array],
15
18
  "url" => ["ND_URL", :string],
@@ -76,38 +76,34 @@ module Net
76
76
  budget = InventoryBudget.new(@options, clock: method(:monotonic))
77
77
  connection = nil
78
78
  connected = false
79
+ failed = false
79
80
  begin
80
81
  Timeout.timeout(budget.remaining, Client::InventoryTimeout) do
81
- # 先取得连接句柄再交付超时,确保异常到达时 ensure 能显式关闭连接。
82
- Thread.handle_interrupt(Client::InventoryTimeout => :never) do
82
+ # 只在取得句柄并登记所有权期间延后异步异常,离开后仍立即传播原中断。
83
+ Thread.handle_interrupt(Exception => :never) do
83
84
  connection = PG::Connection.connect_start(@connection_options)
84
85
  end
85
86
  # PostgreSQL NOTICE 可能包含 SQL 或数据,不交给 libpq 默认的 stderr 输出器。
86
87
  connection.set_notice_processor { |_notice| nil }
87
88
  wait_for_connection(connection, budget)
88
89
  connected = true
89
- # 使用 pg 可中断的 Ruby 等待接口;底层 socket 仍以非阻塞方式工作。
90
- connection.setnonblocking(false)
91
- connection.set_client_encoding("UTF8")
92
- connection.exec("BEGIN READ ONLY").clear
93
- set_statement_timeout(connection, budget)
94
- # 即使没有参数也走扩展查询协议,由 PostgreSQL 拒绝多语句和非查询命令。
95
- connection.exec_params("DECLARE net_connector_inventory NO SCROLL CURSOR FOR\n#{@options.fetch(:query)}",
96
- @options.fetch(:query_params)).clear
97
- rows = inventory(connection, budget)
98
- connection.exec("ROLLBACK").clear
99
- budget.remaining
100
- rows
90
+ query_inventory(connection, budget)
101
91
  end
102
92
  rescue Client::Error => error
93
+ failed = true
103
94
  raise error, cause: nil
104
95
  rescue PG::QueryCanceled
96
+ failed = true
105
97
  raise Client::InventoryTimeout, cause: nil
106
98
  rescue PG::Error, IOError, SystemCallError
99
+ failed = true
107
100
  code = connected ? :query_failed : :connection_failed
108
101
  raise Client::Error.new("Netdisco database #{code.to_s.tr("_", " ")}", code: code), cause: nil
102
+ rescue Exception # rubocop:disable Lint/RescueException -- 清理继续保留未知异常和用户中断。
103
+ failed = true
104
+ raise
109
105
  ensure
110
- connection&.finish
106
+ close_connection(connection, failed: failed)
111
107
  end
112
108
  end
113
109
 
@@ -115,6 +111,28 @@ module Net
115
111
 
116
112
  private
117
113
 
114
+ # 连接始终尝试关闭;收尾故障不能覆盖查询失败或中断,完成后的关闭失败仍须传播。
115
+ def close_connection(connection, failed:)
116
+ connection&.finish
117
+ rescue Exception # rubocop:disable Lint/RescueException -- 保留先发生的查询失败和用户中断。
118
+ raise unless failed
119
+ end
120
+
121
+ # 使用 pg 可中断的等待与扩展查询协议,只读游标共享本次清单的总预算。
122
+ def query_inventory(connection, budget)
123
+ connection.setnonblocking(false)
124
+ connection.set_client_encoding("UTF8")
125
+ connection.exec("BEGIN READ ONLY").clear
126
+ set_statement_timeout(connection, budget)
127
+ # 即使没有参数也走扩展查询协议,由 PostgreSQL 拒绝多语句和非查询命令。
128
+ connection.exec_params("DECLARE net_connector_inventory NO SCROLL CURSOR FOR\n#{@options.fetch(:query)}",
129
+ @options.fetch(:query_params)).clear
130
+ rows = inventory(connection, budget)
131
+ connection.exec("ROLLBACK").clear
132
+ budget.remaining
133
+ rows
134
+ end
135
+
118
136
  def wait_for_connection(connection, budget)
119
137
  deadline = monotonic + @connection_options.fetch(:connect_timeout, budget.remaining).to_f
120
138
  loop do