net-connector 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +112 -50
  5. data/docs/RELEASING.md +11 -8
  6. data/docs/VERIFICATION.md +42 -1
  7. data/docs/architecture.md +15 -2
  8. data/examples/backup.yml +40 -0
  9. data/lib/net/connector/device/base.rb +30 -38
  10. data/lib/net/connector/device/profile/builder.rb +8 -8
  11. data/lib/net/connector/device/profile.rb +2 -2
  12. data/lib/net/connector/device/running_config.rb +2 -2
  13. data/lib/net/connector/device/tftp/receipt.rb +12 -3
  14. data/lib/net/connector/device/tftp.rb +7 -7
  15. data/lib/net/connector/device/topology.rb +4 -4
  16. data/lib/net/connector/engine/command.rb +2 -2
  17. data/lib/net/connector/engine/configuration.rb +5 -2
  18. data/lib/net/connector/engine/execution.rb +25 -14
  19. data/lib/net/connector/engine/known_hosts.rb +112 -0
  20. data/lib/net/connector/engine/log.rb +10 -7
  21. data/lib/net/connector/engine/session.rb +117 -128
  22. data/lib/net/connector/engine/transport.rb +52 -11
  23. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  24. data/lib/net/connector/netdisco/batch.rb +1 -1
  25. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  26. data/lib/net/connector/netdisco/cli.rb +13 -26
  27. data/lib/net/connector/netdisco/client.rb +6 -6
  28. data/lib/net/connector/netdisco/config_file.rb +20 -19
  29. data/lib/net/connector/netdisco/connection.rb +38 -0
  30. data/lib/net/connector/netdisco/database_client.rb +2 -2
  31. data/lib/net/connector/netdisco/device.rb +11 -1
  32. data/lib/net/connector/netdisco/fleet.rb +68 -47
  33. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  34. data/lib/net/connector/netdisco/plan.rb +14 -9
  35. data/lib/net/connector/netdisco/planner.rb +18 -14
  36. data/lib/net/connector/netdisco/progress.rb +253 -0
  37. data/lib/net/connector/netdisco/report/files.rb +41 -0
  38. data/lib/net/connector/netdisco/report/text.rb +45 -0
  39. data/lib/net/connector/netdisco/report.rb +53 -31
  40. data/lib/net/connector/netdisco/result_store.rb +3 -1
  41. data/lib/net/connector/netdisco/rules.rb +10 -2
  42. data/lib/net/connector/netdisco/settings.rb +118 -68
  43. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  44. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  45. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  46. data/lib/net/connector/netdisco/worker.rb +8 -3
  47. data/lib/net/connector/netdisco.rb +10 -0
  48. data/lib/net/connector/storage/backup_lock.rb +1 -1
  49. data/lib/net/connector/storage/batch_directory.rb +29 -0
  50. data/lib/net/connector/storage.rb +1 -0
  51. data/lib/net/connector/vendor/h3c.rb +2 -1
  52. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  53. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  55. data/lib/net/connector/vendor/radware.rb +4 -3
  56. data/lib/net/connector/version.rb +1 -1
  57. data/lib/net/connector.rb +2 -2
  58. metadata +42 -111
  59. /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
@@ -40,14 +41,29 @@ module Net
40
41
  @channel = if @channel_factory
41
42
  @channel_factory.call
42
43
  else
43
- Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
44
+ if defined?(Expect::Session)
45
+ Expect::Session.new(buffer_limit: nil, write_timeout: configuration.write_timeout)
46
+ else
47
+ Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
44
48
  preserve_buffer: false, log_stdout: false, log_listeners: false,
45
49
  debug_level: 0, write_timeout: configuration.write_timeout)
50
+ end
46
51
  end
47
52
  # Profile 使用宽、高;PTY 使用行数、列数。
48
53
  @channel.slave.winsize = @terminal_size.reverse if @terminal_size
49
- @channel.log_output = method(:write_log_output)
50
- @channel.spawn(*argv)
54
+ if @channel.respond_to?(:transcript=)
55
+ writer = Object.new
56
+ callback = method(:write_log_output)
57
+ writer.define_singleton_method(:write) do |bytes|
58
+ callback.call(bytes)
59
+ bytes.bytesize
60
+ end
61
+ @channel.transcript = writer
62
+ @channel.spawn(*argv, raw: true)
63
+ else
64
+ @channel.log_output = method(:write_log_output)
65
+ @channel.spawn(*argv)
66
+ end
51
67
  self
52
68
  rescue Exception # rubocop:disable Lint/RescueException -- A half-open PTY must be released on interrupts.
53
69
  begin
@@ -60,7 +76,8 @@ module Net
60
76
 
61
77
  # 从信道读取下一事件,并把字符串字段转换为字节字符串。
62
78
  def read(patterns, timeout:)
63
- result = @channel.expect_result(*patterns, STREAM, timeout: timeout)
79
+ reader = @channel.respond_to?(:expect_result) ? :expect_result : :expect
80
+ result = @channel.public_send(reader, *patterns, STREAM, timeout: timeout)
64
81
  Event.new(index: result.number && (result.number - 1), before: result.before.to_s.b,
65
82
  match: result.match.to_s.b, error: result.error)
66
83
  end
@@ -125,6 +142,29 @@ module Net
125
142
  @legacy_arguments = legacy_arguments.dup.freeze
126
143
  end
127
144
 
145
+ def open
146
+ configuration.validate_endpoint!
147
+ unless configuration.host_key_policy == :strict
148
+ @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
149
+ end
150
+ super
151
+ rescue Exception # rubocop:disable Lint/RescueException -- Release a staged trust file on failed startup.
152
+ close
153
+ raise
154
+ end
155
+
156
+ def authenticated
157
+ @known_hosts&.commit
158
+ @replace_host_key = false
159
+ end
160
+
161
+ def close
162
+ super
163
+ ensure
164
+ @known_hosts&.close
165
+ @known_hosts = nil
166
+ end
167
+
128
168
  # 返回 SSH 协议标识。
129
169
  def protocol = :ssh
130
170
 
@@ -133,9 +173,10 @@ module Net
133
173
  configuration.validate_endpoint!
134
174
  checking = (configuration.host_key_policy == :strict) ? "yes" : "accept-new"
135
175
  arguments = ["ssh", *legacy_arguments, "-tt", "-o", "StrictHostKeyChecking=#{checking}",
136
- "-o", "NumberOfPasswordPrompts=1",
176
+ "-o", "NumberOfPasswordPrompts=1", "-o", "UpdateHostKeys=no",
137
177
  "-o", "ConnectTimeout=#{[configuration.login_timeout.ceil, 1].max}"]
138
- arguments += ["-o", "UserKnownHostsFile=#{configuration.known_hosts}"] if configuration.known_hosts
178
+ known_hosts = @known_hosts&.path || configuration.known_hosts
179
+ arguments += ["-o", "UserKnownHostsFile=#{known_hosts}"] if known_hosts
139
180
  arguments += ["-p", configuration.port.to_s] if configuration.port
140
181
  arguments + ["-l", configuration.username, configuration.host]
141
182
  end
@@ -151,13 +192,13 @@ module Net
151
192
  Telnet.new(configuration, channel_factory: @channel_factory, terminal_size: @terminal_size)
152
193
  end
153
194
 
154
- # 从指定 known_hosts 文件中删除当前设备的旧主机密钥。
195
+ # 恢复只标记下一次会话副本;共享信任文件在认证成功后才更新。
155
196
  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?
197
+ raise IOError, "host key replacement is not enabled" unless configuration.host_key_policy == :replace
198
+
199
+ @replace_host_key = true
160
200
  end
201
+
161
202
  end
162
203
 
163
204
  # 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?