net-connector 0.8.0 → 0.9.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 +12 -0
- data/CONTRIBUTING.md +12 -0
- data/README.md +26 -425
- data/README.zh-CN.md +64 -0
- data/bin/net-backup +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +9 -4
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +5 -4
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +9 -8
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -2
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +14 -9
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/session.rb +14 -5
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +13 -5
- data/lib/net/connector/netdisco/cli/options.rb +17 -8
- data/lib/net/connector/netdisco/cli.rb +3 -3
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/database_client.rb +40 -24
- data/lib/net/connector/netdisco/device.rb +3 -0
- data/lib/net/connector/netdisco/diagnostic.rb +4 -0
- data/lib/net/connector/netdisco/fleet.rb +2 -2
- data/lib/net/connector/netdisco/plan.rb +12 -3
- data/lib/net/connector/netdisco/progress.rb +9 -0
- data/lib/net/connector/netdisco/report/files.rb +1 -0
- data/lib/net/connector/netdisco/result_store.rb +1 -0
- data/lib/net/connector/netdisco/rules.rb +7 -5
- data/lib/net/connector/netdisco/settings.rb +77 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +4 -0
- data/lib/net/connector/netdisco/worker.rb +1 -1
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +7 -16
|
@@ -25,8 +25,8 @@ module Net
|
|
|
25
25
|
@file.write(@original)
|
|
26
26
|
@file.flush
|
|
27
27
|
remove_host(@path) if @replace
|
|
28
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
29
|
-
|
|
28
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也删除私有快照。
|
|
29
|
+
close_preserving_failure
|
|
30
30
|
raise
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -57,13 +57,29 @@ module Net
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def close
|
|
60
|
-
|
|
61
|
-
@file&.close!
|
|
60
|
+
file = @file
|
|
62
61
|
@file = nil
|
|
62
|
+
File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
|
|
63
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 删除附属文件失败或中断后仍须关闭私有快照。
|
|
64
|
+
failed = true
|
|
65
|
+
raise
|
|
66
|
+
ensure
|
|
67
|
+
begin
|
|
68
|
+
file&.close!
|
|
69
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 私有快照清理不能覆盖先发生的删除故障。
|
|
70
|
+
raise unless failed
|
|
71
|
+
end
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
private
|
|
66
75
|
|
|
76
|
+
# 初始化失败时保留原始异常,同时尽力清理已经创建的临时文件。
|
|
77
|
+
def close_preserving_failure
|
|
78
|
+
close
|
|
79
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 不让清理异常覆盖初始化故障或用户中断。
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
67
83
|
def read_shared
|
|
68
84
|
File.open(@destination, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
69
85
|
raise IOError, "known_hosts must be a regular file" unless file.stat.file?
|
|
@@ -39,7 +39,7 @@ module Net
|
|
|
39
39
|
open_transcript if @io && @configuration.log_level == :debug
|
|
40
40
|
end
|
|
41
41
|
rescue => error
|
|
42
|
-
|
|
42
|
+
close_preserving_failure
|
|
43
43
|
raise build_error("unable to open session log", error), cause: nil
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -49,13 +49,13 @@ module Net
|
|
|
49
49
|
finish_output
|
|
50
50
|
@context = @context.merge(fields).freeze
|
|
51
51
|
yield
|
|
52
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
52
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 保留从当前作用域向外传播的故障,包括中断。
|
|
53
53
|
failed = true
|
|
54
54
|
raise
|
|
55
55
|
ensure
|
|
56
56
|
begin
|
|
57
57
|
finish_output
|
|
58
|
-
rescue
|
|
58
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 收尾故障或中断不能覆盖当前作用域的原始失败。
|
|
59
59
|
raise unless failed
|
|
60
60
|
ensure
|
|
61
61
|
@context = previous
|
|
@@ -171,13 +171,13 @@ module Net
|
|
|
171
171
|
finish_output
|
|
172
172
|
@transport.log_output = nil if @transport
|
|
173
173
|
yield
|
|
174
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
174
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 恢复日志接线,不覆盖当前作用域的故障或中断。
|
|
175
175
|
failed = true
|
|
176
176
|
raise
|
|
177
177
|
ensure
|
|
178
178
|
begin
|
|
179
179
|
@transport.log_output = self if @transport && @writer && @attached
|
|
180
|
-
rescue
|
|
180
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 接线恢复故障或中断不能覆盖原始操作异常。
|
|
181
181
|
# 恢复接线仍须尝试,但不能盖掉设备失败或用户中断。
|
|
182
182
|
raise unless failed
|
|
183
183
|
end
|
|
@@ -188,9 +188,14 @@ module Net
|
|
|
188
188
|
begin
|
|
189
189
|
@transport.log_output = nil if @transport
|
|
190
190
|
finish_output
|
|
191
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 脱离日志或刷新失败后仍须关闭文件并清除状态。
|
|
192
|
+
failed = true
|
|
193
|
+
raise
|
|
191
194
|
ensure
|
|
192
195
|
begin
|
|
193
196
|
@io&.close
|
|
197
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
|
|
198
|
+
raise unless failed
|
|
194
199
|
ensure
|
|
195
200
|
@writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
|
|
196
201
|
@attached = false
|
|
@@ -249,10 +254,10 @@ module Net
|
|
|
249
254
|
file.binmode
|
|
250
255
|
file.write("\n") if file.size.positive? && @configuration.log_format == :text
|
|
251
256
|
file
|
|
252
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
257
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也释放尚未完成设置的文件。
|
|
253
258
|
begin
|
|
254
259
|
file.close
|
|
255
|
-
rescue
|
|
260
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 保留文件设置的原始故障或中断。
|
|
256
261
|
# 保留设置失败的原始原因。
|
|
257
262
|
end
|
|
258
263
|
raise
|
|
@@ -260,9 +265,9 @@ module Net
|
|
|
260
265
|
end
|
|
261
266
|
|
|
262
267
|
# 在日志打开失败后尽力释放资源。
|
|
263
|
-
def
|
|
268
|
+
def close_preserving_failure
|
|
264
269
|
close
|
|
265
|
-
rescue
|
|
270
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 清理故障或中断不能覆盖日志打开失败。
|
|
266
271
|
nil
|
|
267
272
|
end
|
|
268
273
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
3
|
require_relative "authentication"
|
|
5
4
|
require_relative "log"
|
|
6
5
|
require_relative "redactor"
|
|
@@ -182,8 +181,11 @@ module Net
|
|
|
182
181
|
@state = :interacting
|
|
183
182
|
begin
|
|
184
183
|
transport.interact(**options)
|
|
184
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 交互失败或中断后仍关闭会话,保留本次原始异常。
|
|
185
|
+
failed = true
|
|
186
|
+
raise
|
|
185
187
|
ensure
|
|
186
|
-
|
|
188
|
+
failed ? close_preserving_failure : close_resources
|
|
187
189
|
end
|
|
188
190
|
end
|
|
189
191
|
end
|
|
@@ -249,7 +251,7 @@ module Net
|
|
|
249
251
|
failure = normalize_error(error, phase: phase)
|
|
250
252
|
close_preserving_failure
|
|
251
253
|
raise failure, cause: nil
|
|
252
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
254
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
|
|
253
255
|
close_preserving_failure
|
|
254
256
|
raise
|
|
255
257
|
ensure
|
|
@@ -384,8 +386,15 @@ module Net
|
|
|
384
386
|
@prompt = nil
|
|
385
387
|
begin
|
|
386
388
|
transport.close
|
|
389
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 传输关闭失败或中断后仍须尝试关闭日志。
|
|
390
|
+
failed = true
|
|
391
|
+
raise
|
|
387
392
|
ensure
|
|
388
|
-
|
|
393
|
+
begin
|
|
394
|
+
@log.close
|
|
395
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 日志收尾不能覆盖传输关闭的首个失败。
|
|
396
|
+
raise unless failed
|
|
397
|
+
end
|
|
389
398
|
end
|
|
390
399
|
nil
|
|
391
400
|
rescue => error
|
|
@@ -395,7 +404,7 @@ module Net
|
|
|
395
404
|
# 尝试清理资源但不覆盖原始操作异常。
|
|
396
405
|
def close_preserving_failure
|
|
397
406
|
close_resources
|
|
398
|
-
rescue
|
|
407
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 原操作失败时,清理故障或中断均不得覆盖它。
|
|
399
408
|
# 所有清理路径完成后,原始操作异常仍然是权威结果。
|
|
400
409
|
nil
|
|
401
410
|
end
|
|
@@ -65,12 +65,8 @@ module Net
|
|
|
65
65
|
@channel.spawn(*argv)
|
|
66
66
|
end
|
|
67
67
|
self
|
|
68
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
69
|
-
|
|
70
|
-
close
|
|
71
|
-
rescue
|
|
72
|
-
# 尝试释放半打开信道后,仍然抛出原始失败。
|
|
73
|
-
end
|
|
68
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
|
|
69
|
+
close_preserving_failure
|
|
74
70
|
raise
|
|
75
71
|
end
|
|
76
72
|
|
|
@@ -120,6 +116,13 @@ module Net
|
|
|
120
116
|
|
|
121
117
|
private
|
|
122
118
|
|
|
119
|
+
# 已有操作异常时仍尝试清理;次级故障或中断不能覆盖原始失败。
|
|
120
|
+
def close_preserving_failure
|
|
121
|
+
close
|
|
122
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 清理后继续传播原始操作异常。
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
123
126
|
# 检查缓冲上限并把设备输出写入当前日志目标。
|
|
124
127
|
def write_log_output(bytes)
|
|
125
128
|
if @channel.buffer.bytesize > configuration.max_output_bytes
|
|
@@ -148,8 +151,8 @@ module Net
|
|
|
148
151
|
@known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
|
|
149
152
|
end
|
|
150
153
|
super
|
|
151
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
152
|
-
|
|
154
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
|
|
155
|
+
close_preserving_failure
|
|
153
156
|
raise
|
|
154
157
|
end
|
|
155
158
|
|
|
@@ -159,10 +162,18 @@ module Net
|
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
def close
|
|
165
|
+
known_hosts = @known_hosts
|
|
166
|
+
@known_hosts = nil
|
|
162
167
|
super
|
|
168
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
169
|
+
failed = true
|
|
170
|
+
raise
|
|
163
171
|
ensure
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
begin
|
|
173
|
+
known_hosts&.close
|
|
174
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
175
|
+
raise unless failed
|
|
176
|
+
end
|
|
166
177
|
end
|
|
167
178
|
|
|
168
179
|
# 返回 SSH 协议标识。
|
|
@@ -14,19 +14,21 @@ module Net
|
|
|
14
14
|
module Netdisco
|
|
15
15
|
# 人类批次入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
|
|
16
16
|
class BackupRun
|
|
17
|
+
# 保存批次模式与调用方 IO,构造阶段不读取清单、不创建目录。
|
|
17
18
|
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr)
|
|
18
19
|
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
19
20
|
|
|
20
21
|
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# 先固定并校验策略,再预览、执行和保存报告;成功判定使用最终报告。
|
|
23
25
|
def run
|
|
24
26
|
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
|
|
25
27
|
return 0 if options[:help]
|
|
26
28
|
|
|
27
29
|
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
28
30
|
client, credentials = Connection.build(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
29
|
-
progress = Progress.new(io: @error, enabled:
|
|
31
|
+
progress = Progress.new(io: @error, enabled: !options[:no_progress], verbose: options[:verbose])
|
|
30
32
|
resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
|
|
31
33
|
fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
|
|
32
34
|
progress.reading_inventory
|
|
@@ -57,8 +59,9 @@ module Net
|
|
|
57
59
|
|
|
58
60
|
private
|
|
59
61
|
|
|
62
|
+
# 预检完成后才创建批次目录;补充日志路径时保持原策略快照与动态凭据来源。
|
|
60
63
|
def prepare(options)
|
|
61
|
-
settings = CLI::Options.settings(options, env: @env)
|
|
64
|
+
settings = CLI::Options.settings(options, env: @env).for_run(mode: @mode)
|
|
62
65
|
success_policy = options.fetch(:success_policy, :selected)
|
|
63
66
|
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
64
67
|
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
@@ -69,10 +72,10 @@ module Net
|
|
|
69
72
|
if @mode == :tftp && !settings.log_directory
|
|
70
73
|
settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
|
|
71
74
|
end
|
|
72
|
-
settings = settings.for_run(mode: @mode)
|
|
73
75
|
[settings, success_policy, server_root, batch_directory]
|
|
74
76
|
end
|
|
75
77
|
|
|
78
|
+
# 事件流仅记录受控元数据;计划写入后在有界工作线程中执行同一批设备。
|
|
76
79
|
def execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
77
80
|
File.open(File.join(batch_directory, "events.jsonl"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |events|
|
|
78
81
|
options = { plan: plan, concurrency: settings.concurrency, success_policy: success_policy,
|
|
@@ -89,6 +92,7 @@ module Net
|
|
|
89
92
|
end
|
|
90
93
|
end
|
|
91
94
|
|
|
95
|
+
# 将任务结果追加到私有事件流并更新进度;异常由工作线程保留为回调诊断。
|
|
92
96
|
def result_callback(progress, events)
|
|
93
97
|
lock = Mutex.new
|
|
94
98
|
lambda do |outcome|
|
|
@@ -102,6 +106,7 @@ module Net
|
|
|
102
106
|
end
|
|
103
107
|
end
|
|
104
108
|
|
|
109
|
+
# 报告先保存,再显示完成信息;终端收尾失败只补写诊断,不重放任务。
|
|
105
110
|
def finish(report, progress, options, batch_directory, plan, concurrency)
|
|
106
111
|
progress.finish(report)
|
|
107
112
|
progress.location(report.report_location) if report.report_location
|
|
@@ -121,21 +126,24 @@ module Net
|
|
|
121
126
|
report.policy_success? ? 0 : 1
|
|
122
127
|
end
|
|
123
128
|
|
|
129
|
+
# 输出故障只记录一次,保留已有批次结果和回调错误。
|
|
124
130
|
def record_progress_error(report, progress)
|
|
125
131
|
return report unless progress.output_error && !report.callback_errors.include?(progress.output_error)
|
|
126
132
|
|
|
127
133
|
report.with(callback_errors: [*report.callback_errors, progress.output_error].freeze)
|
|
128
134
|
end
|
|
129
135
|
|
|
136
|
+
# 优先使用显式核验根目录;仅服务器是本机地址时探测本机惯用 TFTP 目录。
|
|
130
137
|
def tftp_root(settings, options)
|
|
131
|
-
server = settings.tftp_server || (raise ArgumentError, "
|
|
132
|
-
return options[:tftp_root] ||
|
|
138
|
+
server = settings.tftp_server || (raise ArgumentError, "YAML tftp.server is required")
|
|
139
|
+
return options[:tftp_root] || settings.tftp_root if options[:tftp_root] || settings.tftp_root
|
|
133
140
|
return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
134
141
|
|
|
135
142
|
candidate = File.join(Dir.home, "Documents", "TFTP")
|
|
136
143
|
candidate if File.directory?(candidate)
|
|
137
144
|
end
|
|
138
145
|
|
|
146
|
+
# 预览来自已有计划,文件名按本次模式计算,不重新请求清单或设备。
|
|
139
147
|
def plan_summary(plan)
|
|
140
148
|
{ total: plan.inventory.size, ready: plan.inventory.count(&:ready?),
|
|
141
149
|
vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
|
|
@@ -11,6 +11,7 @@ module Net
|
|
|
11
11
|
class CLI
|
|
12
12
|
# 可注入参数与输入输出的备份选项,不退出宿主进程。
|
|
13
13
|
class Options
|
|
14
|
+
# 复制参数数组,返回选项或帮助标记;非法输入抛错,不退出宿主进程。
|
|
14
15
|
def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
|
|
15
16
|
values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
|
|
16
17
|
parser = parser_for(values, output: output, program: program)
|
|
@@ -22,7 +23,7 @@ module Net
|
|
|
22
23
|
raise ArgumentError, "--stdin-credentials 不能与命令行凭据或密码提示同时使用"
|
|
23
24
|
end
|
|
24
25
|
read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "设备密码", input: input, output: error)
|
|
25
|
-
read_password(values, :ask_netdisco_password, "
|
|
26
|
+
read_password(values, :ask_netdisco_password, "ND_PASSWORD", "Netdisco 密码", input: input, output: error)
|
|
26
27
|
|
|
27
28
|
values
|
|
28
29
|
rescue OptionParser::ParseError
|
|
@@ -36,6 +37,7 @@ module Net
|
|
|
36
37
|
add_execution(opts, values)
|
|
37
38
|
opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
|
|
38
39
|
opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
|
|
40
|
+
opts.on("--no-progress", "关闭进度显示") { values[:no_progress] = true }
|
|
39
41
|
opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
|
|
40
42
|
opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
|
|
41
43
|
opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
|
|
@@ -43,6 +45,7 @@ module Net
|
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# 正式 CLI 与批次示例共用并发、抽样、核验和成功策略选项。
|
|
46
49
|
def self.add_execution(opts, values)
|
|
47
50
|
opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
|
|
48
51
|
raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
|
|
@@ -60,14 +63,17 @@ module Net
|
|
|
60
63
|
end
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# 公开 CLI 参数进入 overrides,凭据留在 environment;内部键不会重新开放已移除环境设置。
|
|
63
67
|
def self.settings(options, env: ENV)
|
|
64
68
|
raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
|
|
65
69
|
|
|
66
70
|
environment = environment(options, env: env)
|
|
67
71
|
path = environment["NC_CONFIG"]
|
|
68
72
|
defaults = path && !path.empty? ? ConfigFile.load(path) : {}
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
keys = ConfigFile::FIELDS.values.flat_map { |fields| fields.values.map(&:first) }
|
|
74
|
+
overrides = options.fetch(:environment).slice(*keys)
|
|
75
|
+
environment = environment.reject { |key, _| overrides.key?(key) && !Settings::ENVIRONMENT_KEYS.include?(key) }
|
|
76
|
+
{ source: "ND_SOURCE", query: "ND_QUERY", query_params: "ND_QUERY_PARAMS",
|
|
71
77
|
max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
|
|
72
78
|
limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
|
|
73
79
|
overrides[key] = options[option].to_s if options.key?(option)
|
|
@@ -76,14 +82,15 @@ module Net
|
|
|
76
82
|
Settings.new(env: environment, defaults: defaults, overrides: overrides)
|
|
77
83
|
end
|
|
78
84
|
|
|
85
|
+
# 共用配置与凭据参数登记;具体入口决定后续预览、执行或导出。
|
|
79
86
|
def self.add_settings(opts, values)
|
|
80
87
|
{
|
|
81
88
|
"--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
|
|
82
89
|
"--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
|
|
83
90
|
"--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "设备提权密码"],
|
|
84
|
-
"--netdisco-url URL" => ["
|
|
85
|
-
"--netdisco-username USER" => ["
|
|
86
|
-
"--netdisco-password PASSWORD" => ["
|
|
91
|
+
"--netdisco-url URL" => ["ND_URL", "Netdisco 地址"],
|
|
92
|
+
"--netdisco-username USER" => ["ND_USERNAME", "Netdisco 用户名"],
|
|
93
|
+
"--netdisco-password PASSWORD" => ["ND_PASSWORD", "Netdisco 密码"],
|
|
87
94
|
"--directory DIR" => ["NC_BACKUP_DIRECTORY", "备份目录,相对项目根目录"],
|
|
88
95
|
"--config FILE" => ["NC_CONFIG", "YAML 配置,相对项目根目录"]
|
|
89
96
|
}.each do |flag, (key, description)|
|
|
@@ -101,6 +108,7 @@ module Net
|
|
|
101
108
|
end
|
|
102
109
|
end
|
|
103
110
|
|
|
111
|
+
# 复制原环境并合并显式凭据,清除被通用凭据覆盖的厂商值及旧 API key。
|
|
104
112
|
def self.environment(options, env: ENV)
|
|
105
113
|
result = env.to_h.dup
|
|
106
114
|
overrides = options.fetch(:environment)
|
|
@@ -114,12 +122,13 @@ module Net
|
|
|
114
122
|
(suffix != "PASSWORD" || !name.end_with?("_ENABLE_PASSWORD"))
|
|
115
123
|
end
|
|
116
124
|
end
|
|
117
|
-
if overrides.key?("
|
|
118
|
-
result.delete("
|
|
125
|
+
if overrides.key?("ND_USERNAME") || overrides.key?("ND_PASSWORD")
|
|
126
|
+
result.delete("ND_API_KEY")
|
|
119
127
|
end
|
|
120
128
|
result.merge(overrides)
|
|
121
129
|
end
|
|
122
130
|
|
|
131
|
+
# 隐藏输入只用于交互终端;与显式密码互斥,不输出或记录密码内容。
|
|
123
132
|
def self.read_password(values, flag, key, label, input:, output:)
|
|
124
133
|
return unless values[flag]
|
|
125
134
|
|
|
@@ -39,7 +39,7 @@ module Net
|
|
|
39
39
|
return 0
|
|
40
40
|
end
|
|
41
41
|
if options[:tftp] && !options[:plan] && !settings.tftp_server
|
|
42
|
-
raise ArgumentError, "
|
|
42
|
+
raise ArgumentError, "YAML tftp.server is required"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
fleet = @fleet_factory.call(settings)
|
|
@@ -71,7 +71,7 @@ module Net
|
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
-
#
|
|
74
|
+
# 公开 CLI 参数覆盖 YAML 与 ND_URL;设备凭据按环境或显式 CLI 参数解析。
|
|
75
75
|
def settings_for(options)
|
|
76
76
|
Options.settings(options, env: @env)
|
|
77
77
|
end
|
|
@@ -105,7 +105,7 @@ module Net
|
|
|
105
105
|
fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
|
|
106
106
|
concurrency: settings.concurrency,
|
|
107
107
|
vrfs: settings.tftp_vrfs,
|
|
108
|
-
source_files: settings.tftp_source_files, verification_root: options[:tftp_root] ||
|
|
108
|
+
source_files: settings.tftp_source_files, verification_root: options[:tftp_root] || settings.tftp_root, **reporting)
|
|
109
109
|
else
|
|
110
110
|
fleet.backup_all(plan: plan, directory: settings.backup_directory,
|
|
111
111
|
concurrency: settings.concurrency, **reporting)
|
|
@@ -6,20 +6,24 @@ require "yaml"
|
|
|
6
6
|
module Net
|
|
7
7
|
module Connector
|
|
8
8
|
module Netdisco
|
|
9
|
-
#
|
|
9
|
+
# 无凭据 YAML 配置;仅 ND_URL 允许从进程环境覆盖,其他策略由 CLI 覆盖。
|
|
10
10
|
class ConfigFile
|
|
11
11
|
FIELDS = {
|
|
12
|
-
"netdisco" => { "source" => ["
|
|
13
|
-
"query" => ["
|
|
14
|
-
"query_params" => ["
|
|
15
|
-
"url" => ["
|
|
16
|
-
"page_size" => ["
|
|
17
|
-
"max_pages" => ["
|
|
18
|
-
"max_response_bytes" => ["
|
|
19
|
-
"max_inventory_bytes" => ["
|
|
20
|
-
"max_devices" => ["
|
|
21
|
-
"inventory_timeout" => ["
|
|
22
|
-
"allow_insecure_http" => ["
|
|
12
|
+
"netdisco" => { "source" => ["ND_SOURCE", :string],
|
|
13
|
+
"query" => ["ND_QUERY", :string],
|
|
14
|
+
"query_params" => ["ND_QUERY_PARAMS", :json_array],
|
|
15
|
+
"url" => ["ND_URL", :string],
|
|
16
|
+
"page_size" => ["ND_PAGE_SIZE", :integer],
|
|
17
|
+
"max_pages" => ["ND_MAX_PAGES", :integer],
|
|
18
|
+
"max_response_bytes" => ["ND_MAX_RESPONSE_BYTES", :integer],
|
|
19
|
+
"max_inventory_bytes" => ["ND_MAX_INVENTORY_BYTES", :integer],
|
|
20
|
+
"max_devices" => ["ND_MAX_DEVICES", :integer],
|
|
21
|
+
"inventory_timeout" => ["ND_INVENTORY_TIMEOUT", :number],
|
|
22
|
+
"allow_insecure_http" => ["ND_ALLOW_INSECURE_HTTP", :boolean],
|
|
23
|
+
"db_port" => ["ND_DB_PORT", :integer],
|
|
24
|
+
"db_sslmode" => ["ND_DB_SSLMODE", :string],
|
|
25
|
+
"db_sslrootcert" => ["ND_DB_SSLROOTCERT", :string],
|
|
26
|
+
"db_connect_timeout" => ["ND_DB_CONNECT_TIMEOUT", :integer] },
|
|
23
27
|
"backup" => { "directory" => ["NC_BACKUP_DIRECTORY", :string],
|
|
24
28
|
"concurrency" => ["NC_CONCURRENCY", :integer],
|
|
25
29
|
"limit_per_vendor" => ["NC_SAMPLE_PER_VENDOR", :integer] },
|
|
@@ -36,14 +40,15 @@ module Net
|
|
|
36
40
|
"host_key_policy" => ["NC_HOST_KEY_POLICY", :string],
|
|
37
41
|
"log_directory" => ["NC_LOG_DIRECTORY", :string],
|
|
38
42
|
"log_level" => ["NC_LOG_LEVEL", :string] },
|
|
39
|
-
"tftp" => { "server" => ["
|
|
43
|
+
"tftp" => { "server" => ["NC_TFTP_HOST", :string],
|
|
44
|
+
"verification_root" => ["NC_TFTP_ROOT", :string],
|
|
40
45
|
"vrfs" => ["NC_TFTP_VRFS", :json],
|
|
41
46
|
"h3c_source_file" => ["NC_H3C_TFTP_SOURCE_FILE", :string],
|
|
42
47
|
"h3c_wireless_source_file" => ["NC_H3C_WIRELESS_TFTP_SOURCE_FILE", :string],
|
|
43
48
|
"huawei_source_file" => ["NC_HUAWEI_TFTP_SOURCE_FILE", :string] }
|
|
44
49
|
}.freeze
|
|
45
50
|
|
|
46
|
-
# 安全读取 YAML
|
|
51
|
+
# 安全读取 YAML 配置并编码为 Settings 的内部键值。
|
|
47
52
|
def self.load(path)
|
|
48
53
|
raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
49
54
|
|
|
@@ -8,15 +8,44 @@ module Net
|
|
|
8
8
|
module Netdisco
|
|
9
9
|
# SQL 负责业务选择和列别名;适配器只管理只读查询、清单预算与连接生命周期。
|
|
10
10
|
class DatabaseClient
|
|
11
|
+
# 只有数据库身份从环境读取;非敏感连接策略的内部选项与诊断名称另行映射。
|
|
11
12
|
CONNECTION_ENV = {
|
|
12
|
-
host: "
|
|
13
|
-
user: "NETDISCO_DB_USER", password: "NETDISCO_DB_PASS",
|
|
14
|
-
sslmode: "NETDISCO_DB_SSLMODE", sslrootcert: "NETDISCO_DB_SSLROOTCERT",
|
|
15
|
-
connect_timeout: "NETDISCO_DB_CONNECT_TIMEOUT"
|
|
13
|
+
host: "ND_DB_HOST", dbname: "ND_DB_NAME", user: "ND_DB_USER", password: "ND_DB_PASS"
|
|
16
14
|
}.freeze
|
|
15
|
+
CONNECTION_OPTIONS = CONNECTION_ENV.merge(
|
|
16
|
+
port: "netdisco.db_port", sslmode: "netdisco.db_sslmode", sslrootcert: "netdisco.db_sslrootcert",
|
|
17
|
+
connect_timeout: "netdisco.db_connect_timeout"
|
|
18
|
+
).freeze
|
|
17
19
|
REQUIRED_CONNECTION_KEYS = %i[host dbname user password].freeze
|
|
20
|
+
POLICY_KEYS = (CONNECTION_OPTIONS.keys - REQUIRED_CONNECTION_KEYS).freeze
|
|
21
|
+
private_constant :POLICY_KEYS
|
|
18
22
|
DEFAULTS = Client::DEFAULTS.except(:allow_insecure_http).freeze
|
|
19
23
|
|
|
24
|
+
# 预检与实际连接共用公开策略校验;不读取身份凭据,也不访问文件或数据库。
|
|
25
|
+
def self.connection_policy(**options)
|
|
26
|
+
raise ArgumentError, "unknown PostgreSQL connection policy" unless (options.keys - POLICY_KEYS).empty?
|
|
27
|
+
|
|
28
|
+
options.each do |key, value|
|
|
29
|
+
valid = value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
30
|
+
valid ||= %i[port connect_timeout].include?(key) && value.is_a?(Integer)
|
|
31
|
+
raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is invalid" unless valid
|
|
32
|
+
end
|
|
33
|
+
%i[port connect_timeout].each do |key|
|
|
34
|
+
next unless options.key?(key)
|
|
35
|
+
|
|
36
|
+
value = options.fetch(key).to_s
|
|
37
|
+
maximum = key == :port ? 65_535 : 2_147_483_647
|
|
38
|
+
unless value.match?(/\A\d+\z/) && value.to_i.between?(1, maximum)
|
|
39
|
+
raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} must be a positive integer within range"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
if options[:sslmode] && !%w[disable allow prefer require verify-ca verify-full].include?(options[:sslmode])
|
|
43
|
+
raise ArgumentError, "netdisco.db_sslmode is invalid"
|
|
44
|
+
end
|
|
45
|
+
options.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 纯校验 SQL、绑定参数和清单预算,不连接数据库或打印查询内容。
|
|
20
49
|
def self.options(query:, query_params: [], **values)
|
|
21
50
|
unless query.is_a?(String) && !query.strip.empty? && !query.include?("\0")
|
|
22
51
|
raise ArgumentError, "query must be a nonempty SQL string without NUL"
|
|
@@ -35,6 +64,7 @@ module Net
|
|
|
35
64
|
).freeze
|
|
36
65
|
end
|
|
37
66
|
|
|
67
|
+
# 复制并校验查询与连接选项;真正连接由 devices 的资源作用域管理。
|
|
38
68
|
def initialize(connection_options:, **options)
|
|
39
69
|
@options = self.class.options(**options)
|
|
40
70
|
@connection_options = validate_connection_options!(connection_options)
|
|
@@ -105,36 +135,22 @@ module Net
|
|
|
105
135
|
end
|
|
106
136
|
end
|
|
107
137
|
|
|
138
|
+
# 数据库名不得嵌入 URI/conninfo;合法选项仍受清单总期限及只读查询边界约束。
|
|
108
139
|
def validate_connection_options!(options)
|
|
109
|
-
unless options.is_a?(Hash) && (options.keys -
|
|
140
|
+
unless options.is_a?(Hash) && (options.keys - CONNECTION_OPTIONS.keys).empty?
|
|
110
141
|
raise ArgumentError, "connection_options must contain supported PostgreSQL connection keys"
|
|
111
142
|
end
|
|
112
143
|
REQUIRED_CONNECTION_KEYS.each do |key|
|
|
113
144
|
value = options[key]
|
|
114
145
|
unless value.is_a?(String) && !value.strip.empty?
|
|
115
|
-
raise ArgumentError, "#{
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
options.each do |key, value|
|
|
119
|
-
valid = value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
120
|
-
valid ||= %i[port connect_timeout].include?(key) && value.is_a?(Integer)
|
|
121
|
-
raise ArgumentError, "#{CONNECTION_ENV.fetch(key)} is invalid" unless valid
|
|
122
|
-
end
|
|
123
|
-
%i[port connect_timeout].each do |key|
|
|
124
|
-
next unless options.key?(key)
|
|
125
|
-
|
|
126
|
-
value = options.fetch(key).to_s
|
|
127
|
-
maximum = key == :port ? 65_535 : 2_147_483_647
|
|
128
|
-
unless value.match?(/\A\d+\z/) && value.to_i.between?(1, maximum)
|
|
129
|
-
raise ArgumentError, "#{CONNECTION_ENV.fetch(key)} must be a positive integer within range"
|
|
146
|
+
raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is required"
|
|
130
147
|
end
|
|
148
|
+
raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is invalid" if value.include?("\0")
|
|
131
149
|
end
|
|
132
|
-
|
|
133
|
-
raise ArgumentError, "NETDISCO_DB_SSLMODE is invalid"
|
|
134
|
-
end
|
|
150
|
+
self.class.connection_policy(**options.slice(*POLICY_KEYS))
|
|
135
151
|
# libpq 会把 dbname 中的 URI/conninfo 再解释为连接参数,不能借此绕过凭据来源。
|
|
136
152
|
if options.fetch(:dbname).match?(/=|\Apostgres(?:ql)?:\/\//)
|
|
137
|
-
raise ArgumentError, "
|
|
153
|
+
raise ArgumentError, "ND_DB_NAME must be a database name, not a connection string"
|
|
138
154
|
end
|
|
139
155
|
options.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
140
156
|
end
|
|
@@ -17,6 +17,9 @@ module Net
|
|
|
17
17
|
super(*frozen_values, **frozen_attributes)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Ruby 3.2 的 Data#with 不调用初始化;更新也须复制并冻结外部字符串。
|
|
21
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
22
|
+
|
|
20
23
|
# 将 Netdisco 清单记录转换为可规划的设备对象。
|
|
21
24
|
def self.from_row(row, rules:)
|
|
22
25
|
valid = row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].empty? &&
|
|
@@ -30,6 +30,9 @@ module Net
|
|
|
30
30
|
verification: verification == :device_reported ? verification : nil)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# 更新也经过初始化白名单;Ruby 3.2 的原生 Data#with 会跳过这一边界。
|
|
34
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
35
|
+
|
|
33
36
|
# 读取已知错误的白名单属性后立即丢弃异常引用,不把回调对象保留进批次。
|
|
34
37
|
def self.from(error, backup: nil, phase: nil)
|
|
35
38
|
return unless error
|
|
@@ -44,6 +47,7 @@ module Net
|
|
|
44
47
|
new(error_type: "StandardError", phase: phase)
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# 只有本库错误与匹配的业务回执才能证明提交状态,不能采信任意对象自报成功。
|
|
47
51
|
def self.completion_fields(error, backup, phase)
|
|
48
52
|
if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
|
|
49
53
|
{ artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
|