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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -1
- data/README.md +92 -20
- data/docs/VERIFICATION.md +17 -7
- data/docs/architecture.md +102 -64
- data/examples/netdisco_database.yml +16 -0
- 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 +2 -2
- data/lib/net/connector/engine/configuration.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +21 -21
- 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 +17 -10
- 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 +17 -10
- data/lib/net/connector/netdisco/client.rb +4 -3
- data/lib/net/connector/netdisco/config_file.rb +6 -3
- data/lib/net/connector/netdisco/database_client.rb +197 -0
- 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 +26 -9
- data/lib/net/connector/netdisco.rb +1 -0
- 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 +47 -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
|
-
|
|
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,
|
|
@@ -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 =
|
|
85
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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" => { "
|
|
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)
|