net-connector 0.7.2 → 0.8.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 +18 -1
- data/CONTRIBUTING.md +1 -1
- data/README.md +16 -11
- data/docs/RELEASING.md +2 -2
- data/docs/architecture.md +22 -1
- data/lib/net/connector/device/base.rb +7 -1
- data/lib/net/connector/device/tftp.rb +14 -8
- data/lib/net/connector/engine/authentication.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +0 -132
- data/lib/net/connector/engine/error_metadata.rb +5 -3
- data/lib/net/connector/engine/errors.rb +0 -92
- data/lib/net/connector/engine/known_hosts.rb +14 -1
- data/lib/net/connector/engine/log.rb +17 -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 +6 -2
- data/lib/net/connector/netdisco/backup_run.rb +22 -3
- data/lib/net/connector/netdisco/cli/options.rb +3 -0
- data/lib/net/connector/netdisco/cli.rb +11 -8
- data/lib/net/connector/netdisco/connection.rb +0 -1
- data/lib/net/connector/netdisco/database_client.rb +4 -2
- data/lib/net/connector/netdisco/device.rb +2 -0
- data/lib/net/connector/netdisco/diagnostic.rb +4 -0
- data/lib/net/connector/netdisco/fleet.rb +10 -1
- data/lib/net/connector/netdisco/plan.rb +13 -0
- data/lib/net/connector/netdisco/planner.rb +2 -1
- data/lib/net/connector/netdisco/progress.rb +25 -8
- data/lib/net/connector/netdisco/report/files.rb +11 -6
- data/lib/net/connector/netdisco/report/text.rb +1 -1
- data/lib/net/connector/netdisco/report.rb +19 -20
- data/lib/net/connector/netdisco/result_store.rb +5 -7
- data/lib/net/connector/netdisco/rules.rb +1 -0
- data/lib/net/connector/netdisco/settings.rb +6 -0
- data/lib/net/connector/netdisco/tftp_archive.rb +28 -13
- data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
- data/lib/net/connector/netdisco/worker.rb +2 -0
- data/lib/net/connector/netdisco.rb +1 -21
- data/lib/net/connector/vendor/palo_alto/running_config.rb +26 -6
- data/lib/net/connector/version.rb +1 -1
- metadata +7 -6
- data/lib/net/connector/netdisco/tftp_history.rb +0 -16
- /data/{exe/net-connector-backup → bin/net-backup} +0 -0
|
@@ -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 -- Preserve only failures propagating from this scope, including interrupts.
|
|
53
|
+
failed = true
|
|
54
|
+
raise
|
|
52
55
|
ensure
|
|
53
56
|
begin
|
|
54
57
|
finish_output
|
|
58
|
+
rescue Error
|
|
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 -- Restore attachment without replacing this scope's failure or interruption.
|
|
175
|
+
failed = true
|
|
176
|
+
raise
|
|
166
177
|
ensure
|
|
167
|
-
|
|
178
|
+
begin
|
|
179
|
+
@transport.log_output = self if @transport && @writer && @attached
|
|
180
|
+
rescue StandardError
|
|
181
|
+
# 恢复接线仍须尝试,但不能盖掉设备失败或用户中断。
|
|
182
|
+
raise unless failed
|
|
183
|
+
end
|
|
168
184
|
end
|
|
169
185
|
|
|
170
186
|
# 断开回显记录并关闭日志资源。
|
|
@@ -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
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "English"
|
|
4
4
|
require_relative "authentication"
|
|
5
5
|
require_relative "log"
|
|
6
|
+
require_relative "redactor"
|
|
7
|
+
require_relative "response_reader"
|
|
6
8
|
require_relative "recovery"
|
|
7
9
|
require_relative "transport"
|
|
8
10
|
|
|
@@ -45,12 +47,14 @@ module Net
|
|
|
45
47
|
started = Expect.monotonic
|
|
46
48
|
@log.log_event("operation_start", level: :debug, phase: :script)
|
|
47
49
|
result = yield
|
|
48
|
-
log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
|
|
49
|
-
result
|
|
50
50
|
rescue => error
|
|
51
51
|
failure = normalize_error(error, phase: :script)
|
|
52
52
|
log_operation_completion(started, failure, steps: steps.size)
|
|
53
53
|
raise failure, cause: nil
|
|
54
|
+
else
|
|
55
|
+
# 完成事件只发送一次;观察者失败由外层保留业务结果,不再次调用同一观察者。
|
|
56
|
+
log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
|
|
57
|
+
result
|
|
54
58
|
end
|
|
55
59
|
end
|
|
56
60
|
|
|
@@ -2,6 +2,12 @@
|
|
|
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
|
|
@@ -38,8 +44,9 @@ module Net
|
|
|
38
44
|
|
|
39
45
|
Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
|
|
40
46
|
report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
47
|
+
report = record_progress_error(report, progress)
|
|
41
48
|
report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
|
|
42
|
-
finish(report, progress, options, batch_directory)
|
|
49
|
+
finish(report, progress, options, batch_directory, plan, settings.concurrency)
|
|
43
50
|
rescue ArgumentError, Client::Error => exception
|
|
44
51
|
@error.puts "备份参数或清单错误:#{exception.message}"
|
|
45
52
|
2
|
|
@@ -95,10 +102,16 @@ module Net
|
|
|
95
102
|
end
|
|
96
103
|
end
|
|
97
104
|
|
|
98
|
-
def finish(report, progress, options, batch_directory)
|
|
105
|
+
def finish(report, progress, options, batch_directory, plan, concurrency)
|
|
99
106
|
progress.finish(report)
|
|
100
107
|
progress.location(report.report_location) if report.report_location
|
|
101
|
-
|
|
108
|
+
summary_path = File.join(batch_directory, "summary.txt")
|
|
109
|
+
progress.location(summary_path, label: "文本报告") if File.file?(summary_path)
|
|
110
|
+
updated = record_progress_error(report, progress)
|
|
111
|
+
unless updated.equal?(report)
|
|
112
|
+
# 末尾显示也可能首次失败,只补写诊断,不重复设备任务或事件。
|
|
113
|
+
report = Report::Files.write(updated, directory: batch_directory, plan: plan, concurrency: concurrency)
|
|
114
|
+
end
|
|
102
115
|
if options[:json]
|
|
103
116
|
@output.puts JSON.generate(directory: batch_directory, counts: report.counts,
|
|
104
117
|
tasks_succeeded: report.policy_success?, success: report.policy_success?,
|
|
@@ -108,6 +121,12 @@ module Net
|
|
|
108
121
|
report.policy_success? ? 0 : 1
|
|
109
122
|
end
|
|
110
123
|
|
|
124
|
+
def record_progress_error(report, progress)
|
|
125
|
+
return report unless progress.output_error && !report.callback_errors.include?(progress.output_error)
|
|
126
|
+
|
|
127
|
+
report.with(callback_errors: [*report.callback_errors, progress.output_error].freeze)
|
|
128
|
+
end
|
|
129
|
+
|
|
111
130
|
def tftp_root(settings, options)
|
|
112
131
|
server = settings.tftp_server || (raise ArgumentError, "TFTP_HOST or YAML tftp.server is required")
|
|
113
132
|
return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["TFTP_ROOT"]
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "ipaddr"
|
|
5
5
|
require "optparse"
|
|
6
|
+
require_relative "../version"
|
|
7
|
+
require_relative "fleet"
|
|
8
|
+
require_relative "cli/options"
|
|
6
9
|
|
|
7
10
|
module Net
|
|
8
11
|
module Connector
|
|
@@ -46,23 +49,23 @@ module Net
|
|
|
46
49
|
return 0
|
|
47
50
|
end
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
summary =
|
|
52
|
+
report = backup(fleet, plan, settings, options)
|
|
53
|
+
summary = report.summary
|
|
51
54
|
@output.puts JSON.pretty_generate(summary)
|
|
52
|
-
|
|
55
|
+
report.policy_success? ? 0 : 1
|
|
53
56
|
rescue Storage::PrivateFile::WriteError => exception
|
|
54
57
|
message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
|
|
55
|
-
@error.puts "net-
|
|
58
|
+
@error.puts "net-backup: #{message}"
|
|
56
59
|
2
|
|
57
60
|
rescue OptionParser::ParseError
|
|
58
|
-
@error.puts "net-
|
|
61
|
+
@error.puts "net-backup: 参数无效;使用 --help 查看参数"
|
|
59
62
|
2
|
|
60
63
|
rescue ArgumentError, KeyError, Errno::ENOENT,
|
|
61
64
|
Psych::Exception, JSON::ParserError, Client::Error => exception
|
|
62
|
-
@error.puts "net-
|
|
65
|
+
@error.puts "net-backup: #{exception.message}"
|
|
63
66
|
2
|
|
64
67
|
rescue StandardError => exception
|
|
65
|
-
@error.puts "net-
|
|
68
|
+
@error.puts "net-backup 失败(#{exception.class})"
|
|
66
69
|
2
|
|
67
70
|
end
|
|
68
71
|
|
|
@@ -113,7 +116,7 @@ module Net
|
|
|
113
116
|
def parse_options
|
|
114
117
|
options = { environment: {} }
|
|
115
118
|
parser = OptionParser.new do |args|
|
|
116
|
-
args.banner = "用法:net-
|
|
119
|
+
args.banner = "用法:net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
|
|
117
120
|
Options.add_settings(args, options)
|
|
118
121
|
Options.add_execution(args, options)
|
|
119
122
|
args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
|
|
@@ -48,8 +48,10 @@ module Net
|
|
|
48
48
|
connected = false
|
|
49
49
|
begin
|
|
50
50
|
Timeout.timeout(budget.remaining, Client::InventoryTimeout) do
|
|
51
|
-
#
|
|
52
|
-
|
|
51
|
+
# 先取得连接句柄再交付超时,确保异常到达时 ensure 能显式关闭连接。
|
|
52
|
+
Thread.handle_interrupt(Client::InventoryTimeout => :never) do
|
|
53
|
+
connection = PG::Connection.connect_start(@connection_options)
|
|
54
|
+
end
|
|
53
55
|
# PostgreSQL NOTICE 可能包含 SQL 或数据,不交给 libpq 默认的 stderr 输出器。
|
|
54
56
|
connection.set_notice_processor { |_notice| nil }
|
|
55
57
|
wait_for_connection(connection, budget)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../engine/error_metadata"
|
|
4
|
+
require_relative "../engine/errors"
|
|
5
|
+
require_relative "../device/local_backup"
|
|
6
|
+
require_relative "../device/tftp/receipt"
|
|
7
|
+
require_relative "../storage/private_file"
|
|
4
8
|
|
|
5
9
|
module Net
|
|
6
10
|
module Connector
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
-
|
|
4
|
+
require_relative "../storage"
|
|
5
|
+
require_relative "client"
|
|
6
|
+
require_relative "settings"
|
|
7
|
+
require_relative "device"
|
|
8
|
+
require_relative "worker"
|
|
9
|
+
require_relative "result_store"
|
|
10
|
+
require_relative "planner"
|
|
11
|
+
require_relative "report"
|
|
12
|
+
require_relative "tftp_verification"
|
|
13
|
+
require_relative "tftp_archive"
|
|
5
14
|
|
|
6
15
|
module Net
|
|
7
16
|
module Connector
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require_relative "device"
|
|
5
|
+
require_relative "batch"
|
|
6
|
+
|
|
3
7
|
module Net
|
|
4
8
|
module Connector
|
|
5
9
|
module Netdisco
|
|
@@ -27,6 +31,7 @@ module Net
|
|
|
27
31
|
unless inventory.all?(Device) && outcomes.size == inventory.size
|
|
28
32
|
raise ArgumentError, "plan inventory and outcomes must have the same device slots"
|
|
29
33
|
end
|
|
34
|
+
validate_duplicate_hosts!
|
|
30
35
|
|
|
31
36
|
selected_indices = {}
|
|
32
37
|
selected_filenames = {}
|
|
@@ -65,6 +70,14 @@ module Net
|
|
|
65
70
|
|
|
66
71
|
private
|
|
67
72
|
|
|
73
|
+
# 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
|
|
74
|
+
def validate_duplicate_hosts!
|
|
75
|
+
groups = inventory.group_by { |device| device.host && IPAddr.new(device.host).to_s }
|
|
76
|
+
return unless groups.any? { |host, devices| host && devices.size > 1 && devices.any? { |device| device.issue != :duplicate_host } }
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
|
|
79
|
+
end
|
|
80
|
+
|
|
68
81
|
# 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
|
|
69
82
|
def valid_skipped_outcome?(result, device, selected_filenames)
|
|
70
83
|
return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "plan"
|
|
4
|
+
require_relative "batch"
|
|
4
5
|
|
|
5
6
|
module Net
|
|
6
7
|
module Connector
|
|
@@ -20,7 +21,7 @@ module Net
|
|
|
20
21
|
end
|
|
21
22
|
selected_tasks = if limit_per_vendor
|
|
22
23
|
ready_tasks.group_by { |_index, device| device.vendor }.values.flat_map do |tasks|
|
|
23
|
-
tasks.
|
|
24
|
+
tasks.min_by(limit_per_vendor) { |_index, device| device.host }
|
|
24
25
|
end
|
|
25
26
|
else
|
|
26
27
|
ready_tasks
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "io/console"
|
|
4
|
+
require_relative "../engine/error_metadata"
|
|
5
|
+
require_relative "../engine/log/event"
|
|
4
6
|
|
|
5
7
|
module Net
|
|
6
8
|
module Connector
|
|
7
9
|
module Netdisco
|
|
8
10
|
# 共享一个实例接收并发会话事件和任务回调;人类进度不混入 JSON 输出。
|
|
9
11
|
class Progress
|
|
12
|
+
attr_reader :output_error
|
|
13
|
+
|
|
10
14
|
def initialize(io: $stderr, enabled: true, verbose: false)
|
|
11
15
|
@io, @enabled, @verbose = io, enabled, verbose
|
|
12
16
|
@tty = io.respond_to?(:tty?) && io.tty?
|
|
@@ -101,7 +105,9 @@ module Net
|
|
|
101
105
|
end
|
|
102
106
|
|
|
103
107
|
def finish(report)
|
|
104
|
-
|
|
108
|
+
return unless @enabled
|
|
109
|
+
|
|
110
|
+
summary = report.statistics
|
|
105
111
|
failures = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
106
112
|
.group_by { |item| item.error_code || item.status }.transform_values(&:size)
|
|
107
113
|
unless failures.empty?
|
|
@@ -146,8 +152,8 @@ module Net
|
|
|
146
152
|
@mutex.synchronize { refresh(force: true) }
|
|
147
153
|
end
|
|
148
154
|
|
|
149
|
-
def location(path)
|
|
150
|
-
write(nil, "
|
|
155
|
+
def location(path, label: "报告")
|
|
156
|
+
write(nil, "#{label}:#{path}")
|
|
151
157
|
end
|
|
152
158
|
|
|
153
159
|
private
|
|
@@ -207,9 +213,7 @@ module Net
|
|
|
207
213
|
|
|
208
214
|
clear_refresh
|
|
209
215
|
@last_status = lines
|
|
210
|
-
@
|
|
211
|
-
@io.flush
|
|
212
|
-
@refreshing = true
|
|
216
|
+
@refreshing = write_output(lines.join("\n"))
|
|
213
217
|
end
|
|
214
218
|
|
|
215
219
|
# 留一列避免终端自动换行,窄窗口也不会留下旧进度行。
|
|
@@ -229,7 +233,7 @@ module Net
|
|
|
229
233
|
def clear_refresh
|
|
230
234
|
return unless @refreshing
|
|
231
235
|
|
|
232
|
-
|
|
236
|
+
write_output("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
|
|
233
237
|
@refreshing = false
|
|
234
238
|
end
|
|
235
239
|
|
|
@@ -244,8 +248,21 @@ module Net
|
|
|
244
248
|
clear_refresh
|
|
245
249
|
line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} 执行中 #{@active}] [#{host || "批次"}] #{message}"
|
|
246
250
|
line = "[#{Time.now.strftime("%H:%M:%S")}] #{host ? "#{host} " : ""}#{message}" unless @verbose
|
|
247
|
-
|
|
251
|
+
write_output(line.gsub(/[[:cntrl:]]/, " ") + "\n")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# 显示失败只停用进度输出;批次仍保存回执和事件,并记录一次受控回调诊断。
|
|
255
|
+
# 所有调用均持有 @mutex,心跳与设备线程不会重复访问已失败的输出。
|
|
256
|
+
def write_output(text)
|
|
257
|
+
return false unless @enabled
|
|
258
|
+
|
|
259
|
+
@io.write(text)
|
|
248
260
|
@io.flush
|
|
261
|
+
true
|
|
262
|
+
rescue IOError, SystemCallError => error
|
|
263
|
+
@output_error = { host: nil, error_type: ErrorMetadata.type(error.class.name) }.freeze
|
|
264
|
+
@enabled = false
|
|
265
|
+
false
|
|
249
266
|
end
|
|
250
267
|
end
|
|
251
268
|
end
|
|
@@ -16,16 +16,16 @@ module Net
|
|
|
16
16
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
|
|
17
17
|
finalized
|
|
18
18
|
rescue StandardError => error
|
|
19
|
-
location =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
failed = report.with_report_error(error, location: location)
|
|
19
|
+
location = committed_location(error, destination) || report.report_location
|
|
20
|
+
# 更新已有诊断时仍保留首次报告故障,以及之前已提交的报告位置。
|
|
21
|
+
failed = report.report_error ? report.with(report_location: location) : report.with_report_error(error, location: location)
|
|
23
22
|
begin
|
|
24
23
|
saved = failed.with(report_location: destination)
|
|
25
24
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
|
|
26
25
|
failed = saved
|
|
27
|
-
rescue StandardError
|
|
28
|
-
#
|
|
26
|
+
rescue StandardError => persistence_error
|
|
27
|
+
# 补写也可能在替换后失败;保留位置,但不覆盖首次报告故障。
|
|
28
|
+
failed = saved if committed_location(persistence_error, destination)
|
|
29
29
|
end
|
|
30
30
|
begin
|
|
31
31
|
Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
|
|
@@ -34,6 +34,11 @@ module Net
|
|
|
34
34
|
end
|
|
35
35
|
failed
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
def self.committed_location(error, destination)
|
|
39
|
+
destination if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == File.expand_path(destination)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :committed_location
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
end
|
|
@@ -10,7 +10,7 @@ module Net
|
|
|
10
10
|
# 将批次结果写为人类可读的文本摘要。
|
|
11
11
|
class Text
|
|
12
12
|
def self.write(directory:, report:, plan:, concurrency:)
|
|
13
|
-
summary = report.
|
|
13
|
+
summary = report.statistics
|
|
14
14
|
failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
15
15
|
groups = failed.group_by { |item| item.error_code || item.status }
|
|
16
16
|
lines = ["网络设备备份报告", "=" * 72,
|