net-connector 0.4.2 → 0.6.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -1
  3. data/README.md +92 -20
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +102 -64
  6. data/examples/netdisco_database.yml +16 -0
  7. data/lib/net/connector/device/base.rb +27 -107
  8. data/lib/net/connector/device/local_backup.rb +96 -0
  9. data/lib/net/connector/device/profile.rb +8 -3
  10. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  11. data/lib/net/connector/device/running_config.rb +44 -5
  12. data/lib/net/connector/device/save_config.rb +21 -0
  13. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  14. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  15. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  16. data/lib/net/connector/device/tftp/target.rb +63 -0
  17. data/lib/net/connector/device/tftp.rb +117 -0
  18. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  19. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  20. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  21. data/lib/net/connector/device/topology/strategy.rb +75 -0
  22. data/lib/net/connector/device/topology.rb +277 -0
  23. data/lib/net/connector/engine/authentication.rb +2 -2
  24. data/lib/net/connector/engine/configuration.rb +1 -1
  25. data/lib/net/connector/engine/dialogue.rb +21 -21
  26. data/lib/net/connector/engine/error_metadata.rb +50 -0
  27. data/lib/net/connector/engine/errors.rb +15 -1
  28. data/lib/net/connector/engine/execution.rb +17 -10
  29. data/lib/net/connector/engine/log/event.rb +66 -0
  30. data/lib/net/connector/engine/log/formatter.rb +16 -0
  31. data/lib/net/connector/engine/log/messages.rb +52 -0
  32. data/lib/net/connector/engine/log/stream.rb +56 -0
  33. data/lib/net/connector/engine/log.rb +105 -84
  34. data/lib/net/connector/engine/session.rb +83 -42
  35. data/lib/net/connector/engine/terminal_text.rb +33 -0
  36. data/lib/net/connector/engine/transport.rb +2 -2
  37. data/lib/net/connector/netdisco/batch.rb +9 -45
  38. data/lib/net/connector/netdisco/cli.rb +17 -10
  39. data/lib/net/connector/netdisco/client.rb +4 -3
  40. data/lib/net/connector/netdisco/config_file.rb +6 -3
  41. data/lib/net/connector/netdisco/database_client.rb +197 -0
  42. data/lib/net/connector/netdisco/device.rb +4 -5
  43. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  44. data/lib/net/connector/netdisco/fleet.rb +26 -39
  45. data/lib/net/connector/netdisco/report.rb +37 -16
  46. data/lib/net/connector/netdisco/result_store.rb +2 -2
  47. data/lib/net/connector/netdisco/settings.rb +26 -9
  48. data/lib/net/connector/netdisco.rb +1 -0
  49. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  50. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  51. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  52. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  53. data/lib/net/connector/storage.rb +15 -0
  54. data/lib/net/connector/textfsm.rb +72 -0
  55. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  56. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  57. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  59. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  60. data/lib/net/connector/vendor/h3c.rb +3 -3
  61. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  63. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  65. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  66. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  67. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  68. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  69. data/lib/net/connector/version.rb +1 -1
  70. data/lib/net/connector.rb +1 -6
  71. metadata +47 -39
  72. data/lib/net/connector/device/interface_description.rb +0 -44
  73. data/lib/net/connector/device/interface_name.rb +0 -56
  74. data/lib/net/connector/engine/base.rb +0 -4
  75. data/lib/net/connector/engine/log_messages.rb +0 -47
  76. data/lib/net/connector/engine/profile.rb +0 -4
  77. data/lib/net/connector/engine.rb +0 -4
  78. data/lib/net/connector/operations/local_backup.rb +0 -89
  79. data/lib/net/connector/operations/parse_output.rb +0 -77
  80. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  81. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  82. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  83. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  84. data/lib/net/connector/operations/running_config.rb +0 -16
  85. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  86. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  87. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  88. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  89. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  90. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  91. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  92. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  93. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  94. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  95. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  96. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  97. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  98. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  99. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  100. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  101. data/lib/net/connector/operations/topology/radware.rb +0 -11
  102. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  103. data/lib/net/connector/operations/topology.rb +0 -261
  104. 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,
@@ -70,6 +69,9 @@ module Net
70
69
  def settings_for(options)
71
70
  defaults = options[:config] ? ConfigFile.load(options[:config]) : {}
72
71
  values = {}
72
+ values["NETDISCO_SOURCE"] = options[:source] if options[:source]
73
+ values["NETDISCO_QUERY"] = options[:query] if options[:query]
74
+ values["NETDISCO_QUERY_PARAMS"] = options[:query_params] if options[:query_params]
73
75
  values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
74
76
  values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
75
77
  values["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES"] = options[:max_script_output_bytes].to_s if options[:max_script_output_bytes]
@@ -81,8 +83,8 @@ module Net
81
83
 
82
84
  # 导出已有配置不创建清单或设备连接。
83
85
  def export_config(settings, options)
84
- path = Operations::SavedConfig.new(directory: settings.backup_directory)
85
- .export(host: options[:export], output: options[:output], io: @output)
86
+ path = Storage::SavedConfig.new(directory: settings.backup_directory)
87
+ .export(host: options[:export], output: options[:output], io: @output)
86
88
  @output.puts JSON.generate(host: options[:export], output: path) if path
87
89
  end
88
90
 
@@ -103,7 +105,7 @@ module Net
103
105
 
104
106
  # 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
105
107
  def backup(fleet, plan, settings, options)
106
- reporting = options.slice(:success_policy, :report_schema)
108
+ reporting = options.slice(:success_policy)
107
109
  if options[:tftp]
108
110
  fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
109
111
  concurrency: settings.concurrency,
@@ -121,6 +123,9 @@ module Net
121
123
  parser = OptionParser.new do |args|
122
124
  args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
123
125
  args.on("--config FILE", "读取不含凭据的 YAML 设置") { |value| options[:config] = value }
126
+ args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
127
+ args.on("--query SQL", "PostgreSQL 清单查询,使用 $1、$2 绑定参数") { |value| options[:query] = value }
128
+ args.on("--query-params JSON", "查询参数的 JSON 数组") { |value| options[:query_params] = value }
124
129
  args.on("--show-config", "显示生效的非敏感设置") { options[:show_config] = true }
125
130
  args.on("--plan", "预览设备清单,不连接设备") { options[:plan] = true }
126
131
  args.on("--tftp", "由设备发起 TFTP 备份") { options[:tftp] = true }
@@ -135,7 +140,6 @@ module Net
135
140
  end
136
141
  args.on("--all", "选择全部就绪设备") { options[:all] = true }
137
142
  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
143
  args.on("-v", "--version", "显示版本") do
140
144
  @output.puts Net::Connector::VERSION
141
145
  options[:done] = true
@@ -153,15 +157,18 @@ module Net
153
157
 
154
158
  # 拒绝互斥或缺少依赖的命令行选项。
155
159
  def validate_options!(options)
156
- Report.options(policy: options.fetch(:success_policy, :strict), schema: options[:report_schema])
160
+ Report.validate_policy!(options.fetch(:success_policy, :strict))
157
161
  Planner.validate_limit!(options[:limit])
158
162
  Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
159
163
  modes = [:show_config, :plan, :export].count { |key| options[key] }
160
164
  raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
161
165
  raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
162
166
  raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
167
+ if options[:export] && (options[:source] || options[:query] || options[:query_params])
168
+ raise ArgumentError, "--export 不能与清单查询选项同时使用"
169
+ end
163
170
  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])
171
+ options[:success_policy] || options[:max_script_output_bytes])
165
172
  raise ArgumentError, "--export 不能与备份选项同时使用"
166
173
  end
167
174
  end
@@ -96,7 +96,7 @@ module Net
96
96
  @options.fetch(:max_pages).times do |index|
97
97
  uri = endpoint("api/v1/search/device")
98
98
  uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @options.fetch(:page_size),
99
- offset: index * @options.fetch(:page_size))
99
+ offset: index * @options.fetch(:page_size))
100
100
  page = request_json(uri, authorized_get(uri, token), budget, allow_empty: true, query_fallback: true)
101
101
  validate_rows!(page, budget)
102
102
  return rows.uniq if page.empty?
@@ -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)
@@ -274,6 +274,7 @@ module Net
274
274
  end
275
275
 
276
276
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
277
+
277
278
  def present?(value) = value.is_a?(String) && !value.strip.empty?
278
279
  end
279
280
  end
@@ -9,7 +9,10 @@ module Net
9
9
  # 命令行使用的无凭据 YAML 配置,环境变量优先。
10
10
  class ConfigFile
11
11
  FIELDS = {
12
- "netdisco" => { "url" => ["NETDISCO_URL", :string],
12
+ "netdisco" => { "source" => ["NETDISCO_SOURCE", :string],
13
+ "query" => ["NETDISCO_QUERY", :string],
14
+ "query_params" => ["NETDISCO_QUERY_PARAMS", :json_array],
15
+ "url" => ["NETDISCO_URL", :string],
13
16
  "page_size" => ["NETDISCO_PAGE_SIZE", :integer],
14
17
  "max_pages" => ["NETDISCO_MAX_PAGES", :integer],
15
18
  "max_response_bytes" => ["NETDISCO_MAX_RESPONSE_BYTES", :integer],
@@ -94,8 +97,8 @@ module Net
94
97
  end
95
98
 
96
99
  value.join(",")
97
- when :json
98
- expected = name == "device_rules" ? Array : Hash
100
+ when :json, :json_array
101
+ expected = type == :json_array || name == "device_rules" ? Array : Hash
99
102
  raise ArgumentError, "#{section}.#{name} must be a #{expected}" unless value.is_a?(expected)
100
103
 
101
104
  JSON.generate(value)