net-connector 0.9.0 → 0.10.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/VERIFICATION.md +1 -1
  4. data/docs/architecture.md +18 -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/base.rb +5 -11
  12. data/lib/net/connector/device/running_config.rb +5 -3
  13. data/lib/net/connector/device/topology/interface_description.rb +3 -3
  14. data/lib/net/connector/device/topology/interface_name.rb +10 -19
  15. data/lib/net/connector/device/topology/stages.rb +50 -0
  16. data/lib/net/connector/device/topology.rb +65 -24
  17. data/lib/net/connector/device_session.rb +43 -0
  18. data/lib/net/connector/engine/known_hosts.rb +31 -3
  19. data/lib/net/connector/engine/log.rb +28 -21
  20. data/lib/net/connector/engine/transport.rb +74 -32
  21. data/lib/net/connector/execution_pool.rb +109 -0
  22. data/lib/net/connector/netdisco/backup_run.rb +14 -2
  23. data/lib/net/connector/netdisco/cli/options.rb +1 -0
  24. data/lib/net/connector/netdisco/cli.rb +18 -7
  25. data/lib/net/connector/netdisco/client.rb +5 -6
  26. data/lib/net/connector/netdisco/config_file.rb +5 -2
  27. data/lib/net/connector/netdisco/database_client.rb +33 -15
  28. data/lib/net/connector/netdisco/fleet.rb +68 -36
  29. data/lib/net/connector/netdisco/inventory_cache.rb +218 -0
  30. data/lib/net/connector/netdisco/operation_batch.rb +154 -0
  31. data/lib/net/connector/netdisco/operations.rb +268 -0
  32. data/lib/net/connector/netdisco/planner.rb +26 -17
  33. data/lib/net/connector/netdisco/progress.rb +22 -10
  34. data/lib/net/connector/netdisco/settings.rb +36 -6
  35. data/lib/net/connector/netdisco/tftp_archive.rb +2 -1
  36. data/lib/net/connector/netdisco/tftp_verification.rb +36 -11
  37. data/lib/net/connector/netdisco/worker.rb +7 -46
  38. data/lib/net/connector/storage/backup_lock.rb +29 -3
  39. data/lib/net/connector/storage/safe_file.rb +30 -4
  40. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  41. data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
  42. data/lib/net/connector/version.rb +1 -1
  43. metadata +8 -1
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../engine/command"
4
+ require_relative "interface_description"
5
+
6
+ module Net
7
+ module Connector
8
+ class Topology
9
+ # 离线预检与现场执行共用确切阶段,不复制厂商命令或把保存合并到变更阶段。
10
+ module Stages
11
+ def self.build(strategy:, host:, changes:)
12
+ validate_changes!(strategy, changes)
13
+ phases = %i[leave_configuration_commands verification_commands persistence_commands].map do |method|
14
+ strategy.public_send(method)
15
+ end
16
+ unless phases.all? { |commands| commands.is_a?(Array) && !commands.empty? }
17
+ raise UnsupportedOperation.new("topology strategy must declare separate readback and persistence stages",
18
+ code: :description_stages_unsupported, host: host, phase: :plan)
19
+ end
20
+ leave, verify, persist = phases
21
+ commands = changes.flat_map do |change|
22
+ strategy.change_commands(change)
23
+ end
24
+ sequences = { change: [strategy.enter_configuration_command] + commands + leave,
25
+ verify: verify, persist: persist }
26
+ sequences.transform_values do |sequence|
27
+ Script.new(sequence.map { |command| strategy.script_command(command) }, name: "interface descriptions")
28
+ end
29
+ end
30
+
31
+ # 同一物理接口只能出现一次;缩写与全称也不能绕过离线和现场的共同预检。
32
+ def self.validate_changes!(strategy, changes)
33
+ unless changes.is_a?(Array) && changes.all?(Topology::Change)
34
+ raise ArgumentError, "topology changes must be an Array of Changes"
35
+ end
36
+
37
+ interfaces = {}
38
+ changes.each do |change|
39
+ InterfaceDescription.validate_interface!(change.interface)
40
+ key = strategy.interface_key(change.interface)
41
+ raise ArgumentError, "topology changes must identify unique interfaces" if interfaces.key?(key)
42
+
43
+ interfaces[key] = true
44
+ end
45
+ end
46
+ private_class_method :validate_changes!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -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,43 @@
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) do
18
+ connector = factory.call
19
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 工厂同步故障先于退出屏蔽范围时交付的取消。
20
+ failure = error
21
+ raise
22
+ end
23
+ value = yield connector
24
+ completed = true
25
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾后保留最先发生的中断,普通故障单独交付业务层。
26
+ failure ||= error
27
+ completed = true
28
+ ensure
29
+ begin
30
+ # 二次异步取消须等待关闭完成,不能在收尾中再次丢失资源所有权。
31
+ Thread.handle_interrupt(Exception => :never) { connector&.close }
32
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 关闭故障不能覆盖原操作故障,包括 Interrupt。
33
+ failure ||= error
34
+ end
35
+ # break 和非局部 return 跳过尾部返回值;收尾故障必须在展开作用域时交付。
36
+ raise failure if failure && (!completed || !failure.is_a?(StandardError))
37
+ end
38
+
39
+ [value, failure]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -20,7 +20,7 @@ module Net
20
20
  @host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
21
21
  @replace = replace
22
22
  @original = read_shared
23
- @file = Tempfile.new([".nc-known-hosts-", ""], File.dirname(@destination))
23
+ create_snapshot
24
24
  @path = @file.path
25
25
  @file.write(@original)
26
26
  @file.flush
@@ -57,6 +57,36 @@ module Net
57
57
  end
58
58
 
59
59
  def close
60
+ failure = nil
61
+ # 清除引用至删除快照期间延后异步取消,不能留下失去所有者的临时文件。
62
+ Thread.handle_interrupt(Exception => :never) do
63
+ close_snapshot
64
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 保存同步清理故障,退出屏蔽范围后继续传播。
65
+ failure = error
66
+ raise
67
+ end
68
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖清理过程中已经发生的故障。
69
+ raise failure || error, cause: nil
70
+ end
71
+
72
+ private
73
+
74
+ # 临时文件返回后先登记所有权,阻塞构造期间仍允许取消。
75
+ def create_snapshot
76
+ failure = nil
77
+ begin
78
+ Thread.handle_interrupt(Exception => :on_blocking) do
79
+ @file = Tempfile.new([".nc-known-hosts-", ""], File.dirname(@destination))
80
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 构造故障先于退出屏蔽范围时递送的二次取消。
81
+ failure = error
82
+ end
83
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 成功交接后取消仍传播,但不覆盖已登记的构造故障。
84
+ failure ||= error
85
+ end
86
+ raise failure, cause: nil if failure
87
+ end
88
+
89
+ def close_snapshot
60
90
  file = @file
61
91
  @file = nil
62
92
  File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
@@ -71,8 +101,6 @@ module Net
71
101
  end
72
102
  end
73
103
 
74
- private
75
-
76
104
  # 初始化失败时保留原始异常,同时尽力清理已经创建的临时文件。
77
105
  def close_preserving_failure
78
106
  close
@@ -31,15 +31,17 @@ module Net
31
31
  @transport = transport
32
32
  @session_id = SecureRandom.hex(8).freeze
33
33
  @command_sequence = 0
34
- @io = open_file(@configuration.log_file) if @configuration.log_file
34
+ open_file(@configuration.log_file) if @configuration.log_file
35
35
  if @configuration.log_format == :raw
36
36
  @writer = @output = RedactingWriter.new(@io, @redactor) if @io
37
37
  elsif @io || @configuration.logger
38
38
  @logger = @configuration.logger || ::Logger.new(@io, formatter: Formatter.new)
39
39
  open_transcript if @io && @configuration.log_level == :debug
40
40
  end
41
- rescue => error
41
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件已登记后的中断也必须释放资源,仍原样交付中断。
42
42
  close_preserving_failure
43
+ raise unless error.is_a?(StandardError)
44
+
43
45
  raise build_error("unable to open session log", error), cause: nil
44
46
  end
45
47
 
@@ -192,10 +194,16 @@ module Net
192
194
  failed = true
193
195
  raise
194
196
  ensure
197
+ close_failure = nil
195
198
  begin
196
- @io&.close
197
- rescue Exception # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
198
- raise unless failed
199
+ Thread.handle_interrupt(Exception => :never) do
200
+ @io&.close
201
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件关闭先发生的故障优先于待处理中断。
202
+ close_failure = error
203
+ raise
204
+ end
205
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
206
+ raise close_failure || error, cause: nil unless failed
199
207
  ensure
200
208
  @writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
201
209
  @attached = false
@@ -244,29 +252,28 @@ module Net
244
252
 
245
253
  # 以私有权限打开设备日志文件。
246
254
  def open_file(path)
255
+ failure = nil
247
256
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
248
- file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
249
- begin
250
- # FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
251
- raise ArgumentError, "session log must be a regular file" unless file.stat.file?
252
-
253
- file.chmod(0o600)
254
- file.binmode
255
- file.write("\n") if file.size.positive? && @configuration.log_format == :text
256
- file
257
- rescue Exception # rubocop:disable Lint/RescueException -- 中断时也释放尚未完成设置的文件。
258
- begin
259
- file.close
260
- rescue Exception # rubocop:disable Lint/RescueException -- 保留文件设置的原始故障或中断。
261
- # 保留设置失败的原始原因。
262
- end
257
+ # 返回文件后先登记唯一所有者;后续设置失败由 open 统一释放,不重复交接资源。
258
+ Thread.handle_interrupt(Exception => :never) do
259
+ @io = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
260
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 非阻塞打开的主故障不能被待处理中断替换。
261
+ failure = error
263
262
  raise
264
263
  end
264
+ # FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
265
+ raise ArgumentError, "session log must be a regular file" unless @io.stat.file?
266
+
267
+ @io.chmod(0o600)
268
+ @io.binmode
269
+ @io.write("\n") if @io.size.positive? && @configuration.log_format == :text
270
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留打开阶段的首个故障。
271
+ raise failure || error, cause: nil
265
272
  end
266
273
 
267
274
  # 在日志打开失败后尽力释放资源。
268
275
  def close_preserving_failure
269
- close
276
+ Thread.handle_interrupt(Exception => :never) { close }
270
277
  rescue Exception # rubocop:disable Lint/RescueException -- 清理故障或中断不能覆盖日志打开失败。
271
278
  nil
272
279
  end
@@ -37,18 +37,23 @@ module Net
37
37
 
38
38
  # 打开伪终端、设置终端大小并启动设备进程。
39
39
  def open
40
+ failure = nil
40
41
  configuration.validate_endpoint!
41
- @channel = if @channel_factory
42
- @channel_factory.call
43
- else
44
- if defined?(Expect::Session)
42
+ # 构造期间的阻塞仍可取消;返回交接先登记信道,再交付异步中断。
43
+ Thread.handle_interrupt(Exception => :on_blocking) do
44
+ @channel = if @channel_factory
45
+ @channel_factory.call
46
+ elsif defined?(Expect::Session)
45
47
  Expect::Session.new(buffer_limit: nil, write_timeout: configuration.write_timeout)
46
48
  else
47
49
  Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
48
- preserve_buffer: false, log_stdout: false, log_listeners: false,
49
- debug_level: 0, write_timeout: configuration.write_timeout)
50
+ preserve_buffer: false, log_stdout: false, log_listeners: false,
51
+ debug_level: 0, write_timeout: configuration.write_timeout)
50
52
  end
51
- end
53
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 退出屏蔽范围交付取消之前,先保存构造器的同步故障。
54
+ failure = error
55
+ raise
56
+ end
52
57
  # Profile 使用宽、高;PTY 使用行数、列数。
53
58
  @channel.slave.winsize = @terminal_size.reverse if @terminal_size
54
59
  if @channel.respond_to?(:transcript=)
@@ -65,9 +70,9 @@ module Net
65
70
  @channel.spawn(*argv)
66
71
  end
67
72
  self
68
- rescue Exception # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
73
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
69
74
  close_preserving_failure
70
- raise
75
+ raise failure || error, cause: nil
71
76
  end
72
77
 
73
78
  # 从信道读取下一事件,并把字符串字段转换为字节字符串。
@@ -80,11 +85,21 @@ module Net
80
85
 
81
86
  # 临时设置写入超时,完成写入后恢复原设置。
82
87
  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
88
+ channel = @channel
89
+ previous = channel.write_timeout
90
+ begin
91
+ channel.write_timeout = timeout
92
+ channel.write(bytes)
93
+ rescue Exception # rubocop:disable Lint/RescueException -- 恢复超时前保留写入的首个故障或中断。
94
+ failed = true
95
+ raise
96
+ ensure
97
+ begin
98
+ channel.write_timeout = previous
99
+ rescue Exception # rubocop:disable Lint/RescueException -- 收尾故障不能把中断转换成普通写入错误。
100
+ raise unless failed
101
+ end
102
+ end
88
103
  end
89
104
 
90
105
  # 判断信道尚未建立或已经关闭。
@@ -97,10 +112,19 @@ module Net
97
112
 
98
113
  # 清除信道和日志目标,并强制关闭底层进程。
99
114
  def close
100
- channel = @channel
101
- @channel = nil
102
- @log_target = nil
103
- channel&.hard_close
115
+ failure = nil
116
+ # 从字段移交到关闭完成期间不能取消,避免丢失尚未回收的进程。
117
+ Thread.handle_interrupt(Exception => :never) do
118
+ channel = @channel
119
+ @channel = nil
120
+ @log_target = nil
121
+ channel&.hard_close
122
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
123
+ failure = error
124
+ raise
125
+ end
126
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留最先观察到的故障。
127
+ raise failure || error, cause: nil
104
128
  end
105
129
 
106
130
  # 把人工交互结果转换为设备 EOF、超时或输入状态。
@@ -146,14 +170,21 @@ module Net
146
170
  end
147
171
 
148
172
  def open
173
+ failure = nil
149
174
  configuration.validate_endpoint!
150
175
  unless configuration.host_key_policy == :strict
151
- @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
176
+ # 临时信任文件的返回交接与信道遵守同一所有权边界。
177
+ Thread.handle_interrupt(Exception => :on_blocking) do
178
+ @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
179
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 构造器先失败时,延迟取消不得覆盖其原始异常。
180
+ failure = error
181
+ raise
182
+ end
152
183
  end
153
184
  super
154
- rescue Exception # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
185
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
155
186
  close_preserving_failure
156
- raise
187
+ raise failure || error, cause: nil
157
188
  end
158
189
 
159
190
  def authenticated
@@ -162,18 +193,29 @@ module Net
162
193
  end
163
194
 
164
195
  def close
165
- known_hosts = @known_hosts
166
- @known_hosts = nil
167
- super
168
- rescue Exception # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
169
- failed = true
170
- raise
171
- ensure
172
- begin
173
- known_hosts&.close
174
- rescue Exception # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
175
- raise unless failed
196
+ failure = nil
197
+ # 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
198
+ Thread.handle_interrupt(Exception => :never) do
199
+ known_hosts = @known_hosts
200
+ @known_hosts = nil
201
+ begin
202
+ super
203
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
204
+ failure = error
205
+ raise
206
+ ensure
207
+ begin
208
+ known_hosts&.close
209
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
210
+ unless failure
211
+ failure = error
212
+ raise
213
+ end
214
+ end
215
+ end
176
216
  end
217
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
218
+ raise failure || error, cause: nil
177
219
  end
178
220
 
179
221
  # 返回 SSH 协议标识。
@@ -0,0 +1,109 @@
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
+ # 二次取消可能已以原故障为 cause;不能反向关联形成异常原因环。
105
+ raise primary_failure || error, cause: nil
106
+ end
107
+ end
108
+ end
109
+ 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)