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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/docs/VERIFICATION.md +1 -1
- data/docs/architecture.md +18 -5
- data/docs/netdisco.md +54 -1
- data/docs/netdisco.zh-CN.md +46 -2
- data/docs/usage.md +24 -7
- data/docs/usage.zh-CN.md +22 -8
- data/examples/backup.yml +1 -1
- data/lib/net/connector/background_task.rb +69 -0
- data/lib/net/connector/device/base.rb +5 -11
- data/lib/net/connector/device/running_config.rb +5 -3
- data/lib/net/connector/device/topology/interface_description.rb +3 -3
- data/lib/net/connector/device/topology/interface_name.rb +10 -19
- data/lib/net/connector/device/topology/stages.rb +50 -0
- data/lib/net/connector/device/topology.rb +65 -24
- data/lib/net/connector/device_session.rb +43 -0
- data/lib/net/connector/engine/known_hosts.rb +31 -3
- data/lib/net/connector/engine/log.rb +28 -21
- data/lib/net/connector/engine/transport.rb +74 -32
- data/lib/net/connector/execution_pool.rb +109 -0
- data/lib/net/connector/netdisco/backup_run.rb +14 -2
- data/lib/net/connector/netdisco/cli/options.rb +1 -0
- data/lib/net/connector/netdisco/cli.rb +18 -7
- data/lib/net/connector/netdisco/client.rb +5 -6
- data/lib/net/connector/netdisco/config_file.rb +5 -2
- data/lib/net/connector/netdisco/database_client.rb +33 -15
- data/lib/net/connector/netdisco/fleet.rb +68 -36
- data/lib/net/connector/netdisco/inventory_cache.rb +218 -0
- data/lib/net/connector/netdisco/operation_batch.rb +154 -0
- data/lib/net/connector/netdisco/operations.rb +268 -0
- data/lib/net/connector/netdisco/planner.rb +26 -17
- data/lib/net/connector/netdisco/progress.rb +22 -10
- data/lib/net/connector/netdisco/settings.rb +36 -6
- data/lib/net/connector/netdisco/tftp_archive.rb +2 -1
- data/lib/net/connector/netdisco/tftp_verification.rb +36 -11
- data/lib/net/connector/netdisco/worker.rb +7 -46
- data/lib/net/connector/storage/backup_lock.rb +29 -3
- data/lib/net/connector/storage/safe_file.rb +30 -4
- data/lib/net/connector/vendor/h3c/topology.rb +1 -1
- data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
- data/lib/net/connector/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
+
preserve_buffer: false, log_stdout: false, log_listeners: false,
|
|
51
|
+
debug_level: 0, write_timeout: configuration.write_timeout)
|
|
50
52
|
end
|
|
51
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|