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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -1
- data/docs/VERIFICATION.md +1 -1
- data/docs/architecture.md +16 -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/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 +38 -0
- data/lib/net/connector/engine/transport.rb +15 -5
- data/lib/net/connector/execution_pool.rb +108 -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/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
|
@@ -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,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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
74
|
+
# CLI 覆盖支持的环境变量与 YAML;设备凭据按环境或显式 CLI 参数解析。
|
|
75
75
|
def settings_for(options)
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
9
|
+
# 无凭据 YAML 配置;ND_URL、ND_CACHE_TTL、NC_CONCURRENCY 允许环境覆盖,其余策略由 CLI 覆盖。
|
|
10
10
|
class ConfigFile
|
|
11
11
|
FIELDS = {
|
|
12
|
-
"netdisco" => { "
|
|
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
|
-
#
|
|
82
|
-
Thread.handle_interrupt(
|
|
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
|
-
|
|
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
|
|
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
|