net-connector 0.7.3 → 0.9.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 +21 -1
- data/CONTRIBUTING.md +12 -0
- data/README.md +27 -424
- data/README.zh-CN.md +64 -0
- data/{exe/net-connector-backup → bin/net-backup} +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +27 -5
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +12 -5
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +23 -16
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/authentication.rb +1 -0
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -134
- data/lib/net/connector/engine/errors.rb +0 -92
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +27 -6
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/redactor.rb +98 -0
- data/lib/net/connector/engine/response_reader.rb +139 -0
- data/lib/net/connector/engine/session.rb +20 -7
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +35 -8
- data/lib/net/connector/netdisco/cli/options.rb +20 -8
- data/lib/net/connector/netdisco/cli.rb +14 -11
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/connection.rb +0 -1
- data/lib/net/connector/netdisco/database_client.rb +44 -26
- data/lib/net/connector/netdisco/device.rb +5 -0
- data/lib/net/connector/netdisco/diagnostic.rb +8 -0
- data/lib/net/connector/netdisco/fleet.rb +12 -3
- data/lib/net/connector/netdisco/plan.rb +16 -3
- data/lib/net/connector/netdisco/planner.rb +2 -1
- data/lib/net/connector/netdisco/progress.rb +31 -7
- data/lib/net/connector/netdisco/report/files.rb +4 -2
- data/lib/net/connector/netdisco/result_store.rb +6 -7
- data/lib/net/connector/netdisco/rules.rb +8 -5
- data/lib/net/connector/netdisco/settings.rb +83 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
- data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
- data/lib/net/connector/netdisco/worker.rb +3 -1
- data/lib/net/connector/netdisco.rb +1 -21
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +12 -20
- data/lib/net/connector/netdisco/tftp_history.rb +0 -16
|
@@ -39,7 +39,7 @@ module Net
|
|
|
39
39
|
open_transcript if @io && @configuration.log_level == :debug
|
|
40
40
|
end
|
|
41
41
|
rescue => error
|
|
42
|
-
|
|
42
|
+
close_preserving_failure
|
|
43
43
|
raise build_error("unable to open session log", error), cause: nil
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -49,9 +49,14 @@ module Net
|
|
|
49
49
|
finish_output
|
|
50
50
|
@context = @context.merge(fields).freeze
|
|
51
51
|
yield
|
|
52
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 保留从当前作用域向外传播的故障,包括中断。
|
|
53
|
+
failed = true
|
|
54
|
+
raise
|
|
52
55
|
ensure
|
|
53
56
|
begin
|
|
54
57
|
finish_output
|
|
58
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 收尾故障或中断不能覆盖当前作用域的原始失败。
|
|
59
|
+
raise unless failed
|
|
55
60
|
ensure
|
|
56
61
|
@context = previous
|
|
57
62
|
end
|
|
@@ -98,6 +103,9 @@ module Net
|
|
|
98
103
|
code: ErrorMetadata.code(failure.code),
|
|
99
104
|
phase: ErrorMetadata.phase(failure.phase) || ErrorMetadata.phase(phase),
|
|
100
105
|
message: failure.message)
|
|
106
|
+
rescue Error
|
|
107
|
+
# 原始失败已由调用者保留;诊断通道故障不能替换登录、命令或业务错误。
|
|
108
|
+
nil
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
# 用户钩子可能把未登记的配置片段放进事件名、字段名或值;敏感范围统一隐藏。
|
|
@@ -163,8 +171,16 @@ module Net
|
|
|
163
171
|
finish_output
|
|
164
172
|
@transport.log_output = nil if @transport
|
|
165
173
|
yield
|
|
174
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 恢复日志接线,不覆盖当前作用域的故障或中断。
|
|
175
|
+
failed = true
|
|
176
|
+
raise
|
|
166
177
|
ensure
|
|
167
|
-
|
|
178
|
+
begin
|
|
179
|
+
@transport.log_output = self if @transport && @writer && @attached
|
|
180
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 接线恢复故障或中断不能覆盖原始操作异常。
|
|
181
|
+
# 恢复接线仍须尝试,但不能盖掉设备失败或用户中断。
|
|
182
|
+
raise unless failed
|
|
183
|
+
end
|
|
168
184
|
end
|
|
169
185
|
|
|
170
186
|
# 断开回显记录并关闭日志资源。
|
|
@@ -172,9 +188,14 @@ module Net
|
|
|
172
188
|
begin
|
|
173
189
|
@transport.log_output = nil if @transport
|
|
174
190
|
finish_output
|
|
191
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 脱离日志或刷新失败后仍须关闭文件并清除状态。
|
|
192
|
+
failed = true
|
|
193
|
+
raise
|
|
175
194
|
ensure
|
|
176
195
|
begin
|
|
177
196
|
@io&.close
|
|
197
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
|
|
198
|
+
raise unless failed
|
|
178
199
|
ensure
|
|
179
200
|
@writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
|
|
180
201
|
@attached = false
|
|
@@ -233,10 +254,10 @@ module Net
|
|
|
233
254
|
file.binmode
|
|
234
255
|
file.write("\n") if file.size.positive? && @configuration.log_format == :text
|
|
235
256
|
file
|
|
236
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
257
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也释放尚未完成设置的文件。
|
|
237
258
|
begin
|
|
238
259
|
file.close
|
|
239
|
-
rescue
|
|
260
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 保留文件设置的原始故障或中断。
|
|
240
261
|
# 保留设置失败的原始原因。
|
|
241
262
|
end
|
|
242
263
|
raise
|
|
@@ -244,9 +265,9 @@ module Net
|
|
|
244
265
|
end
|
|
245
266
|
|
|
246
267
|
# 在日志打开失败后尽力释放资源。
|
|
247
|
-
def
|
|
268
|
+
def close_preserving_failure
|
|
248
269
|
close
|
|
249
|
-
rescue
|
|
270
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 清理故障或中断不能覆盖日志打开失败。
|
|
250
271
|
nil
|
|
251
272
|
end
|
|
252
273
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expect/redactor"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
# 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
|
|
8
|
+
class Redactor
|
|
9
|
+
MASK = "[REDACTED]".b.freeze
|
|
10
|
+
|
|
11
|
+
# 保存初始凭据,并建立第一版脱敏词表。
|
|
12
|
+
def initialize(*secrets)
|
|
13
|
+
unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
|
|
14
|
+
raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@configured = secrets.compact.map(&:b).map(&:freeze).freeze
|
|
18
|
+
reset
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 重置动态词表,同时保留配置中的固定凭据。
|
|
22
|
+
def reset
|
|
23
|
+
@secrets = []
|
|
24
|
+
@patterns = nil
|
|
25
|
+
@scoped = false
|
|
26
|
+
@sensitive = false
|
|
27
|
+
@output_sensitive = false
|
|
28
|
+
@configured.each { |secret| remember(secret) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
|
|
32
|
+
def with_scope(reuse: false)
|
|
33
|
+
return yield if reuse && @scoped
|
|
34
|
+
|
|
35
|
+
previous = @secrets
|
|
36
|
+
previous_patterns = @patterns
|
|
37
|
+
previous_scoped = @scoped
|
|
38
|
+
previous_sensitive = @sensitive
|
|
39
|
+
previous_output_sensitive = @output_sensitive
|
|
40
|
+
@secrets = previous.dup
|
|
41
|
+
@scoped = true
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
@secrets = previous
|
|
46
|
+
@patterns = previous_patterns
|
|
47
|
+
@scoped = previous_scoped
|
|
48
|
+
@sensitive = previous_sensitive
|
|
49
|
+
@output_sensitive = previous_output_sensitive
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
|
|
54
|
+
def sensitive! = @sensitive = true
|
|
55
|
+
|
|
56
|
+
# 判断当前脱敏范围是否包含敏感交互。
|
|
57
|
+
def sensitive? = @sensitive
|
|
58
|
+
|
|
59
|
+
# 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
|
|
60
|
+
def output_sensitive!
|
|
61
|
+
@output_sensitive = true
|
|
62
|
+
sensitive!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 后续查询继承输出边界,即使查询命令本身没有敏感标记。
|
|
66
|
+
def output_sensitive? = @output_sensitive
|
|
67
|
+
|
|
68
|
+
# 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
|
|
69
|
+
def remember(secret)
|
|
70
|
+
return if secret.nil? || secret.empty?
|
|
71
|
+
|
|
72
|
+
bytes = secret.b
|
|
73
|
+
return if @secrets.include?(bytes)
|
|
74
|
+
|
|
75
|
+
@secrets << bytes.freeze
|
|
76
|
+
@patterns = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
|
|
80
|
+
def call(text)
|
|
81
|
+
Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
|
|
85
|
+
def stream
|
|
86
|
+
Expect::Redactor.new(patterns, replacement: MASK)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
|
|
90
|
+
def patterns
|
|
91
|
+
@patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# 返回脱敏器类型摘要。
|
|
95
|
+
def inspect = "#<#{self.class}>"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expect/pty"
|
|
4
|
+
require_relative "dialogue"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
# 在传输层上执行有界对话;收到字节或分页标记不会重置截止时间。
|
|
9
|
+
class ResponseReader
|
|
10
|
+
# 保存会话引用,以便读取器使用传输、配置和脱敏上下文。
|
|
11
|
+
def initialize(session)
|
|
12
|
+
@session = session
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# 读取到结束提示、失败模式或超时,并处理交互响应和输出上限。
|
|
16
|
+
def read(prompt:, interactions:, phase:, timeout: nil, deadline: nil, command: nil, failures: {})
|
|
17
|
+
deadline ||= Expect.monotonic + timeout
|
|
18
|
+
patterns = [*failures.keys, *interactions.map(&:pattern), prompt]
|
|
19
|
+
raw = []
|
|
20
|
+
output = []
|
|
21
|
+
counts = Hash.new(0)
|
|
22
|
+
bytes = 0
|
|
23
|
+
loop do
|
|
24
|
+
event = read_event(patterns, deadline)
|
|
25
|
+
raw << event.before unless event.before.empty?
|
|
26
|
+
output << event.before unless event.before.empty?
|
|
27
|
+
bytes += event.before.bytesize + event.match.bytesize
|
|
28
|
+
validate_event!(event, bytes, raw, phase, command)
|
|
29
|
+
|
|
30
|
+
if event.index == patterns.size
|
|
31
|
+
raw << event.match
|
|
32
|
+
output << event.match
|
|
33
|
+
elsif event.index == patterns.size - 1
|
|
34
|
+
return finish_response(event.match, raw, output, phase, command)
|
|
35
|
+
elsif event.index < failures.size
|
|
36
|
+
raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
|
|
37
|
+
else
|
|
38
|
+
reply = interactions.fetch(event.index - failures.size)
|
|
39
|
+
raw << event.match
|
|
40
|
+
output << event.match if reply.capture?
|
|
41
|
+
respond(reply, event.match, counts, deadline, phase, command)
|
|
42
|
+
end
|
|
43
|
+
next if Expect.monotonic < deadline
|
|
44
|
+
|
|
45
|
+
raise build_read_error(nil, phase, command, raw.join), cause: nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# 分页和流式输出只消耗剩余时间,不为下一次读取重新计时。
|
|
52
|
+
def read_event(patterns, deadline)
|
|
53
|
+
@session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
|
|
57
|
+
def build_connection_error(failure, phase, command)
|
|
58
|
+
klass, code = failure
|
|
59
|
+
@session.build_error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
|
|
63
|
+
def validate_event!(event, bytes, raw, phase, command)
|
|
64
|
+
if bytes > @session.configuration.max_output_bytes
|
|
65
|
+
raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
|
|
66
|
+
phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
|
|
67
|
+
end
|
|
68
|
+
raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
|
|
72
|
+
def finish_response(prompt, raw, output, phase, command)
|
|
73
|
+
if prompt.empty?
|
|
74
|
+
raise @session.build_error(PromptError, "prompt pattern did not consume any output",
|
|
75
|
+
phase: phase, command: command, output: raw.join), cause: nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raw << prompt
|
|
79
|
+
output << prompt
|
|
80
|
+
raw_text = raw.join
|
|
81
|
+
Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
|
|
85
|
+
def respond(reply, prompt, counts, deadline, phase, command)
|
|
86
|
+
counts[reply] += 1
|
|
87
|
+
if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
|
|
88
|
+
raise build_interaction_error("response rejected or repeated", phase, command), cause: nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
value = interaction_response(reply, prompt, phase, command)
|
|
92
|
+
raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
|
|
93
|
+
|
|
94
|
+
@session.redactor.remember(value.chomp) if reply.sensitive?
|
|
95
|
+
@session.write(value, deadline: deadline, phase: phase, command: command)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
|
|
99
|
+
# 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
|
|
100
|
+
def interaction_response(reply, prompt, phase, command)
|
|
101
|
+
return reply.response(prompt) unless reply.sensitive?
|
|
102
|
+
|
|
103
|
+
@session.redactor.with_scope(reuse: true) do
|
|
104
|
+
@session.redactor.sensitive!
|
|
105
|
+
reply.response(prompt)
|
|
106
|
+
rescue => error
|
|
107
|
+
raise @session.normalize_error(error, phase: phase, command: command), cause: nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 将交互拒绝或响应缺失转换为认证或脚本错误。
|
|
112
|
+
def build_interaction_error(message, phase, command)
|
|
113
|
+
authenticating = %i[login enable].include?(phase)
|
|
114
|
+
klass = authenticating ? AuthenticationError : ScriptError
|
|
115
|
+
label = authenticating ? "authentication" : "command interaction"
|
|
116
|
+
@session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
|
|
120
|
+
def build_read_error(event, phase, command, output)
|
|
121
|
+
if event&.error.is_a?(Exception)
|
|
122
|
+
return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
|
|
123
|
+
underlying: event.error, output: output)
|
|
124
|
+
end
|
|
125
|
+
klass = if event&.error == :eof
|
|
126
|
+
ConnectionClosed
|
|
127
|
+
elsif command&.prompt
|
|
128
|
+
PromptError
|
|
129
|
+
elsif %i[login enable].include?(phase)
|
|
130
|
+
LoginTimeout
|
|
131
|
+
else
|
|
132
|
+
CommandTimeout
|
|
133
|
+
end
|
|
134
|
+
@session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
|
|
135
|
+
command: command, output: output)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "English"
|
|
4
3
|
require_relative "authentication"
|
|
5
4
|
require_relative "log"
|
|
5
|
+
require_relative "redactor"
|
|
6
|
+
require_relative "response_reader"
|
|
6
7
|
require_relative "recovery"
|
|
7
8
|
require_relative "transport"
|
|
8
9
|
|
|
@@ -45,12 +46,14 @@ module Net
|
|
|
45
46
|
started = Expect.monotonic
|
|
46
47
|
@log.log_event("operation_start", level: :debug, phase: :script)
|
|
47
48
|
result = yield
|
|
48
|
-
log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
|
|
49
|
-
result
|
|
50
49
|
rescue => error
|
|
51
50
|
failure = normalize_error(error, phase: :script)
|
|
52
51
|
log_operation_completion(started, failure, steps: steps.size)
|
|
53
52
|
raise failure, cause: nil
|
|
53
|
+
else
|
|
54
|
+
# 完成事件只发送一次;观察者失败由外层保留业务结果,不再次调用同一观察者。
|
|
55
|
+
log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
|
|
56
|
+
result
|
|
54
57
|
end
|
|
55
58
|
end
|
|
56
59
|
|
|
@@ -178,8 +181,11 @@ module Net
|
|
|
178
181
|
@state = :interacting
|
|
179
182
|
begin
|
|
180
183
|
transport.interact(**options)
|
|
184
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 交互失败或中断后仍关闭会话,保留本次原始异常。
|
|
185
|
+
failed = true
|
|
186
|
+
raise
|
|
181
187
|
ensure
|
|
182
|
-
|
|
188
|
+
failed ? close_preserving_failure : close_resources
|
|
183
189
|
end
|
|
184
190
|
end
|
|
185
191
|
end
|
|
@@ -245,7 +251,7 @@ module Net
|
|
|
245
251
|
failure = normalize_error(error, phase: phase)
|
|
246
252
|
close_preserving_failure
|
|
247
253
|
raise failure, cause: nil
|
|
248
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
254
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
|
|
249
255
|
close_preserving_failure
|
|
250
256
|
raise
|
|
251
257
|
ensure
|
|
@@ -380,8 +386,15 @@ module Net
|
|
|
380
386
|
@prompt = nil
|
|
381
387
|
begin
|
|
382
388
|
transport.close
|
|
389
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 传输关闭失败或中断后仍须尝试关闭日志。
|
|
390
|
+
failed = true
|
|
391
|
+
raise
|
|
383
392
|
ensure
|
|
384
|
-
|
|
393
|
+
begin
|
|
394
|
+
@log.close
|
|
395
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 日志收尾不能覆盖传输关闭的首个失败。
|
|
396
|
+
raise unless failed
|
|
397
|
+
end
|
|
385
398
|
end
|
|
386
399
|
nil
|
|
387
400
|
rescue => error
|
|
@@ -391,7 +404,7 @@ module Net
|
|
|
391
404
|
# 尝试清理资源但不覆盖原始操作异常。
|
|
392
405
|
def close_preserving_failure
|
|
393
406
|
close_resources
|
|
394
|
-
rescue
|
|
407
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 原操作失败时,清理故障或中断均不得覆盖它。
|
|
395
408
|
# 所有清理路径完成后,原始操作异常仍然是权威结果。
|
|
396
409
|
nil
|
|
397
410
|
end
|
|
@@ -65,12 +65,8 @@ module Net
|
|
|
65
65
|
@channel.spawn(*argv)
|
|
66
66
|
end
|
|
67
67
|
self
|
|
68
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
69
|
-
|
|
70
|
-
close
|
|
71
|
-
rescue
|
|
72
|
-
# 尝试释放半打开信道后,仍然抛出原始失败。
|
|
73
|
-
end
|
|
68
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
|
|
69
|
+
close_preserving_failure
|
|
74
70
|
raise
|
|
75
71
|
end
|
|
76
72
|
|
|
@@ -120,6 +116,13 @@ module Net
|
|
|
120
116
|
|
|
121
117
|
private
|
|
122
118
|
|
|
119
|
+
# 已有操作异常时仍尝试清理;次级故障或中断不能覆盖原始失败。
|
|
120
|
+
def close_preserving_failure
|
|
121
|
+
close
|
|
122
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 清理后继续传播原始操作异常。
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
123
126
|
# 检查缓冲上限并把设备输出写入当前日志目标。
|
|
124
127
|
def write_log_output(bytes)
|
|
125
128
|
if @channel.buffer.bytesize > configuration.max_output_bytes
|
|
@@ -148,8 +151,8 @@ module Net
|
|
|
148
151
|
@known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
|
|
149
152
|
end
|
|
150
153
|
super
|
|
151
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
152
|
-
|
|
154
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
|
|
155
|
+
close_preserving_failure
|
|
153
156
|
raise
|
|
154
157
|
end
|
|
155
158
|
|
|
@@ -159,10 +162,18 @@ module Net
|
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
def close
|
|
165
|
+
known_hosts = @known_hosts
|
|
166
|
+
@known_hosts = nil
|
|
162
167
|
super
|
|
168
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
169
|
+
failed = true
|
|
170
|
+
raise
|
|
163
171
|
ensure
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
begin
|
|
173
|
+
known_hosts&.close
|
|
174
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
175
|
+
raise unless failed
|
|
176
|
+
end
|
|
166
177
|
end
|
|
167
178
|
|
|
168
179
|
# 返回 SSH 协议标识。
|
|
@@ -2,25 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "socket"
|
|
5
|
+
require_relative "cli/options"
|
|
6
|
+
require_relative "connection"
|
|
7
|
+
require_relative "fleet"
|
|
8
|
+
require_relative "progress"
|
|
9
|
+
require_relative "report/files"
|
|
10
|
+
require_relative "../storage/batch_directory"
|
|
5
11
|
|
|
6
12
|
module Net
|
|
7
13
|
module Connector
|
|
8
14
|
module Netdisco
|
|
9
15
|
# 人类批次入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
|
|
10
16
|
class BackupRun
|
|
17
|
+
# 保存批次模式与调用方 IO,构造阶段不读取清单、不创建目录。
|
|
11
18
|
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr)
|
|
12
19
|
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
13
20
|
|
|
14
21
|
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
15
22
|
end
|
|
16
23
|
|
|
24
|
+
# 先固定并校验策略,再预览、执行和保存报告;成功判定使用最终报告。
|
|
17
25
|
def run
|
|
18
26
|
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
|
|
19
27
|
return 0 if options[:help]
|
|
20
28
|
|
|
21
29
|
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
22
30
|
client, credentials = Connection.build(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
23
|
-
progress = Progress.new(io: @error, enabled:
|
|
31
|
+
progress = Progress.new(io: @error, enabled: !options[:no_progress], verbose: options[:verbose])
|
|
24
32
|
resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
|
|
25
33
|
fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
|
|
26
34
|
progress.reading_inventory
|
|
@@ -38,8 +46,9 @@ module Net
|
|
|
38
46
|
|
|
39
47
|
Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
|
|
40
48
|
report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
49
|
+
report = record_progress_error(report, progress)
|
|
41
50
|
report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
|
|
42
|
-
finish(report, progress, options, batch_directory)
|
|
51
|
+
finish(report, progress, options, batch_directory, plan, settings.concurrency)
|
|
43
52
|
rescue ArgumentError, Client::Error => exception
|
|
44
53
|
@error.puts "备份参数或清单错误:#{exception.message}"
|
|
45
54
|
2
|
|
@@ -50,8 +59,9 @@ module Net
|
|
|
50
59
|
|
|
51
60
|
private
|
|
52
61
|
|
|
62
|
+
# 预检完成后才创建批次目录;补充日志路径时保持原策略快照与动态凭据来源。
|
|
53
63
|
def prepare(options)
|
|
54
|
-
settings = CLI::Options.settings(options, env: @env)
|
|
64
|
+
settings = CLI::Options.settings(options, env: @env).for_run(mode: @mode)
|
|
55
65
|
success_policy = options.fetch(:success_policy, :selected)
|
|
56
66
|
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
57
67
|
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
@@ -62,10 +72,10 @@ module Net
|
|
|
62
72
|
if @mode == :tftp && !settings.log_directory
|
|
63
73
|
settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
|
|
64
74
|
end
|
|
65
|
-
settings = settings.for_run(mode: @mode)
|
|
66
75
|
[settings, success_policy, server_root, batch_directory]
|
|
67
76
|
end
|
|
68
77
|
|
|
78
|
+
# 事件流仅记录受控元数据;计划写入后在有界工作线程中执行同一批设备。
|
|
69
79
|
def execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
70
80
|
File.open(File.join(batch_directory, "events.jsonl"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |events|
|
|
71
81
|
options = { plan: plan, concurrency: settings.concurrency, success_policy: success_policy,
|
|
@@ -82,6 +92,7 @@ module Net
|
|
|
82
92
|
end
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
# 将任务结果追加到私有事件流并更新进度;异常由工作线程保留为回调诊断。
|
|
85
96
|
def result_callback(progress, events)
|
|
86
97
|
lock = Mutex.new
|
|
87
98
|
lambda do |outcome|
|
|
@@ -95,10 +106,17 @@ module Net
|
|
|
95
106
|
end
|
|
96
107
|
end
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
# 报告先保存,再显示完成信息;终端收尾失败只补写诊断,不重放任务。
|
|
110
|
+
def finish(report, progress, options, batch_directory, plan, concurrency)
|
|
99
111
|
progress.finish(report)
|
|
100
112
|
progress.location(report.report_location) if report.report_location
|
|
101
|
-
|
|
113
|
+
summary_path = File.join(batch_directory, "summary.txt")
|
|
114
|
+
progress.location(summary_path, label: "文本报告") if File.file?(summary_path)
|
|
115
|
+
updated = record_progress_error(report, progress)
|
|
116
|
+
unless updated.equal?(report)
|
|
117
|
+
# 末尾显示也可能首次失败,只补写诊断,不重复设备任务或事件。
|
|
118
|
+
report = Report::Files.write(updated, directory: batch_directory, plan: plan, concurrency: concurrency)
|
|
119
|
+
end
|
|
102
120
|
if options[:json]
|
|
103
121
|
@output.puts JSON.generate(directory: batch_directory, counts: report.counts,
|
|
104
122
|
tasks_succeeded: report.policy_success?, success: report.policy_success?,
|
|
@@ -108,15 +126,24 @@ module Net
|
|
|
108
126
|
report.policy_success? ? 0 : 1
|
|
109
127
|
end
|
|
110
128
|
|
|
129
|
+
# 输出故障只记录一次,保留已有批次结果和回调错误。
|
|
130
|
+
def record_progress_error(report, progress)
|
|
131
|
+
return report unless progress.output_error && !report.callback_errors.include?(progress.output_error)
|
|
132
|
+
|
|
133
|
+
report.with(callback_errors: [*report.callback_errors, progress.output_error].freeze)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# 优先使用显式核验根目录;仅服务器是本机地址时探测本机惯用 TFTP 目录。
|
|
111
137
|
def tftp_root(settings, options)
|
|
112
|
-
server = settings.tftp_server || (raise ArgumentError, "
|
|
113
|
-
return options[:tftp_root] ||
|
|
138
|
+
server = settings.tftp_server || (raise ArgumentError, "YAML tftp.server is required")
|
|
139
|
+
return options[:tftp_root] || settings.tftp_root if options[:tftp_root] || settings.tftp_root
|
|
114
140
|
return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
115
141
|
|
|
116
142
|
candidate = File.join(Dir.home, "Documents", "TFTP")
|
|
117
143
|
candidate if File.directory?(candidate)
|
|
118
144
|
end
|
|
119
145
|
|
|
146
|
+
# 预览来自已有计划,文件名按本次模式计算,不重新请求清单或设备。
|
|
120
147
|
def plan_summary(plan)
|
|
121
148
|
{ total: plan.inventory.size, ready: plan.inventory.count(&:ready?),
|
|
122
149
|
vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
|