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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +12 -0
  4. data/README.md +26 -425
  5. data/README.zh-CN.md +64 -0
  6. data/bin/net-backup +1 -0
  7. data/docs/VERIFICATION.md +2 -2
  8. data/docs/architecture.md +9 -4
  9. data/docs/netdisco.md +176 -0
  10. data/docs/netdisco.zh-CN.md +247 -0
  11. data/docs/usage.md +124 -0
  12. data/docs/usage.zh-CN.md +190 -0
  13. data/examples/backup.yml +5 -4
  14. data/examples/inventory_sql.yml +5 -1
  15. data/lib/net/connector/device/base.rb +5 -4
  16. data/lib/net/connector/device/local_backup.rb +3 -0
  17. data/lib/net/connector/device/running_config/strategy.rb +2 -1
  18. data/lib/net/connector/device/running_config.rb +2 -0
  19. data/lib/net/connector/device/tftp/receipt.rb +1 -0
  20. data/lib/net/connector/device/tftp/strategy.rb +28 -9
  21. data/lib/net/connector/device/tftp.rb +9 -8
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
  23. data/lib/net/connector/engine/core.rb +1 -0
  24. data/lib/net/connector/engine/dialogue.rb +5 -2
  25. data/lib/net/connector/engine/known_hosts.rb +20 -4
  26. data/lib/net/connector/engine/log.rb +14 -9
  27. data/lib/net/connector/engine/recovery.rb +1 -1
  28. data/lib/net/connector/engine/session.rb +14 -5
  29. data/lib/net/connector/engine/transport.rb +21 -10
  30. data/lib/net/connector/netdisco/backup_run.rb +13 -5
  31. data/lib/net/connector/netdisco/cli/options.rb +17 -8
  32. data/lib/net/connector/netdisco/cli.rb +3 -3
  33. data/lib/net/connector/netdisco/config_file.rb +19 -14
  34. data/lib/net/connector/netdisco/database_client.rb +40 -24
  35. data/lib/net/connector/netdisco/device.rb +3 -0
  36. data/lib/net/connector/netdisco/diagnostic.rb +4 -0
  37. data/lib/net/connector/netdisco/fleet.rb +2 -2
  38. data/lib/net/connector/netdisco/plan.rb +12 -3
  39. data/lib/net/connector/netdisco/progress.rb +9 -0
  40. data/lib/net/connector/netdisco/report/files.rb +1 -0
  41. data/lib/net/connector/netdisco/result_store.rb +1 -0
  42. data/lib/net/connector/netdisco/rules.rb +7 -5
  43. data/lib/net/connector/netdisco/settings.rb +77 -19
  44. data/lib/net/connector/netdisco/tftp_archive.rb +4 -0
  45. data/lib/net/connector/netdisco/worker.rb +1 -1
  46. data/lib/net/connector/storage/private_file.rb +6 -0
  47. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  48. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
  49. data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
  50. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
  51. data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
  52. data/lib/net/connector/vendor/h3c/topology.rb +2 -0
  53. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  54. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
  55. data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
  56. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
  57. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -0
  58. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
  59. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
  60. data/lib/net/connector/vendor/radware/running_config.rb +1 -0
  61. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
  62. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  63. data/lib/net/connector/version.rb +2 -1
  64. data/lib/net/connector.rb +1 -0
  65. data/lib/net-connector.rb +1 -0
  66. 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 -- Remove the private snapshot on interruption.
29
- close
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
- File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
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
- close_preserving_error
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 -- Preserve only failures propagating from this scope, including interrupts.
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 Error
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 -- Restore attachment without replacing this scope's failure or interruption.
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 StandardError
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 -- Release partially configured files on interrupts too.
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 close_preserving_error
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
 
@@ -48,7 +48,7 @@ module Net
48
48
  @rules.freeze
49
49
  end
50
50
 
51
- # 依次尝试恢复规则,并只返回第一个新传输对象。
51
+ # 依次尝试恢复规则,只采用首个可用传输;密钥替换可以复用原对象。
52
52
  def recover(error, transport)
53
53
  @rules.lazy.filter_map { |rule| rule.recover(error, transport) }.first
54
54
  end
@@ -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
- $ERROR_INFO ? close_preserving_failure : close_resources
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 -- Interrupts release the transport, then propagate.
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
- @log.close
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 -- A half-open PTY must be released on interrupts.
69
- begin
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 -- Release a staged trust file on failed startup.
152
- close
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
- @known_hosts&.close
165
- @known_hosts = nil
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: @env.fetch("NC_PROGRESS", "1") != "0", verbose: options[:verbose])
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, "TFTP_HOST or YAML tftp.server is required")
132
- return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["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, "NETDISCO_PASSWORD", "Netdisco 密码", input: input, output: error)
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
- overrides = {}
70
- { source: "NETDISCO_SOURCE", query: "NETDISCO_QUERY", query_params: "NETDISCO_QUERY_PARAMS",
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" => ["NETDISCO_URL", "Netdisco 地址"],
85
- "--netdisco-username USER" => ["NETDISCO_USERNAME", "Netdisco 用户名"],
86
- "--netdisco-password PASSWORD" => ["NETDISCO_PASSWORD", "Netdisco 密码"],
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?("NETDISCO_USERNAME") || overrides.key?("NETDISCO_PASSWORD")
118
- result.delete("NETDISCO_API_KEY")
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, "TFTP_HOST is required"
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] || @env["TFTP_ROOT"], **reporting)
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
- # 命令行使用的无凭据 YAML 配置,环境变量优先。
9
+ # 无凭据 YAML 配置;仅 ND_URL 允许从进程环境覆盖,其他策略由 CLI 覆盖。
10
10
  class ConfigFile
11
11
  FIELDS = {
12
- "netdisco" => { "source" => ["NETDISCO_SOURCE", :string],
13
- "query" => ["NETDISCO_QUERY", :string],
14
- "query_params" => ["NETDISCO_QUERY_PARAMS", :json_array],
15
- "url" => ["NETDISCO_URL", :string],
16
- "page_size" => ["NETDISCO_PAGE_SIZE", :integer],
17
- "max_pages" => ["NETDISCO_MAX_PAGES", :integer],
18
- "max_response_bytes" => ["NETDISCO_MAX_RESPONSE_BYTES", :integer],
19
- "max_inventory_bytes" => ["NETDISCO_MAX_INVENTORY_BYTES", :integer],
20
- "max_devices" => ["NETDISCO_MAX_DEVICES", :integer],
21
- "inventory_timeout" => ["NETDISCO_INVENTORY_TIMEOUT", :number],
22
- "allow_insecure_http" => ["NETDISCO_ALLOW_INSECURE_HTTP", :boolean] },
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" => ["TFTP_HOST", :string],
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: "NETDISCO_DB_HOST", port: "NETDISCO_DB_PORT", dbname: "NETDISCO_DB_NAME",
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 - CONNECTION_ENV.keys).empty?
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, "#{CONNECTION_ENV.fetch(key)} is required"
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
- if options[:sslmode] && !%w[disable allow prefer require verify-ca verify-full].include?(options[:sslmode])
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, "NETDISCO_DB_NAME must be a database name, not a connection string"
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,