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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -1
- data/README.md +42 -19
- data/docs/VERIFICATION.md +17 -7
- data/docs/architecture.md +96 -64
- data/lib/net/connector/device/base.rb +27 -107
- data/lib/net/connector/device/local_backup.rb +96 -0
- data/lib/net/connector/device/profile.rb +8 -3
- data/lib/net/connector/device/running_config/strategy.rb +1 -1
- data/lib/net/connector/device/running_config.rb +44 -5
- data/lib/net/connector/device/save_config.rb +21 -0
- data/lib/net/connector/device/tftp/file_upload.rb +53 -0
- data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
- data/lib/net/connector/device/tftp/strategy.rb +67 -0
- data/lib/net/connector/device/tftp/target.rb +63 -0
- data/lib/net/connector/device/tftp.rb +117 -0
- data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
- data/lib/net/connector/device/topology/interface_description.rb +46 -0
- data/lib/net/connector/device/topology/interface_name.rb +58 -0
- data/lib/net/connector/device/topology/strategy.rb +75 -0
- data/lib/net/connector/device/topology.rb +277 -0
- data/lib/net/connector/engine/authentication.rb +1 -1
- data/lib/net/connector/engine/configuration.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +17 -17
- data/lib/net/connector/engine/error_metadata.rb +50 -0
- data/lib/net/connector/engine/errors.rb +15 -1
- data/lib/net/connector/engine/execution.rb +15 -8
- data/lib/net/connector/engine/log/event.rb +66 -0
- data/lib/net/connector/engine/log/formatter.rb +16 -0
- data/lib/net/connector/engine/log/messages.rb +52 -0
- data/lib/net/connector/engine/log/stream.rb +56 -0
- data/lib/net/connector/engine/log.rb +105 -84
- data/lib/net/connector/engine/session.rb +83 -42
- data/lib/net/connector/engine/terminal_text.rb +33 -0
- data/lib/net/connector/engine/transport.rb +2 -2
- data/lib/net/connector/netdisco/batch.rb +9 -45
- data/lib/net/connector/netdisco/cli.rb +7 -9
- data/lib/net/connector/netdisco/client.rb +2 -2
- data/lib/net/connector/netdisco/device.rb +4 -5
- data/lib/net/connector/netdisco/diagnostic.rb +9 -39
- data/lib/net/connector/netdisco/fleet.rb +26 -39
- data/lib/net/connector/netdisco/report.rb +37 -16
- data/lib/net/connector/netdisco/result_store.rb +2 -2
- data/lib/net/connector/netdisco/settings.rb +0 -6
- data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
- data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
- data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
- data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
- data/lib/net/connector/storage.rb +15 -0
- data/lib/net/connector/textfsm.rb +72 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
- data/lib/net/connector/vendor/h3c/topology.rb +5 -5
- data/lib/net/connector/vendor/h3c.rb +3 -3
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
- data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/topology.rb +2 -2
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +1 -6
- metadata +25 -39
- data/lib/net/connector/device/interface_description.rb +0 -44
- data/lib/net/connector/device/interface_name.rb +0 -56
- data/lib/net/connector/engine/base.rb +0 -4
- data/lib/net/connector/engine/log_messages.rb +0 -47
- data/lib/net/connector/engine/profile.rb +0 -4
- data/lib/net/connector/engine.rb +0 -4
- data/lib/net/connector/operations/local_backup.rb +0 -89
- data/lib/net/connector/operations/parse_output.rb +0 -77
- data/lib/net/connector/operations/running_config/cisco.rb +0 -14
- data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
- data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
- data/lib/net/connector/operations/running_config/strategy.rb +0 -3
- data/lib/net/connector/operations/running_config.rb +0 -16
- data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
- data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
- data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
- data/lib/net/connector/operations/tftp/h3c.rb +0 -11
- data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
- data/lib/net/connector/operations/tftp/huawei.rb +0 -11
- data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
- data/lib/net/connector/operations/tftp/radware.rb +0 -11
- data/lib/net/connector/operations/tftp/strategy.rb +0 -75
- data/lib/net/connector/operations/tftp_backup.rb +0 -183
- data/lib/net/connector/operations/topology/cisco.rb +0 -11
- data/lib/net/connector/operations/topology/h3c.rb +0 -11
- data/lib/net/connector/operations/topology/hillstone.rb +0 -11
- data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
- data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
- data/lib/net/connector/operations/topology/radware.rb +0 -11
- data/lib/net/connector/operations/topology/strategy.rb +0 -87
- data/lib/net/connector/operations/topology.rb +0 -261
- 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
146
|
-
redactor.
|
|
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
|
|
176
|
+
def with_sensitive_output(enabled)
|
|
155
177
|
return yield unless enabled
|
|
156
178
|
|
|
157
|
-
redactor.
|
|
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
|
|
167
|
-
redactor.
|
|
188
|
+
def execute_command(command, timeout:, prompt: nil)
|
|
189
|
+
redactor.with_scope(reuse: true) do
|
|
168
190
|
protect_command(command)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
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.
|
|
268
|
-
message
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
322
|
-
@log.
|
|
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.
|
|
329
|
-
|
|
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(:
|
|
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
|
|
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
|
-
#
|
|
24
|
+
# 修改诊断或时刻时释放不再匹配的派生元数据。
|
|
28
25
|
def with(**attributes)
|
|
29
26
|
return self if attributes.empty?
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
self.class.new(**
|
|
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
|
-
#
|
|
67
|
-
def
|
|
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
|
-
|
|
54
|
-
rescue
|
|
55
|
-
message =
|
|
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 =
|
|
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
|
|
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.
|
|
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[:
|
|
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
|
-
|
|
112
|
+
query_required_devices(token, budget)
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
-
def
|
|
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 "../
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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:
|
|
46
|
-
error_type:
|
|
47
|
-
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?(
|
|
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 &&
|
|
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
|