net-connector 0.4.2 → 0.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +42 -19
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +96 -64
  6. data/lib/net/connector/device/base.rb +27 -107
  7. data/lib/net/connector/device/local_backup.rb +96 -0
  8. data/lib/net/connector/device/profile.rb +8 -3
  9. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  10. data/lib/net/connector/device/running_config.rb +44 -5
  11. data/lib/net/connector/device/save_config.rb +21 -0
  12. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  13. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  14. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  15. data/lib/net/connector/device/tftp/target.rb +63 -0
  16. data/lib/net/connector/device/tftp.rb +117 -0
  17. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  18. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  19. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  20. data/lib/net/connector/device/topology/strategy.rb +75 -0
  21. data/lib/net/connector/device/topology.rb +277 -0
  22. data/lib/net/connector/engine/authentication.rb +1 -1
  23. data/lib/net/connector/engine/configuration.rb +1 -1
  24. data/lib/net/connector/engine/dialogue.rb +17 -17
  25. data/lib/net/connector/engine/error_metadata.rb +50 -0
  26. data/lib/net/connector/engine/errors.rb +15 -1
  27. data/lib/net/connector/engine/execution.rb +15 -8
  28. data/lib/net/connector/engine/log/event.rb +66 -0
  29. data/lib/net/connector/engine/log/formatter.rb +16 -0
  30. data/lib/net/connector/engine/log/messages.rb +52 -0
  31. data/lib/net/connector/engine/log/stream.rb +56 -0
  32. data/lib/net/connector/engine/log.rb +105 -84
  33. data/lib/net/connector/engine/session.rb +83 -42
  34. data/lib/net/connector/engine/terminal_text.rb +33 -0
  35. data/lib/net/connector/engine/transport.rb +2 -2
  36. data/lib/net/connector/netdisco/batch.rb +9 -45
  37. data/lib/net/connector/netdisco/cli.rb +7 -9
  38. data/lib/net/connector/netdisco/client.rb +2 -2
  39. data/lib/net/connector/netdisco/device.rb +4 -5
  40. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  41. data/lib/net/connector/netdisco/fleet.rb +26 -39
  42. data/lib/net/connector/netdisco/report.rb +37 -16
  43. data/lib/net/connector/netdisco/result_store.rb +2 -2
  44. data/lib/net/connector/netdisco/settings.rb +0 -6
  45. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  46. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  47. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  48. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  49. data/lib/net/connector/storage.rb +15 -0
  50. data/lib/net/connector/textfsm.rb +72 -0
  51. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  53. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  55. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  56. data/lib/net/connector/vendor/h3c.rb +3 -3
  57. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  59. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  60. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  61. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  63. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  65. data/lib/net/connector/version.rb +1 -1
  66. data/lib/net/connector.rb +1 -6
  67. metadata +25 -39
  68. data/lib/net/connector/device/interface_description.rb +0 -44
  69. data/lib/net/connector/device/interface_name.rb +0 -56
  70. data/lib/net/connector/engine/base.rb +0 -4
  71. data/lib/net/connector/engine/log_messages.rb +0 -47
  72. data/lib/net/connector/engine/profile.rb +0 -4
  73. data/lib/net/connector/engine.rb +0 -4
  74. data/lib/net/connector/operations/local_backup.rb +0 -89
  75. data/lib/net/connector/operations/parse_output.rb +0 -77
  76. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  77. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  78. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  79. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  80. data/lib/net/connector/operations/running_config.rb +0 -16
  81. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  82. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  83. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  84. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  85. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  86. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  87. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  88. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  89. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  90. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  91. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  92. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  93. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  94. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  96. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/topology/radware.rb +0 -11
  98. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  99. data/lib/net/connector/operations/topology.rb +0 -261
  100. data/lib/net/connector/operations.rb +0 -26
@@ -40,7 +40,22 @@ module Net
40
40
  def mark_privileged! = @privileged = true
41
41
 
42
42
  # 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
43
- def log_event(name, **fields) = @log.event(name, **fields)
43
+ def log_event(name, **fields) = @log.log_custom_event(name, **fields)
44
+
45
+ # 业务名称贯穿脚本及其钩子,底层命令另外分配独立序号。
46
+ def log_script(operation:, steps:)
47
+ @log.with_operation_context(operation) do
48
+ started = Expect.monotonic
49
+ @log.log_event("operation_start", level: :debug, phase: :script)
50
+ result = yield
51
+ log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
52
+ result
53
+ rescue => error
54
+ failure = normalize_error(error, phase: :script)
55
+ log_operation_completion(started, failure, steps: steps.size)
56
+ raise failure, cause: nil
57
+ end
58
+ end
44
59
 
45
60
  # 在会话锁内建立连接。
46
61
  def connect
@@ -53,7 +68,7 @@ module Net
53
68
  return perform_locked(phase) { yield }
54
69
  end
55
70
  unless @lock.try_lock
56
- raise error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
71
+ raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
57
72
  end
58
73
 
59
74
  begin
@@ -66,11 +81,18 @@ module Net
66
81
  # 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
67
82
  def with_operation(phase)
68
83
  unless @lock.try_lock
69
- raise error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
84
+ raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
70
85
  end
71
86
  @operation_owner = [Thread.current, Fiber.current]
87
+ result = nil
72
88
  begin
73
- yield
89
+ @log.with_operation_context(phase) { result = yield }
90
+ rescue Error => error
91
+ raise unless result.is_a?(Result)
92
+
93
+ # 日志收尾失败不能抹去已完成的设备步骤,也不能覆盖原业务错误。
94
+ Result.new(steps: result.steps, config: result.config,
95
+ error: result.error || normalize_error(error, phase: phase))
74
96
  ensure
75
97
  @operation_owner = nil
76
98
  @lock.unlock
@@ -81,7 +103,7 @@ module Net
81
103
  def assert_path_lock_order!(phase)
82
104
  return unless @lock.locked?
83
105
 
84
- raise error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
106
+ raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
85
107
  end
86
108
 
87
109
  # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
@@ -118,7 +140,7 @@ module Net
118
140
  # 独占关闭会话;已有操作占用时返回会话繁忙错误。
119
141
  def close
120
142
  unless @lock.try_lock
121
- raise error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
143
+ raise build_error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
122
144
  end
123
145
 
124
146
  begin
@@ -131,7 +153,7 @@ module Net
131
153
  # 执行特权认证,并更新提示符及特权状态。
132
154
  def enable(command, prompt)
133
155
  unless command
134
- raise error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
156
+ raise build_error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
135
157
  end
136
158
 
137
159
  @privileged = false
@@ -142,8 +164,8 @@ module Net
142
164
  end
143
165
 
144
166
  # 命令准备、交换、后处理及回调共用一份临时词表,退出时一并清除。
145
- def command_scope(command)
146
- redactor.scope do
167
+ def with_command_redaction(command)
168
+ redactor.with_scope do
147
169
  protect_command(command)
148
170
  yield
149
171
  end
@@ -151,10 +173,10 @@ module Net
151
173
 
152
174
  # 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
153
175
  # 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
154
- def output_scope(enabled)
176
+ def with_sensitive_output(enabled)
155
177
  return yield unless enabled
156
178
 
157
- redactor.scope do
179
+ redactor.with_scope do
158
180
  redactor.output_sensitive!
159
181
  yield
160
182
  rescue => error
@@ -163,29 +185,47 @@ module Net
163
185
  end
164
186
 
165
187
  # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
166
- def exchange(command, timeout:, prompt: nil)
167
- redactor.scope(reuse: true) do
188
+ def execute_command(command, timeout:, prompt: nil)
189
+ redactor.with_scope(reuse: true) do
168
190
  protect_command(command)
169
- started = Expect.monotonic
170
- @log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
171
- sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
172
- private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
173
- @log.event("device_output", level: :debug) if @log.detailed? && !private_output
174
- response = exchange_command(command, timeout: timeout, prompt: prompt)
175
- @log.response_output(response.raw) unless private_output
176
- @log.event("command_complete", status: "response_received")
177
- @log.event("command_detail", level: :debug,
178
- duration_ms: ((Expect.monotonic - started) * 1000).round,
179
- response_bytes: response.raw.bytesize)
180
- response
181
- rescue => error
182
- failure = normalize_error(error, phase: :command, command: command)
183
- @log.event("command_complete", level: :error, status: "failed",
184
- error: failure.class.name, message: failure.message)
185
- raise failure, cause: nil
191
+ @log.with_command_context(command) { execute_command_with_logging(command, timeout: timeout, prompt: prompt) }
192
+ ensure
193
+ # 失败的敏感探测也可能被钩子捕获后继续;离开作用域前始终通知执行器。
194
+ yield if block_given?
186
195
  end
187
196
  end
188
197
 
198
+ def execute_command_with_logging(command, timeout:, prompt:)
199
+ started = Expect.monotonic
200
+ @log.log_event("command_start")
201
+ sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
202
+ private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
203
+ @log.log_event("device_output", level: :debug) if @log.debug? && !private_output
204
+ response = exchange_command(command, timeout: timeout, prompt: prompt)
205
+ @log.log_response_output(response.raw) unless private_output
206
+ details = { duration_ms: elapsed_ms(started), response_bytes: response.raw.bytesize }
207
+ @log.log_event("command_complete", status: "response_received", **details)
208
+ response
209
+ rescue => error
210
+ failure = normalize_error(error, phase: :command, command: command)
211
+ @log.log_failure("command_complete", failure, status: "failed", duration_ms: elapsed_ms(started), phase: :command)
212
+ raise failure, cause: nil
213
+ end
214
+
215
+ def elapsed_ms(started) = ((Expect.monotonic - started) * 1000).round
216
+
217
+ def log_operation_completion(started, failure, steps: nil)
218
+ fields = { status: failure ? "failed" : "completed", duration_ms: elapsed_ms(started), steps: steps,
219
+ phase: :script }
220
+ if failure
221
+ @log.log_failure("operation_complete", failure, **fields, failed_command: failure.command)
222
+ else
223
+ @log.log_event("operation_complete", **fields)
224
+ end
225
+ end
226
+
227
+ private :execute_command_with_logging, :elapsed_ms, :log_operation_completion
228
+
189
229
  # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
190
230
  def protect_command(command)
191
231
  redactor.output_sensitive! if command.output_sensitive?
@@ -208,7 +248,7 @@ module Net
208
248
  deadline: deadline, phase: :command, command: command)
209
249
  @prompt = response.prompt
210
250
  if (diagnostic = @dialogue.diagnostic(response.raw))
211
- raise error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
251
+ raise build_error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
212
252
  end
213
253
 
214
254
  response
@@ -243,7 +283,7 @@ module Net
243
283
  end
244
284
 
245
285
  # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
246
- def error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
286
+ def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
247
287
  sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
248
288
  message = safe_error_text(message, sensitive: sensitive)
249
289
  output = safe_error_text(output, sensitive: sensitive)
@@ -264,8 +304,8 @@ module Net
264
304
  end
265
305
  message = safe_error_text(exception.message, sensitive: sensitive)
266
306
  output = safe_error_text(exception.output, sensitive: sensitive)
267
- return exception.class.new(
268
- message, code: exception.code,
307
+ return exception.with_diagnostics(
308
+ message: message,
269
309
  host: exception.host || configuration.host, phase: exception.phase || phase,
270
310
  command: failed_command && redactor.call(failed_command),
271
311
  source: exception.source || command&.source, line: exception.line || command&.line,
@@ -279,7 +319,7 @@ module Net
279
319
  when IOError, SystemCallError, Expect::SpawnError then TransportError
280
320
  else InternalError
281
321
  end
282
- error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
322
+ build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
283
323
  end
284
324
 
285
325
  # 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
@@ -306,27 +346,28 @@ module Net
306
346
  attempted_recovery = false
307
347
  begin
308
348
  @state = :connecting
349
+ started = Expect.monotonic
309
350
  redactor.reset
310
351
  @log.open(transport)
311
- @log.event("connect", host: configuration.host, username: configuration.username,
312
- protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
352
+ @log.log_event("connect", host: configuration.host, username: configuration.username,
353
+ phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
313
354
  transport.open
314
355
  @state = :authenticating
315
- @log.event("login_start", level: :debug)
356
+ @log.log_event("login_start", level: :debug, phase: :login)
316
357
  response = @authentication.login
317
358
  safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
318
359
  prompt: response.prompt)
319
360
  @prompt = response.prompt
320
361
  @after_login.call(self, safe_response)
321
- @log.authentication_output(response.raw)
322
- @log.event("login_complete", status: "ok", prompt: response.prompt)
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))
323
364
  @log.attach
324
365
  @state = :ready
325
366
  rescue => error
326
367
  phase = (@state == :connecting) ? :connect : :login
327
368
  failure = normalize_error(error, phase: phase)
328
- @log.event(phase == :connect ? "connect_failed" : "login_complete", level: :error,
329
- status: "failed", error: failure.class.name, message: failure.message, phase: phase)
369
+ @log.log_failure(phase == :connect ? "connect_failed" : "login_complete", failure,
370
+ status: "failed", phase: phase, duration_ms: elapsed_ms(started))
330
371
  close_preserving_failure
331
372
  replacement = attempted_recovery ? nil : @recovery.recover(failure, transport)
332
373
  if replacement
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terminal_renderer"
4
+ require_relative "errors"
5
+
6
+ module Net
7
+ module Connector
8
+ # 设备/文件提供的是字节,不受 Ruby 源文件编码控制。解析边界只接受 UTF-8,
9
+ # 不猜测编码、不改写原文,也不让终端编辑把非法字节抹掉后变成可信证据。
10
+ module TerminalText
11
+ def self.utf8(bytes, host: nil)
12
+ raise ArgumentError, "text must be a String" unless bytes.is_a?(String)
13
+
14
+ text = bytes.b.force_encoding(Encoding::UTF_8)
15
+ invalid_encoding!(host) unless text.valid_encoding?
16
+ text
17
+ end
18
+
19
+ def self.render(bytes, host: nil)
20
+ TerminalRenderer.render(utf8(bytes, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
21
+ rescue Encoding::InvalidByteSequenceError
22
+ invalid_encoding!(host)
23
+ end
24
+
25
+ def self.invalid_encoding!(host)
26
+ raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
27
+ host: host, phase: :parse), cause: nil
28
+ end
29
+
30
+ private_class_method :invalid_encoding!
31
+ end
32
+ end
33
+ end
@@ -46,7 +46,7 @@ module Net
46
46
  end
47
47
  # Profile 使用宽、高;PTY 使用行数、列数。
48
48
  @channel.slave.winsize = @terminal_size.reverse if @terminal_size
49
- @channel.log_output = method(:record)
49
+ @channel.log_output = method(:write_log_output)
50
50
  @channel.spawn(*argv)
51
51
  self
52
52
  rescue Exception # rubocop:disable Lint/RescueException -- A half-open PTY must be released on interrupts.
@@ -104,7 +104,7 @@ module Net
104
104
  private
105
105
 
106
106
  # 检查缓冲上限并把设备输出写入当前日志目标。
107
- def record(bytes)
107
+ def write_log_output(bytes)
108
108
  if @channel.buffer.bytesize > configuration.max_output_bytes
109
109
  raise OutputLimitExceeded.new("transport buffer exceeded max_output_bytes",
110
110
  phase: :read, output: @channel.buffer.dup), cause: nil
@@ -8,9 +8,7 @@ module Net
8
8
  module Netdisco
9
9
  # 单台设备的最终结果,保留状态、配置产物和错误类型。
10
10
  Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
11
- :started_at, :finished_at) do
12
- attr_reader :diagnostic
13
-
11
+ :started_at, :finished_at, :duration_ms, :diagnostic) do
14
12
  # 创建结果,允许计划阶段尚无执行时间。
15
13
  def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil,
16
14
  duration_ms: nil, diagnostic: nil)
@@ -19,19 +17,18 @@ module Net
19
17
  end
20
18
  raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
21
19
 
22
- @duration_ms, @diagnostic = duration_ms, diagnostic
23
20
  super(device: device, status: status, backup: backup, error_code: error_code, error_type: error_type,
24
- started_at: started_at, finished_at: finished_at)
21
+ started_at: started_at, finished_at: finished_at, duration_ms: duration_ms, diagnostic: diagnostic)
25
22
  end
26
23
 
27
- # Data 成员保持不变;Worker 加上时间、旧文件迁移调整产物时仍保留内部元数据。
24
+ # 修改诊断或时刻时释放不再匹配的派生元数据。
28
25
  def with(**attributes)
29
26
  return self if attributes.empty?
30
27
 
31
- metadata = { duration_ms: @duration_ms, diagnostic: diagnostic }
32
- metadata[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
33
- metadata[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
34
- self.class.new(**to_h, **metadata.merge(attributes))
28
+ values = to_h
29
+ values[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
30
+ values[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
31
+ self.class.new(**values.merge(attributes))
35
32
  end
36
33
 
37
34
  # 判断设备是否完成配置保存或上报上传成功。
@@ -42,13 +39,6 @@ module Net
42
39
 
43
40
  # 判断设备是否因为清单或采样规则未执行。
44
41
  def skipped? = !success? && !partial? && status != :failed
45
-
46
- # 计算设备任务的耗时,计划阶段没有时间时返回空值。
47
- def duration_ms
48
- return @duration_ms unless @duration_ms.nil?
49
-
50
- [((finished_at - started_at) * 1000).round, 0].max if started_at && finished_at
51
- end
52
42
  end
53
43
 
54
44
  # 汇总一次批量任务的设备结果、回调故障和报告写入状态。
@@ -63,34 +53,8 @@ module Net
63
53
  # 空清单与已尝试但未全部成功的批次分别标记。
64
54
  def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
65
55
 
66
- # 单独请求 v2 包装,不改变旧 Data 的成员、解构或默认 summary。
67
- def report(policy: :strict) = Report.new(self, policy: policy)
68
-
69
- # 生成可写入报告及供命令行展示的结构化摘要。
70
- def summary
71
- {
72
- mode: mode, status: status, started_at: started_at.iso8601, finished_at: finished_at.iso8601,
73
- total: outcomes.size,
74
- succeeded: outcomes.count(&:success?),
75
- partial: outcomes.count(&:partial?),
76
- failed: outcomes.count { |item| item.status == :failed },
77
- skipped: outcomes.count(&:skipped?),
78
- counts: counts,
79
- callback_errors: callback_errors,
80
- devices: outcomes.map do |item|
81
- { host: item.device.host || item.device.source_ip, name: item.device.name,
82
- vendor: item.device.vendor,
83
- status: item.status, path: item.backup&.path,
84
- bytes: item.backup.is_a?(Backup) ? item.backup.bytes : nil,
85
- sha256: item.backup.is_a?(Backup) ? item.backup.sha256 : nil,
86
- change: item.backup.is_a?(Backup) ? item.backup.change : nil,
87
- previous_sha256: item.backup.is_a?(Backup) ? item.backup.previous_sha256 : nil,
88
- started_at: item.started_at&.iso8601, finished_at: item.finished_at&.iso8601,
89
- duration_ms: item.duration_ms,
90
- error_code: item.error_code, error_type: item.error_type }
91
- end
92
- }
93
- end
56
+ # 显式从原始执行结果构造报告。
57
+ def build_report(policy: :strict) = Report.new(self, policy: policy)
94
58
  end
95
59
 
96
60
  autoload :Report, File.expand_path("report", __dir__)
@@ -48,11 +48,10 @@ module Net
48
48
 
49
49
  batch = backup(fleet, plan, settings, options)
50
50
  summary = batch.summary
51
- summary = summary.merge(report_location: batch.report_location, report_error: batch.report_error) unless batch.instance_of?(Report)
52
51
  @output.puts JSON.pretty_generate(summary)
53
- (batch.instance_of?(Report) ? batch.policy_success? : batch.success?) ? 0 : 1
54
- rescue Operations::PrivateFile::WriteError => exception
55
- message = Operations::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
52
+ batch.policy_success? ? 0 : 1
53
+ rescue Storage::PrivateFile::WriteError => exception
54
+ message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
56
55
  @error.puts "net-connector-backup: #{message}"
57
56
  2
58
57
  rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
@@ -81,7 +80,7 @@ module Net
81
80
 
82
81
  # 导出已有配置不创建清单或设备连接。
83
82
  def export_config(settings, options)
84
- path = Operations::SavedConfig.new(directory: settings.backup_directory)
83
+ path = Storage::SavedConfig.new(directory: settings.backup_directory)
85
84
  .export(host: options[:export], output: options[:output], io: @output)
86
85
  @output.puts JSON.generate(host: options[:export], output: path) if path
87
86
  end
@@ -103,7 +102,7 @@ module Net
103
102
 
104
103
  # 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
105
104
  def backup(fleet, plan, settings, options)
106
- reporting = options.slice(:success_policy, :report_schema)
105
+ reporting = options.slice(:success_policy)
107
106
  if options[:tftp]
108
107
  fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
109
108
  concurrency: settings.concurrency,
@@ -135,7 +134,6 @@ module Net
135
134
  end
136
135
  args.on("--all", "选择全部就绪设备") { options[:all] = true }
137
136
  args.on("--success-policy POLICY", %w[strict selected], "成功判定:strict(默认)或 selected") { |value| options[:success_policy] = value.to_sym }
138
- args.on("--report-schema N", Integer, "报告版本:1(默认)或 2;selected 使用 2") { |value| options[:report_schema] = value }
139
137
  args.on("-v", "--version", "显示版本") do
140
138
  @output.puts Net::Connector::VERSION
141
139
  options[:done] = true
@@ -153,7 +151,7 @@ module Net
153
151
 
154
152
  # 拒绝互斥或缺少依赖的命令行选项。
155
153
  def validate_options!(options)
156
- Report.options(policy: options.fetch(:success_policy, :strict), schema: options[:report_schema])
154
+ Report.validate_policy!(options.fetch(:success_policy, :strict))
157
155
  Planner.validate_limit!(options[:limit])
158
156
  Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
159
157
  modes = [:show_config, :plan, :export].count { |key| options[key] }
@@ -161,7 +159,7 @@ module Net
161
159
  raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
162
160
  raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
163
161
  if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host] ||
164
- options[:success_policy] || options[:report_schema] || options[:max_script_output_bytes])
162
+ options[:success_policy] || options[:max_script_output_bytes])
165
163
  raise ArgumentError, "--export 不能与备份选项同时使用"
166
164
  end
167
165
  end
@@ -109,10 +109,10 @@ module Net
109
109
  end
110
110
  raise Error.new("Netdisco inventory exceeded max_pages", code: :max_pages), cause: nil
111
111
  rescue QueryRequired
112
- legacy_devices(token, budget)
112
+ query_required_devices(token, budget)
113
113
  end
114
114
 
115
- def legacy_devices(token, budget)
115
+ def query_required_devices(token, budget)
116
116
  uri = endpoint("api/v1/search/device")
117
117
  uri.query = URI.encode_www_form(q: "%", seeallcolumns: true)
118
118
  rows = request_json(uri, authorized_get(uri, token), budget, allow_empty: true)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ipaddr"
4
- require_relative "../operations/saved_config"
4
+ require_relative "../storage/saved_config"
5
5
 
6
6
  module Net
7
7
  module Connector
@@ -44,7 +44,7 @@ module Net
44
44
  def ready? = issue.nil?
45
45
 
46
46
  # 生成不随名称或厂商信息改变的本地文本文件名。
47
- def backup_filename = Operations::SavedConfig.filename(host)
47
+ def backup_filename = Storage::SavedConfig.filename(host)
48
48
 
49
49
  # 生成设备 CLI 可用的 ASCII 远端文件名。
50
50
  def tftp_filename
@@ -53,13 +53,12 @@ module Net
53
53
  .gsub(/\A[._-]+|[._-]+\z/, "")
54
54
  label = vendor&.to_s || "device" if label.empty?
55
55
  strategy = Net::Connector.vendor_class(vendor).profile.tftp_strategy if vendor
56
- # 旧版自定义策略只需实现实例接口,缺少命名接口时沿用通用 cfg 名称。
57
- strategy = Operations::Tftp::Strategy unless strategy.respond_to?(:filename)
56
+ raise UnsupportedOperation, "device has no TFTP strategy" unless strategy
58
57
  TftpTarget.validate_path!(strategy.filename(host, label: label))
59
58
  end
60
59
 
61
60
  # 按厂商及设备地址实例化连接器。
62
- def connector(**settings)
61
+ def build_connector(**settings)
63
62
  raise ArgumentError, "device is not ready: #{issue}" unless ready?
64
63
  raise ArgumentError, "host cannot be overridden" if settings.key?(:host)
65
64
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../engine/error_metadata"
4
+
3
5
  module Net
4
6
  module Connector
5
7
  module Netdisco
@@ -8,59 +10,27 @@ module Net
8
10
  :artifact_state, :artifact_phase, :verification)
9
11
 
10
12
  class Diagnostic
11
- CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
12
- PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
13
- OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
14
- BackupPersistenceError TftpCompletionError SavedConfigChanged].freeze
15
- TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
16
- %w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
17
- RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
18
- Timeout::Error IO::TimeoutError Errno::EIO Errno::EACCES Errno::EPERM Errno::ENOSPC Errno::EROFS
19
- Errno::ENOENT Errno::ENOTDIR Errno::EISDIR Errno::ELOOP Errno::EINVAL Errno::ENOSYS
20
- Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
21
- Net::Connector::Operations::PrivateFile::WriteError
22
- Net::Connector::Operations::PrivateFile::PersistenceError
23
- Net::Connector::Operations::PrivateFile::DirectorySyncUnsupported]).freeze
24
- CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
25
- %i[authentication_failed connection_failed connection_refused no_route connection_reset
26
- connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
27
- ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
28
- description_unconfirmed description_unsupported incomplete_configuration interface_missing
29
- neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
30
- template_invalid template_missing template_unreadable transfer_failed transfer_finalize_failed
31
- transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
32
- unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
33
- backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
34
- file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported]).freeze
35
- PHASES = %i[connect login enable command script read write close logging backup collect discover parse
36
- plan apply verify persist tftp_backup report callback].freeze
37
13
  ARTIFACT_STATES = %i[not_committed committed durable reported_uploaded].freeze
38
14
  ARTIFACT_PHASES = %i[resolve_parent temporary_file write file_sync rename directory_open directory_sync
39
15
  cleanup complete path finalize].freeze
40
- private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES, :ARTIFACT_STATES, :ARTIFACT_PHASES
16
+ private_constant :ARTIFACT_STATES, :ARTIFACT_PHASES
41
17
 
42
18
  # 包括手工构造的结果在内,v2 都不能携带自由文本诊断值。
43
19
  def initialize(error_code: nil, error_type: nil, phase: nil, underlying_type: nil,
44
20
  artifact_state: nil, artifact_phase: nil, verification: nil)
45
- super(error_code: CODES.include?(error_code) ? error_code : nil,
46
- error_type: self.class.type(error_type), phase: PHASES.include?(phase) ? phase : nil,
47
- underlying_type: self.class.type(underlying_type),
21
+ super(error_code: ErrorMetadata.code(error_code),
22
+ error_type: ErrorMetadata.type(error_type), phase: ErrorMetadata.phase(phase),
23
+ underlying_type: ErrorMetadata.type(underlying_type),
48
24
  artifact_state: ARTIFACT_STATES.include?(artifact_state) ? artifact_state : nil,
49
25
  artifact_phase: ARTIFACT_PHASES.include?(artifact_phase) ? artifact_phase : nil,
50
26
  verification: verification == :device_reported ? verification : nil)
51
27
  end
52
28
 
53
- def self.type(name)
54
- return if name.nil?
55
-
56
- name.instance_of?(String) && TYPES.include?(name) ? name.dup.freeze : "StandardError"
57
- end
58
-
59
29
  # 读取已知错误的白名单属性后立即丢弃异常引用,不把回调对象保留进批次。
60
30
  def self.from(error, backup: nil, phase: nil)
61
31
  return unless error
62
32
 
63
- fields = { error_type: type(error.class.name) || "StandardError", phase: phase }
33
+ fields = { error_type: ErrorMetadata.type(error.class.name) || "StandardError", phase: phase }
64
34
  if error.is_a?(Net::Connector::Error)
65
35
  fields.merge!(error_code: error.code, phase: phase || error.phase)
66
36
  fields[:underlying_type] = error.underlying.type if error.underlying.instance_of?(UnderlyingError)
@@ -74,10 +44,10 @@ module Net
74
44
  if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
75
45
  { artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
76
46
  underlying_type: error.underlying_type }
77
- elsif error.instance_of?(TftpCompletionError) && backup.instance_of?(TftpBackup) && error.transfer.equal?(backup)
47
+ elsif error.instance_of?(TftpCompletionError) && backup.instance_of?(TftpReceipt) && error.receipt.equal?(backup)
78
48
  { artifact_state: :reported_uploaded, artifact_phase: error.code == :transfer_finalize_failed ? :finalize : :path,
79
49
  verification: :device_reported, underlying_type: error.underlying_type }
80
- elsif phase == :report && Operations::PrivateFile.receipt_error?(error)
50
+ elsif phase == :report && Storage::PrivateFile.receipt_error?(error)
81
51
  { error_code: error.code, artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
82
52
  underlying_type: error.underlying_type }
83
53
  else