net-connector 0.6.0 → 0.7.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +112 -50
  5. data/docs/VERIFICATION.md +42 -1
  6. data/docs/architecture.md +15 -2
  7. data/examples/backup.yml +40 -0
  8. data/lib/net/connector/device/base.rb +30 -38
  9. data/lib/net/connector/device/profile/builder.rb +8 -8
  10. data/lib/net/connector/device/profile.rb +2 -2
  11. data/lib/net/connector/device/running_config.rb +2 -2
  12. data/lib/net/connector/device/tftp/receipt.rb +12 -3
  13. data/lib/net/connector/device/tftp.rb +7 -7
  14. data/lib/net/connector/device/topology.rb +4 -4
  15. data/lib/net/connector/engine/command.rb +2 -2
  16. data/lib/net/connector/engine/configuration.rb +5 -2
  17. data/lib/net/connector/engine/execution.rb +25 -14
  18. data/lib/net/connector/engine/known_hosts.rb +112 -0
  19. data/lib/net/connector/engine/log.rb +10 -7
  20. data/lib/net/connector/engine/session.rb +117 -128
  21. data/lib/net/connector/engine/transport.rb +32 -7
  22. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  23. data/lib/net/connector/netdisco/batch.rb +1 -1
  24. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  25. data/lib/net/connector/netdisco/cli.rb +13 -26
  26. data/lib/net/connector/netdisco/client.rb +6 -6
  27. data/lib/net/connector/netdisco/config_file.rb +20 -19
  28. data/lib/net/connector/netdisco/connection.rb +38 -0
  29. data/lib/net/connector/netdisco/database_client.rb +2 -2
  30. data/lib/net/connector/netdisco/device.rb +11 -1
  31. data/lib/net/connector/netdisco/fleet.rb +68 -47
  32. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  33. data/lib/net/connector/netdisco/plan.rb +14 -9
  34. data/lib/net/connector/netdisco/planner.rb +18 -14
  35. data/lib/net/connector/netdisco/progress.rb +253 -0
  36. data/lib/net/connector/netdisco/report/files.rb +41 -0
  37. data/lib/net/connector/netdisco/report/text.rb +45 -0
  38. data/lib/net/connector/netdisco/report.rb +53 -31
  39. data/lib/net/connector/netdisco/result_store.rb +3 -1
  40. data/lib/net/connector/netdisco/rules.rb +10 -2
  41. data/lib/net/connector/netdisco/settings.rb +118 -68
  42. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  43. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  44. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  45. data/lib/net/connector/netdisco/worker.rb +8 -3
  46. data/lib/net/connector/netdisco.rb +10 -0
  47. data/lib/net/connector/storage/backup_lock.rb +1 -1
  48. data/lib/net/connector/storage/batch_directory.rb +29 -0
  49. data/lib/net/connector/storage.rb +1 -0
  50. data/lib/net/connector/vendor/h3c.rb +2 -1
  51. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  53. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  54. data/lib/net/connector/vendor/radware.rb +4 -3
  55. data/lib/net/connector/version.rb +1 -1
  56. data/lib/net/connector.rb +2 -2
  57. metadata +42 -111
  58. /data/examples/{netdisco_database.yml → inventory_sql.yml} +0 -0
@@ -30,13 +30,10 @@ module Net
30
30
  @lock = Mutex.new
31
31
  end
32
32
 
33
- # 判断会话状态和传输层是否都表明连接仍然有效。
34
33
  def connected? = state != :closed && !transport.closed?
35
34
 
36
- # 返回当前是否已经进入特权模式。
37
35
  def privileged? = @privileged
38
36
 
39
- # 标记当前会话已进入特权模式。
40
37
  def mark_privileged! = @privileged = true
41
38
 
42
39
  # 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
@@ -63,16 +60,16 @@ module Net
63
60
  end
64
61
 
65
62
  # 锁覆盖完整操作而不是单次写入,保证批处理命令不会交错。
66
- def perform(phase)
63
+ def perform(phase, &)
67
64
  if @operation_owner == [Thread.current, Fiber.current] && !@performing
68
- return perform_locked(phase) { yield }
65
+ return perform_locked(phase, &)
69
66
  end
70
67
  unless @lock.try_lock
71
68
  raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
72
69
  end
73
70
 
74
71
  begin
75
- perform_locked(phase) { yield }
72
+ perform_locked(phase, &)
76
73
  ensure
77
74
  @lock.unlock
78
75
  end
@@ -106,37 +103,6 @@ module Net
106
103
  raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
107
104
  end
108
105
 
109
- # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
110
- def perform_locked(phase)
111
- @performing = true
112
- completed = false
113
- begin
114
- unless connected?
115
- close_resources unless state == :closed
116
- connect_session
117
- end
118
- @state = (phase == :connect) ? :ready : :executing
119
- result = yield
120
- completed = true
121
- result
122
- rescue => error
123
- failure = normalize_error(error, phase: phase)
124
- close_preserving_failure
125
- raise failure, cause: nil
126
- rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
127
- close_preserving_failure
128
- raise
129
- ensure
130
- # throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
131
- # 半途的设备提示符误当成下一条命令的响应。
132
- close_preserving_failure unless completed || !connected?
133
- @state = :ready if connected?
134
- @performing = false
135
- end
136
- end
137
-
138
- private :perform_locked
139
-
140
106
  # 独占关闭会话;已有操作占用时返回会话繁忙错误。
141
107
  def close
142
108
  unless @lock.try_lock
@@ -195,6 +161,99 @@ module Net
195
161
  end
196
162
  end
197
163
 
164
+ # 按截止时间限制传输写入,并统一包装底层错误。
165
+ def write(bytes, deadline:, phase:, command: nil)
166
+ remaining = [deadline - Expect.monotonic, 0].max
167
+ transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
168
+ rescue => error
169
+ raise normalize_error(error, phase: phase, command: command), cause: nil
170
+ end
171
+
172
+ # 人工交互会结束当前连接,未完成的输入行不能在之后重放。
173
+ def interact(**options)
174
+ perform(:interact) do
175
+ @state = :interacting
176
+ begin
177
+ transport.interact(**options)
178
+ ensure
179
+ $ERROR_INFO ? close_preserving_failure : close_resources
180
+ end
181
+ end
182
+ end
183
+
184
+ # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
185
+ def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
186
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
187
+ message = safe_error_text(message, sensitive: sensitive)
188
+ output = safe_error_text(output, sensitive: sensitive)
189
+ klass.new(message, host: configuration.host, phase: phase,
190
+ command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
191
+ source: command&.source, line: command&.line,
192
+ output: truncate_output(output),
193
+ underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
194
+ end
195
+
196
+ # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
197
+ def normalize_error(exception, phase:, command: nil)
198
+ if exception.is_a?(Error)
199
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
200
+ failed_command = exception.command || command&.text
201
+ if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
202
+ failed_command = "[REDACTED]"
203
+ end
204
+ message = safe_error_text(exception.message, sensitive: sensitive)
205
+ output = safe_error_text(exception.output, sensitive: sensitive)
206
+ return exception.with_diagnostics(
207
+ message: message,
208
+ host: exception.host || configuration.host, phase: exception.phase || phase,
209
+ command: failed_command && redactor.call(failed_command),
210
+ source: exception.source || command&.source, line: exception.line || command&.line,
211
+ output: truncate_output(output),
212
+ underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
213
+ )
214
+ end
215
+
216
+ klass = case exception
217
+ when Expect::WriteTimeout then WriteTimeout
218
+ when IOError, SystemCallError, Expect::SpawnError then TransportError
219
+ else InternalError
220
+ end
221
+ build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
222
+ end
223
+
224
+ def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
225
+
226
+ private
227
+
228
+ # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
229
+ def perform_locked(phase)
230
+ @performing = true
231
+ completed = false
232
+ begin
233
+ unless connected?
234
+ close_resources unless state == :closed
235
+ connect_session
236
+ end
237
+ @state = (phase == :connect) ? :ready : :executing
238
+ result = yield
239
+ completed = true
240
+ result
241
+ rescue => error
242
+ failure = normalize_error(error, phase: phase)
243
+ close_preserving_failure
244
+ raise failure, cause: nil
245
+ rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
246
+ close_preserving_failure
247
+ raise
248
+ ensure
249
+ # throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
250
+ # 半途的设备提示符误当成下一条命令的响应。
251
+ close_preserving_failure unless completed || !connected?
252
+ @state = :ready if connected?
253
+ @performing = false
254
+ end
255
+ end
256
+
198
257
  def execute_command_with_logging(command, timeout:, prompt:)
199
258
  started = Expect.monotonic
200
259
  @log.log_event("command_start")
@@ -224,8 +283,6 @@ module Net
224
283
  end
225
284
  end
226
285
 
227
- private :execute_command_with_logging, :elapsed_ms, :log_operation_completion
228
-
229
286
  # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
230
287
  def protect_command(command)
231
288
  redactor.output_sensitive! if command.output_sensitive?
@@ -235,8 +292,6 @@ module Net
235
292
  redactor.remember(command.text) if command.sensitive?
236
293
  end
237
294
 
238
- private :protect_command
239
-
240
295
  # 写入命令、读取对话、识别设备诊断,并返回响应对象。
241
296
  def exchange_command(command, timeout:, prompt: nil)
242
297
  deadline = Expect.monotonic + (command.timeout || timeout)
@@ -260,109 +315,23 @@ module Net
260
315
  end
261
316
  end
262
317
 
263
- private :exchange_command
264
-
265
- # 按截止时间限制传输写入,并统一包装底层错误。
266
- def write(bytes, deadline:, phase:, command: nil)
267
- remaining = [deadline - Expect.monotonic, 0].max
268
- transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
269
- rescue => error
270
- raise normalize_error(error, phase: phase, command: command), cause: nil
271
- end
272
-
273
- # 人工交互会结束当前连接,未完成的输入行不能在之后重放。
274
- def interact(**options)
275
- perform(:interact) do
276
- @state = :interacting
277
- begin
278
- transport.interact(**options)
279
- ensure
280
- $ERROR_INFO ? close_preserving_failure : close_resources
281
- end
282
- end
283
- end
284
-
285
- # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
286
- def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
287
- sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
288
- message = safe_error_text(message, sensitive: sensitive)
289
- output = safe_error_text(output, sensitive: sensitive)
290
- klass.new(message, host: configuration.host, phase: phase,
291
- command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
292
- source: command&.source, line: command&.line,
293
- output: truncate_output(output),
294
- underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
295
- end
296
-
297
- # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
298
- def normalize_error(exception, phase:, command: nil)
299
- if exception.is_a?(Error)
300
- sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
301
- failed_command = exception.command || command&.text
302
- if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
303
- failed_command = "[REDACTED]"
304
- end
305
- message = safe_error_text(exception.message, sensitive: sensitive)
306
- output = safe_error_text(exception.output, sensitive: sensitive)
307
- return exception.with_diagnostics(
308
- message: message,
309
- host: exception.host || configuration.host, phase: exception.phase || phase,
310
- command: failed_command && redactor.call(failed_command),
311
- source: exception.source || command&.source, line: exception.line || command&.line,
312
- output: truncate_output(output),
313
- underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
314
- )
315
- end
316
-
317
- klass = case exception
318
- when Expect::WriteTimeout then WriteTimeout
319
- when IOError, SystemCallError, Expect::SpawnError then TransportError
320
- else InternalError
321
- end
322
- build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
323
- end
324
-
325
318
  # 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
326
319
  def safe_error_text(text, sensitive:)
327
320
  sensitive && !text.to_s.empty? ? "[REDACTED]".b : redactor.call(text)
328
321
  end
329
322
 
330
- private :safe_error_text
331
-
332
323
  # 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
333
324
  def truncate_output(text)
334
325
  text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
335
326
  end
336
327
 
337
- private :truncate_output
338
-
339
- # 返回不包含凭据的会话状态摘要。
340
- def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
341
-
342
- private
343
-
344
328
  # 建立传输并登录;失败时最多执行一次连接级恢复,然后重试登录。
345
329
  def connect_session
346
330
  attempted_recovery = false
347
331
  begin
348
332
  @state = :connecting
349
333
  started = Expect.monotonic
350
- redactor.reset
351
- @log.open(transport)
352
- @log.log_event("connect", host: configuration.host, username: configuration.username,
353
- phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
354
- transport.open
355
- @state = :authenticating
356
- @log.log_event("login_start", level: :debug, phase: :login)
357
- response = @authentication.login
358
- safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
359
- prompt: response.prompt)
360
- @prompt = response.prompt
361
- @after_login.call(self, safe_response)
362
- @log.log_authentication_output(response.raw)
363
- @log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
364
- @log.attach
365
- @state = :ready
334
+ login_once(started)
366
335
  rescue => error
367
336
  phase = (@state == :connecting) ? :connect : :login
368
337
  failure = normalize_error(error, phase: phase)
@@ -379,6 +348,26 @@ module Net
379
348
  end
380
349
  end
381
350
 
351
+ def login_once(started)
352
+ redactor.reset
353
+ @log.open(transport)
354
+ @log.log_event("connect", host: configuration.host, username: configuration.username,
355
+ phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
356
+ transport.open
357
+ @state = :authenticating
358
+ @log.log_event("login_start", level: :debug, phase: :login)
359
+ response = @authentication.login
360
+ transport.authenticated if transport.respond_to?(:authenticated)
361
+ safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
362
+ prompt: response.prompt)
363
+ @prompt = response.prompt
364
+ @after_login.call(self, safe_response)
365
+ @log.log_authentication_output(response.raw)
366
+ @log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
367
+ @log.attach
368
+ @state = :ready
369
+ end
370
+
382
371
  # 清除会话状态并关闭传输和日志资源。
383
372
  def close_resources
384
373
  @state = :closed
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "expect/pty"
5
+ require_relative "known_hosts"
5
6
 
6
7
  module Net
7
8
  module Connector
@@ -125,6 +126,29 @@ module Net
125
126
  @legacy_arguments = legacy_arguments.dup.freeze
126
127
  end
127
128
 
129
+ def open
130
+ configuration.validate_endpoint!
131
+ unless configuration.host_key_policy == :strict
132
+ @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
133
+ end
134
+ super
135
+ rescue Exception # rubocop:disable Lint/RescueException -- Release a staged trust file on failed startup.
136
+ close
137
+ raise
138
+ end
139
+
140
+ def authenticated
141
+ @known_hosts&.commit
142
+ @replace_host_key = false
143
+ end
144
+
145
+ def close
146
+ super
147
+ ensure
148
+ @known_hosts&.close
149
+ @known_hosts = nil
150
+ end
151
+
128
152
  # 返回 SSH 协议标识。
129
153
  def protocol = :ssh
130
154
 
@@ -133,9 +157,10 @@ module Net
133
157
  configuration.validate_endpoint!
134
158
  checking = (configuration.host_key_policy == :strict) ? "yes" : "accept-new"
135
159
  arguments = ["ssh", *legacy_arguments, "-tt", "-o", "StrictHostKeyChecking=#{checking}",
136
- "-o", "NumberOfPasswordPrompts=1",
160
+ "-o", "NumberOfPasswordPrompts=1", "-o", "UpdateHostKeys=no",
137
161
  "-o", "ConnectTimeout=#{[configuration.login_timeout.ceil, 1].max}"]
138
- arguments += ["-o", "UserKnownHostsFile=#{configuration.known_hosts}"] if configuration.known_hosts
162
+ known_hosts = @known_hosts&.path || configuration.known_hosts
163
+ arguments += ["-o", "UserKnownHostsFile=#{known_hosts}"] if known_hosts
139
164
  arguments += ["-p", configuration.port.to_s] if configuration.port
140
165
  arguments + ["-l", configuration.username, configuration.host]
141
166
  end
@@ -151,13 +176,13 @@ module Net
151
176
  Telnet.new(configuration, channel_factory: @channel_factory, terminal_size: @terminal_size)
152
177
  end
153
178
 
154
- # 从指定 known_hosts 文件中删除当前设备的旧主机密钥。
179
+ # 恢复只标记下一次会话副本;共享信任文件在认证成功后才更新。
155
180
  def replace_host_key
156
- host = configuration.host
157
- host = "[#{host}]:#{configuration.port}" if configuration.port && configuration.port != 22
158
- _output, status = Open3.capture2e("ssh-keygen", "-f", configuration.known_hosts, "-R", host)
159
- raise IOError, "removing device host key failed" unless status.success?
181
+ raise IOError, "host key replacement is not enabled" unless configuration.host_key_policy == :replace
182
+
183
+ @replace_host_key = true
160
184
  end
185
+
161
186
  end
162
187
 
163
188
  # Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ # 人类批次入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
10
+ class BackupRun
11
+ def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr)
12
+ raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
13
+
14
+ @mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
15
+ end
16
+
17
+ def run
18
+ options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
19
+ return 0 if options[:help]
20
+
21
+ settings, success_policy, server_root, batch_directory = prepare(options)
22
+ client, credentials = Connection.build(settings, stdin_credentials: options[:stdin_credentials], input: @input)
23
+ progress = Progress.new(io: @error, enabled: @env.fetch("NC_PROGRESS", "1") != "0", verbose: options[:verbose])
24
+ resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
25
+ fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
26
+ progress.reading_inventory
27
+ limit = settings.limit_per_vendor
28
+ plan = if @mode == :backup
29
+ fleet.plan_backup(limit_per_vendor: limit)
30
+ else
31
+ fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?)
32
+ end
33
+ progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
34
+ preview = plan_summary(plan)
35
+ @output.puts JSON.generate(plan: preview) if options[:json]
36
+ @output.flush
37
+ return 1 if options[:stdin_credentials] && @input.gets&.strip != "RUN"
38
+
39
+ Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
40
+ report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
41
+ report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
42
+ finish(report, progress, options, batch_directory)
43
+ rescue ArgumentError, Client::Error => exception
44
+ @error.puts "备份参数或清单错误:#{exception.message}"
45
+ 2
46
+ rescue StandardError => exception
47
+ @error.puts "备份未完成(#{exception.class})"
48
+ 2
49
+ end
50
+
51
+ private
52
+
53
+ def prepare(options)
54
+ settings = CLI::Options.settings(options, env: @env)
55
+ success_policy = options.fetch(:success_policy, :selected)
56
+ raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
57
+ raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
58
+ server_root = tftp_root(settings, options) if @mode == :tftp
59
+ raise ArgumentError, "verified policy requires a local TFTP directory" if success_policy == :verified && !server_root
60
+ TftpVerification.new(root: server_root) if server_root
61
+ batch_directory = Storage::BatchDirectory.create(settings.backup_directory)
62
+ if @mode == :tftp && !settings.log_directory
63
+ settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
64
+ end
65
+ settings = settings.for_run(mode: @mode)
66
+ [settings, success_policy, server_root, batch_directory]
67
+ end
68
+
69
+ def execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
70
+ File.open(File.join(batch_directory, "events.jsonl"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |events|
71
+ options = { plan: plan, concurrency: settings.concurrency, success_policy: success_policy,
72
+ on_start: progress.method(:start), on_result: result_callback(progress, events) }
73
+ progress.with_updates do
74
+ if @mode == :backup
75
+ fleet.backup_all(directory: batch_directory, filename_style: :hostname_ip, **options)
76
+ else
77
+ fleet.tftp_backup_all(server: settings.tftp_server, source_files: settings.tftp_source_files,
78
+ vrfs: settings.tftp_vrfs, report_directory: batch_directory,
79
+ verification_root: server_root, preserve_history: true, **options)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def result_callback(progress, events)
86
+ lock = Mutex.new
87
+ lambda do |outcome|
88
+ progress.result(outcome)
89
+ lock.synchronize do
90
+ events.puts JSON.generate(host: outcome.device.host, vendor: outcome.device.vendor,
91
+ status: outcome.status, error_code: outcome.diagnostic&.error_code,
92
+ error_type: outcome.diagnostic&.error_type, duration_ms: outcome.duration_ms)
93
+ events.flush
94
+ end
95
+ end
96
+ end
97
+
98
+ def finish(report, progress, options, batch_directory)
99
+ progress.finish(report)
100
+ progress.location(report.report_location) if report.report_location
101
+ @error.puts "文本报告:#{File.join(batch_directory, "summary.txt")}" if @env.fetch("NC_PROGRESS", "1") != "0" && File.file?(File.join(batch_directory, "summary.txt"))
102
+ if options[:json]
103
+ @output.puts JSON.generate(directory: batch_directory, counts: report.counts,
104
+ tasks_succeeded: report.policy_success?, success: report.policy_success?,
105
+ policy: report.policy, policy_success: report.policy_success?,
106
+ report_location: report.report_location, report_error: report.report_error)
107
+ end
108
+ report.policy_success? ? 0 : 1
109
+ end
110
+
111
+ def tftp_root(settings, options)
112
+ server = settings.tftp_server || (raise ArgumentError, "TFTP_HOST or YAML tftp.server is required")
113
+ return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["TFTP_ROOT"]
114
+ return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
115
+
116
+ candidate = File.join(Dir.home, "Documents", "TFTP")
117
+ candidate if File.directory?(candidate)
118
+ end
119
+
120
+ def plan_summary(plan)
121
+ { total: plan.inventory.size, ready: plan.inventory.count(&:ready?),
122
+ vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
123
+ issues: plan.inventory.reject(&:ready?).group_by(&:issue).transform_values(&:size),
124
+ selected: plan.selected.map do |device|
125
+ filename = @mode == :backup ? device.backup_filename(style: :hostname_ip) : device.tftp_filename
126
+ { host: device.host, name: device.name, vendor: device.vendor, backup_filename: filename }
127
+ end }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -45,7 +45,7 @@ module Net
45
45
  Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
46
46
  :report_location, :report_error) do
47
47
  # 按设备最终状态统计数量。
48
- def counts = outcomes.group_by(&:status).transform_values(&:size)
48
+ def counts = outcomes.map(&:status).tally
49
49
 
50
50
  # 仅在所有设备成功且回调、报告均正常时判定整批成功。
51
51
  def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Net
6
+ module Connector
7
+ module Netdisco
8
+ class CLI
9
+ # 可注入参数与输入输出的备份选项,不退出宿主进程。
10
+ class Options
11
+ def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
12
+ values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
13
+ parser = parser_for(values, output: output, program: program)
14
+ remaining = parser.parse!(argv.dup)
15
+ return values if values[:help]
16
+ raise OptionParser::InvalidArgument unless remaining.empty?
17
+ credentials = values[:environment].keys.any? { |key| key.match?(/USERNAME|PASSWORD/) }
18
+ if values[:stdin_credentials] && (credentials || values[:ask_password] || values[:ask_netdisco_password])
19
+ raise ArgumentError, "--stdin-credentials 不能与命令行凭据或密码提示同时使用"
20
+ end
21
+ read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "设备密码", input: input, output: error)
22
+ read_password(values, :ask_netdisco_password, "NETDISCO_PASSWORD", "Netdisco 密码", input: input, output: error)
23
+
24
+ values
25
+ rescue OptionParser::ParseError
26
+ raise ArgumentError, "参数无效:并发数须为 1 至 50,抽样须为 1 至 5;使用 --help 查看参数", cause: nil
27
+ end
28
+
29
+ def self.parser_for(values, output:, program:)
30
+ OptionParser.new do |opts|
31
+ opts.banner = "用法:ruby #{File.basename(program)} [选项](默认全量)"
32
+ add_settings(opts, values)
33
+ add_execution(opts, values)
34
+ opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
35
+ opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
36
+ opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
37
+ opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
38
+ opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
39
+ opts.on("-h", "--help", "显示帮助") { output.puts opts; values[:help] = true }
40
+ end
41
+ end
42
+
43
+ def self.add_execution(opts, values)
44
+ opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
45
+ raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
46
+
47
+ values[:environment]["NC_CONCURRENCY"] = count.to_s
48
+ end
49
+ opts.on("--sample N", "--limit-per-vendor N", Integer, "每厂商抽样 1 至 5 台") do |count|
50
+ Planner.validate_limit!(count)
51
+ values[:limit] = count
52
+ end
53
+ opts.on("--tftp-root DIR", "本机 TFTP 服务器文件目录") { |path| values[:tftp_root] = path }
54
+ opts.on("--all", "选择全部就绪设备") { values[:all] = true }
55
+ opts.on("--success-policy POLICY", %w[strict selected verified], "成功判定:strict / selected / verified(TFTP 文件核验)") do |policy|
56
+ values[:success_policy] = policy.to_sym
57
+ end
58
+ end
59
+
60
+ def self.settings(options, env: ENV)
61
+ raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
62
+
63
+ environment = environment(options, env: env)
64
+ path = environment["NC_CONFIG"]
65
+ defaults = path && !path.empty? ? ConfigFile.load(path) : {}
66
+ overrides = {}
67
+ { source: "NETDISCO_SOURCE", query: "NETDISCO_QUERY", query_params: "NETDISCO_QUERY_PARAMS",
68
+ max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
69
+ limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
70
+ overrides[key] = options[option].to_s if options.key?(option)
71
+ end
72
+ overrides["NC_SAMPLE_PER_VENDOR"] = nil if options[:all]
73
+ Settings.new(env: environment, defaults: defaults, overrides: overrides)
74
+ end
75
+
76
+ def self.add_settings(opts, values)
77
+ {
78
+ "--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
79
+ "--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
80
+ "--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "设备提权密码"],
81
+ "--netdisco-url URL" => ["NETDISCO_URL", "Netdisco 地址"],
82
+ "--netdisco-username USER" => ["NETDISCO_USERNAME", "Netdisco 用户名"],
83
+ "--netdisco-password PASSWORD" => ["NETDISCO_PASSWORD", "Netdisco 密码"],
84
+ "--directory DIR" => ["NC_BACKUP_DIRECTORY", "备份目录,相对项目根目录"],
85
+ "--config FILE" => ["NC_CONFIG", "YAML 配置,相对项目根目录"]
86
+ }.each do |flag, (key, description)|
87
+ opts.on(flag, description) do |value|
88
+ raise OptionParser::InvalidArgument if value.empty?
89
+
90
+ values[:environment][key] = value
91
+ end
92
+ end
93
+ opts.on("--host-key-policy POLICY", %w[strict accept_new replace], "主机密钥策略:strict / accept_new / replace(变化时自动替换)") do |policy|
94
+ values[:environment]["NC_HOST_KEY_POLICY"] = policy
95
+ end
96
+ opts.on("--known-hosts FILE", "持久保存主机密钥的文件") do |path|
97
+ values[:environment]["NC_KNOWN_HOSTS"] = path
98
+ end
99
+ end
100
+
101
+ def self.environment(options, env: ENV)
102
+ result = env.to_h.dup
103
+ overrides = options.fetch(:environment)
104
+ # 显式设备参数覆盖对应厂商默认值;未指定的字段继续按原凭据规则解析。
105
+ { "NC_DEVICE_USERNAME" => "USERNAME", "NC_DEVICE_PASSWORD" => "PASSWORD",
106
+ "NC_ENABLE_PASSWORD" => "ENABLE_PASSWORD" }.each do |key, suffix|
107
+ next unless overrides.key?(key)
108
+
109
+ result.delete_if do |name, _|
110
+ name.start_with?("NC_") && name.end_with?("_#{suffix}") &&
111
+ (suffix != "PASSWORD" || !name.end_with?("_ENABLE_PASSWORD"))
112
+ end
113
+ end
114
+ if overrides.key?("NETDISCO_USERNAME") || overrides.key?("NETDISCO_PASSWORD")
115
+ result.delete("NETDISCO_API_KEY")
116
+ end
117
+ result.merge(overrides)
118
+ end
119
+
120
+ def self.read_password(values, flag, key, label, input:, output:)
121
+ return unless values[flag]
122
+
123
+ raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
124
+ raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless input.tty?
125
+ require "io/console"
126
+ output.print "#{label}:"
127
+ output.flush
128
+ password = input.noecho(&:gets)&.chomp
129
+ output.puts
130
+ raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
131
+
132
+ values[:environment][key] = password
133
+ end
134
+ private_class_method :read_password, :parser_for
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end