net-connector 0.6.0 → 0.7.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 +52 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +112 -50
- data/docs/VERIFICATION.md +42 -1
- data/docs/architecture.md +15 -2
- data/examples/backup.yml +40 -0
- data/lib/net/connector/device/base.rb +30 -38
- data/lib/net/connector/device/profile/builder.rb +8 -8
- data/lib/net/connector/device/profile.rb +2 -2
- data/lib/net/connector/device/running_config.rb +2 -2
- data/lib/net/connector/device/tftp/receipt.rb +12 -3
- data/lib/net/connector/device/tftp.rb +7 -7
- data/lib/net/connector/device/topology.rb +4 -4
- data/lib/net/connector/engine/command.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +5 -2
- data/lib/net/connector/engine/execution.rb +25 -14
- data/lib/net/connector/engine/known_hosts.rb +112 -0
- data/lib/net/connector/engine/log.rb +10 -7
- data/lib/net/connector/engine/session.rb +117 -128
- data/lib/net/connector/engine/transport.rb +32 -7
- data/lib/net/connector/netdisco/backup_run.rb +132 -0
- data/lib/net/connector/netdisco/batch.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +139 -0
- data/lib/net/connector/netdisco/cli.rb +13 -26
- data/lib/net/connector/netdisco/client.rb +6 -6
- data/lib/net/connector/netdisco/config_file.rb +20 -19
- data/lib/net/connector/netdisco/connection.rb +38 -0
- data/lib/net/connector/netdisco/database_client.rb +2 -2
- data/lib/net/connector/netdisco/device.rb +11 -1
- data/lib/net/connector/netdisco/fleet.rb +68 -47
- data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
- data/lib/net/connector/netdisco/plan.rb +14 -9
- data/lib/net/connector/netdisco/planner.rb +18 -14
- data/lib/net/connector/netdisco/progress.rb +253 -0
- data/lib/net/connector/netdisco/report/files.rb +41 -0
- data/lib/net/connector/netdisco/report/text.rb +45 -0
- data/lib/net/connector/netdisco/report.rb +53 -31
- data/lib/net/connector/netdisco/result_store.rb +3 -1
- data/lib/net/connector/netdisco/rules.rb +10 -2
- data/lib/net/connector/netdisco/settings.rb +118 -68
- data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
- data/lib/net/connector/netdisco/tftp_history.rb +16 -0
- data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
- data/lib/net/connector/netdisco/worker.rb +8 -3
- data/lib/net/connector/netdisco.rb +10 -0
- data/lib/net/connector/storage/backup_lock.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +29 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c.rb +2 -1
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/running_config.rb +19 -0
- data/lib/net/connector/vendor/radware.rb +4 -3
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +2 -2
- metadata +42 -111
- /data/examples/{netdisco_database.yml → inventory_sql.yml} +0 -0
|
@@ -13,21 +13,23 @@ module Net
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# 生成本地或 TFTP 备份计划,明确记录每台未执行设备的原因。
|
|
16
|
-
def call(mode:, limit_per_vendor:)
|
|
16
|
+
def call(mode:, limit_per_vendor:, allow_fixed_name_reuse: false)
|
|
17
17
|
self.class.validate_limit!(limit_per_vendor)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
ready_tasks = @inventory.each_with_index.filter_map do |device, index|
|
|
19
|
+
[index, device] if device.ready?
|
|
20
|
+
end
|
|
21
|
+
selected_tasks = if limit_per_vendor
|
|
22
|
+
ready_tasks.group_by { |_index, device| device.vendor }.values.flat_map do |tasks|
|
|
23
|
+
tasks.sort_by { |_index, device| device.host }.first(limit_per_vendor)
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
ready_tasks
|
|
27
|
+
end
|
|
28
|
+
selected_indices = selected_tasks.to_h { |index, _device| [index, true] }
|
|
29
|
+
first_index_by_filename = {}
|
|
28
30
|
if mode == :tftp
|
|
29
31
|
# 先按采样顺序保留每个目标的首台设备,再按清单顺序输出结果。
|
|
30
|
-
|
|
32
|
+
selected_tasks.each { |index, device| first_index_by_filename[device.tftp_filename] ||= index }
|
|
31
33
|
end
|
|
32
34
|
outcomes = Array.new(@inventory.size)
|
|
33
35
|
ready = @inventory.each_with_index.filter_map do |device, index|
|
|
@@ -37,14 +39,16 @@ module Net
|
|
|
37
39
|
elsif !selected_indices.key?(index)
|
|
38
40
|
outcomes[index] = skipped(device, :sample_limit)
|
|
39
41
|
nil
|
|
40
|
-
elsif mode == :tftp &&
|
|
42
|
+
elsif mode == :tftp && first_index_by_filename.fetch(device.tftp_filename) != index &&
|
|
43
|
+
!(allow_fixed_name_reuse && device.vendor == :palo_alto)
|
|
41
44
|
outcomes[index] = skipped(device, :remote_filename_collision)
|
|
42
45
|
nil
|
|
43
46
|
else
|
|
44
47
|
[index, device].freeze
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
|
-
Plan.new(mode: mode, inventory: @inventory, ready: ready.freeze, outcomes: outcomes.freeze
|
|
50
|
+
Plan.new(mode: mode, inventory: @inventory, ready: ready.freeze, outcomes: outcomes.freeze,
|
|
51
|
+
allow_fixed_name_reuse: allow_fixed_name_reuse)
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
# 限制单厂商采样数,空值表示选择所有就绪设备。
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Netdisco
|
|
8
|
+
# 共享一个实例接收并发会话事件和任务回调;人类进度不混入 JSON 输出。
|
|
9
|
+
class Progress
|
|
10
|
+
def initialize(io: $stderr, enabled: true, verbose: false)
|
|
11
|
+
@io, @enabled, @verbose = io, enabled, verbose
|
|
12
|
+
@tty = io.respond_to?(:tty?) && io.tty?
|
|
13
|
+
@stages = {}
|
|
14
|
+
@task_started = {}
|
|
15
|
+
@failures = Hash.new(0)
|
|
16
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@succeeded = @failed = @unverified = 0
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@total = @completed = @active = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reading_inventory
|
|
23
|
+
write(nil, "正在读取 Netdisco 设备清单")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def plan(plan, concurrency:, limit_per_vendor: nil)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@total = plan.ready.size
|
|
29
|
+
@completed = @active = 0
|
|
30
|
+
@run_started = monotonic
|
|
31
|
+
mode = limit_per_vendor ? "抽样(每厂商最多 #{limit_per_vendor} 台)" : "全量"
|
|
32
|
+
emit(nil, "#{mode} | 清单 #{plan.inventory.size} 台;本次 #{@total} 台;跳过 #{plan.inventory.size - @total} 台;并发 #{concurrency}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start(device)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@active += 1
|
|
39
|
+
@stages[device.host] = "登录"
|
|
40
|
+
@task_started[device.host] = monotonic
|
|
41
|
+
@verbose ? emit(device.host, "开始备份(#{device.vendor})") : refresh
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 只显示已脱敏事件中的阶段和安全命令,不显示设备回显或异常原文。
|
|
46
|
+
def event(event)
|
|
47
|
+
return unless event.is_a?(Log::Event)
|
|
48
|
+
|
|
49
|
+
unless @verbose
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
host = event.fields[:host]
|
|
52
|
+
@stages[host] = "采集" if @stages.key?(host) && event.name == "login_complete" && event.fields[:status] == "ok"
|
|
53
|
+
refresh
|
|
54
|
+
end
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
fields = event.fields
|
|
59
|
+
message = case event.name
|
|
60
|
+
when "connect" then "正在连接并登录(#{fields[:protocol]})"
|
|
61
|
+
when "login_complete"
|
|
62
|
+
fields[:status] == "ok" ? "登录成功" : "登录失败(#{fields[:code]})"
|
|
63
|
+
when "connect_failed" then "连接失败(#{fields[:code]})"
|
|
64
|
+
when "command_start" then "执行命令:#{fields[:text]};等待响应"
|
|
65
|
+
when "command_complete"
|
|
66
|
+
fields[:status] == "response_received" ? "命令响应完成(#{fields[:duration_ms]} ms)" :
|
|
67
|
+
"命令失败(#{fields[:code]})"
|
|
68
|
+
when "operation_complete"
|
|
69
|
+
fields[:status] == "completed" ? "脚本处理完成" : "脚本处理失败(#{fields[:code]})"
|
|
70
|
+
end
|
|
71
|
+
write(fields[:host], message) if message
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def result(outcome)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@completed += 1
|
|
77
|
+
@active -= 1
|
|
78
|
+
@stages.delete(outcome.device.host)
|
|
79
|
+
@task_started.delete(outcome.device.host)
|
|
80
|
+
@failures[outcome.error_code || outcome.status] += 1 unless outcome.success?
|
|
81
|
+
outcome.success? ? @succeeded += 1 : @failed += 1
|
|
82
|
+
@unverified += 1 if outcome.status == :reported_uploaded && outcome.backup&.verification != :server_verified
|
|
83
|
+
message = case outcome.status
|
|
84
|
+
when :backed_up then "备份已保存(#{outcome.backup.bytes} 字节)"
|
|
85
|
+
when :reported_uploaded
|
|
86
|
+
outcome.backup&.verification == :server_verified ? "配置已归档(#{outcome.backup.server_bytes} 字节)" :
|
|
87
|
+
"设备报告上传完成;服务器文件尚未核验"
|
|
88
|
+
when :saved_with_error then "备份有产物但未完全成功(#{outcome.error_code})"
|
|
89
|
+
when :reported_with_error then "设备报告上传但收尾异常(#{outcome.error_code})"
|
|
90
|
+
else "未完成(#{outcome.error_code || outcome.status})"
|
|
91
|
+
end
|
|
92
|
+
hint = failure_hint(outcome.error_code)
|
|
93
|
+
message = "#{message};#{hint}" if hint
|
|
94
|
+
if @verbose || !outcome.success?
|
|
95
|
+
emit(outcome.device.host, "#{message};耗时 #{outcome.duration_ms} ms")
|
|
96
|
+
elsif !@tty && ((@completed % 25).zero? || @completed == @total)
|
|
97
|
+
emit(nil, status_line)
|
|
98
|
+
end
|
|
99
|
+
refresh
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finish(report)
|
|
104
|
+
summary = report.summary
|
|
105
|
+
failures = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
106
|
+
.group_by { |item| item.error_code || item.status }.transform_values(&:size)
|
|
107
|
+
unless failures.empty?
|
|
108
|
+
write(nil, "未完成分类:#{failures.sort_by { |code, count| [-count, code.to_s] }.map { |code, count| "#{code}=#{count}" }.join(",")}")
|
|
109
|
+
end
|
|
110
|
+
write(nil, "批次结束:成功 #{summary[:succeeded]},部分成功 #{summary[:partial]},失败 #{summary[:failed]},跳过 #{summary[:skipped]};耗时 #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at).round(1)} 秒;结果 #{report.policy_success? ? "完成" : "未完成"}")
|
|
111
|
+
unverified = summary[:verification]&.fetch(:unverified, 0) || 0
|
|
112
|
+
write(nil, "其中 #{unverified} 台仅设备报告上传完成,服务器文件尚未核验") if unverified.positive?
|
|
113
|
+
return unless !report.callback_errors.empty? || report.report_error
|
|
114
|
+
|
|
115
|
+
write(nil, "回调异常 #{report.callback_errors.size},报告错误 #{report.report_error || "无"}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 心跳只负责显示;退出、异常或中断时先唤醒并回收线程。
|
|
119
|
+
def with_updates
|
|
120
|
+
return yield unless @enabled && @tty && !@verbose
|
|
121
|
+
|
|
122
|
+
lock = Mutex.new
|
|
123
|
+
signal = ConditionVariable.new
|
|
124
|
+
stopped = false
|
|
125
|
+
worker = Thread.new do
|
|
126
|
+
loop do
|
|
127
|
+
stop = lock.synchronize do
|
|
128
|
+
signal.wait(lock, 1) unless stopped
|
|
129
|
+
stopped
|
|
130
|
+
end
|
|
131
|
+
break if stop
|
|
132
|
+
|
|
133
|
+
tick
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
yield
|
|
137
|
+
ensure
|
|
138
|
+
if worker
|
|
139
|
+
lock.synchronize { stopped = true; signal.broadcast }
|
|
140
|
+
worker.value
|
|
141
|
+
@mutex.synchronize { clear_refresh }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def tick
|
|
146
|
+
@mutex.synchronize { refresh(force: true) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def location(path)
|
|
150
|
+
write(nil, "报告:#{path}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def failure_hint(code)
|
|
156
|
+
case code
|
|
157
|
+
when :authentication_failed then "设备拒绝认证,请核对账号、密码或 AAA 策略"
|
|
158
|
+
when :authentication_error then "认证交互未完成,请核对认证方式或设备 AAA 日志"
|
|
159
|
+
when :host_key_untrusted then "主机密钥尚未信任,请确认指纹并登记"
|
|
160
|
+
when :host_key_changed then "已登记主机密钥发生变化,请核实设备身份"
|
|
161
|
+
when :login_timeout then "未在期限内获得登录提示,请检查 SSH 握手与登录交互"
|
|
162
|
+
when :connection_timeout then "SSH 连接或握手超时,请检查服务与网络"
|
|
163
|
+
when :device_error then "设备拒绝命令,请核对厂商映射和账号权限"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def status_line
|
|
168
|
+
percent = @total.positive? ? @completed * 100 / @total : 0
|
|
169
|
+
"#{@completed}/#{@total} #{percent}% | 成功 #{@succeeded} 未完成 #{@failed} | 登录 #{@stages.values.count("登录")} 采集 #{@stages.values.count("采集")} 排队 #{[@total - @completed - @active, 0].max}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
173
|
+
|
|
174
|
+
def duration(seconds)
|
|
175
|
+
total = seconds.round
|
|
176
|
+
format("%02d:%02d:%02d", total / 3600, total / 60 % 60, total % 60)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def timing_line
|
|
180
|
+
elapsed = [monotonic - (@run_started || @started_at), 0].max
|
|
181
|
+
rate = elapsed.positive? ? @completed / elapsed : 0
|
|
182
|
+
eta = if @completed == @total && @total.positive?
|
|
183
|
+
"00:00:00"
|
|
184
|
+
elsif @completed >= 5 && rate.positive?
|
|
185
|
+
"约 #{duration((@total - @completed) / rate)}"
|
|
186
|
+
else
|
|
187
|
+
"估算中"
|
|
188
|
+
end
|
|
189
|
+
line = "耗时 #{duration(elapsed)} | #{format("%.1f", rate)} 台/秒 | 剩余 #{eta}"
|
|
190
|
+
host, started = @task_started.min_by { |_key, value| value }
|
|
191
|
+
if started && monotonic - started >= 10
|
|
192
|
+
line += " | 最久 #{host} #{@stages[host]} #{(monotonic - started).floor}s"
|
|
193
|
+
end
|
|
194
|
+
line
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def refresh(force: false)
|
|
198
|
+
return unless @enabled && @tty && !@verbose
|
|
199
|
+
|
|
200
|
+
now = monotonic
|
|
201
|
+
return if !force && @refreshing && @completed < @total && @last_refresh && now - @last_refresh < 0.2
|
|
202
|
+
|
|
203
|
+
@last_refresh = now
|
|
204
|
+
divider = fit_terminal("-" * 100)
|
|
205
|
+
lines = [divider, fit_terminal(status_line), fit_terminal(timing_line), divider]
|
|
206
|
+
return if @refreshing && @last_status == lines
|
|
207
|
+
|
|
208
|
+
clear_refresh
|
|
209
|
+
@last_status = lines
|
|
210
|
+
@io.write(lines.join("\n"))
|
|
211
|
+
@io.flush
|
|
212
|
+
@refreshing = true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 留一列避免终端自动换行,窄窗口也不会留下旧进度行。
|
|
216
|
+
def fit_terminal(line)
|
|
217
|
+
columns = @io.respond_to?(:winsize) ? @io.winsize.last : 120
|
|
218
|
+
columns = 120 unless columns.positive?
|
|
219
|
+
width = 0
|
|
220
|
+
text = line.gsub(/[[:cntrl:]]/, " ")
|
|
221
|
+
text.each_char.take_while do |character|
|
|
222
|
+
width += character.ord > 255 ? 2 : 1
|
|
223
|
+
width < columns
|
|
224
|
+
end.join
|
|
225
|
+
rescue IOError, SystemCallError
|
|
226
|
+
line
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def clear_refresh
|
|
230
|
+
return unless @refreshing
|
|
231
|
+
|
|
232
|
+
@io.write("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
|
|
233
|
+
@refreshing = false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def write(host, message)
|
|
237
|
+
@mutex.synchronize { emit(host, message) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def emit(host, message)
|
|
241
|
+
return unless @enabled
|
|
242
|
+
|
|
243
|
+
percent = @total.positive? ? " #{(@completed * 100.0 / @total).floor}%" : ""
|
|
244
|
+
clear_refresh
|
|
245
|
+
line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} 执行中 #{@active}] [#{host || "批次"}] #{message}"
|
|
246
|
+
line = "[#{Time.now.strftime("%H:%M:%S")}] #{host ? "#{host} " : ""}#{message}" unless @verbose
|
|
247
|
+
@io.write(line.gsub(/[[:cntrl:]]/, " ") + "\n")
|
|
248
|
+
@io.flush
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "text"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
class Report
|
|
10
|
+
# 示例与批次入口共享唯一摘要结构;保存结果先于终端成功提示。
|
|
11
|
+
class Files
|
|
12
|
+
def self.write(report, directory:, plan:, concurrency:)
|
|
13
|
+
destination = File.join(directory, "summary.json")
|
|
14
|
+
finalized = report.with(report_location: destination)
|
|
15
|
+
Text.write(directory: directory, report: finalized, plan: plan, concurrency: concurrency)
|
|
16
|
+
Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
|
|
17
|
+
finalized
|
|
18
|
+
rescue StandardError => error
|
|
19
|
+
location = if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == destination
|
|
20
|
+
destination
|
|
21
|
+
end
|
|
22
|
+
failed = report.with_report_error(error, location: location)
|
|
23
|
+
begin
|
|
24
|
+
saved = failed.with(report_location: destination)
|
|
25
|
+
Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
|
|
26
|
+
failed = saved
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# JSON 仍无法保存时,返回带有已知提交位置的失败报告。
|
|
29
|
+
end
|
|
30
|
+
begin
|
|
31
|
+
Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
# 文本输出不能覆盖首次保存失败。
|
|
34
|
+
end
|
|
35
|
+
failed
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../storage/private_file"
|
|
4
|
+
require_relative "../report"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
class Report
|
|
10
|
+
# 将批次结果写为人类可读的文本摘要。
|
|
11
|
+
class Text
|
|
12
|
+
def self.write(directory:, report:, plan:, concurrency:)
|
|
13
|
+
summary = report.summary
|
|
14
|
+
failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
15
|
+
groups = failed.group_by { |item| item.error_code || item.status }
|
|
16
|
+
lines = ["网络设备备份报告", "=" * 72,
|
|
17
|
+
"开始时间:#{report.started_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
|
|
18
|
+
"结束时间:#{report.finished_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
|
|
19
|
+
"清单:#{plan.inventory.size} 台;本次:#{plan.ready.size} 台;并发:#{concurrency}",
|
|
20
|
+
"成功:#{summary[:succeeded]};部分成功:#{summary[:partial]};失败:#{summary[:failed]};跳过:#{summary[:skipped]}",
|
|
21
|
+
"耗时:#{(report.duration_ms.to_f / 1000).round(1)} 秒;批次状态:#{summary[:status]}",
|
|
22
|
+
"成功策略:#{report.policy};策略通过:#{report.policy_success?}",
|
|
23
|
+
"回调异常:#{report.callback_errors.size};报告错误:#{report.report_error || "无"}",
|
|
24
|
+
"状态计数:#{report.counts.map { |key, value| "#{key}=#{value}" }.join(",")}",
|
|
25
|
+
"未完成分类:#{groups.empty? ? "无" : groups.map { |key, items| "#{key}=#{items.size}" }.join(",")}",
|
|
26
|
+
"-" * 72, "未完成设备:"]
|
|
27
|
+
lines.concat(failure_lines(failed))
|
|
28
|
+
lines << "无" if failed.empty?
|
|
29
|
+
lines << "TFTP 成功仅表示设备报告上传完成;服务器文件核验结果请查看 summary.json。" if report.mode == :tftp
|
|
30
|
+
lines += ["=" * 72, "备份目录:#{directory}", "完整明细:#{File.join(directory, "summary.json")}"]
|
|
31
|
+
Net::Connector::Storage::PrivateFile.write(File.join(directory, "summary.txt"), lines.join("\n") + "\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.failure_lines(failed)
|
|
35
|
+
failed.map do |item|
|
|
36
|
+
label = item.device.name.to_s.gsub(/[[:cntrl:]]/, " ")
|
|
37
|
+
"#{item.device.host} | #{label} | #{item.status} | #{item.error_code || "无错误码"} | #{item.duration_ms} ms"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :failure_lines
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -9,21 +9,21 @@ module Net
|
|
|
9
9
|
# 唯一的批量报告契约,分开呈现成功策略、清单覆盖和受控诊断。
|
|
10
10
|
class Report
|
|
11
11
|
extend Forwardable
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
private_constant :
|
|
12
|
+
SUCCESS_STATUSES = %i[backed_up reported_uploaded].freeze
|
|
13
|
+
ATTEMPTED_STATUSES = [*SUCCESS_STATUSES, :failed, :saved_with_error, :reported_with_error].freeze
|
|
14
|
+
IGNORABLE_STATUSES = %i[filtered sample_limit].freeze
|
|
15
|
+
private_constant :SUCCESS_STATUSES, :ATTEMPTED_STATUSES, :IGNORABLE_STATUSES
|
|
16
16
|
|
|
17
|
-
attr_reader :batch, :policy, :duration_ms, :report_diagnostic
|
|
17
|
+
attr_reader :batch, :policy, :duration_ms, :report_diagnostic, :log_directory
|
|
18
18
|
def_delegators :@batch, *Batch.members, :success?, :status, :counts
|
|
19
19
|
|
|
20
20
|
def self.validate_policy!(policy)
|
|
21
|
-
raise ArgumentError, "success_policy must be strict or
|
|
21
|
+
raise ArgumentError, "success_policy must be strict, selected or verified" unless %i[strict selected verified].include?(policy)
|
|
22
22
|
|
|
23
23
|
policy
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil)
|
|
26
|
+
def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil, log_directory: nil)
|
|
27
27
|
self.class.validate_policy!(policy)
|
|
28
28
|
raise ArgumentError, "report requires a Batch" unless batch.instance_of?(Batch)
|
|
29
29
|
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
@@ -33,6 +33,8 @@ module Net
|
|
|
33
33
|
raise ArgumentError, "report_diagnostic must be a Diagnostic"
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
raise ArgumentError, "verified policy requires TFTP mode" if policy == :verified && batch.mode != :tftp
|
|
37
|
+
@log_directory = log_directory&.dup&.freeze
|
|
36
38
|
@batch, @policy, @duration_ms, @report_diagnostic = batch, policy, duration_ms, report_diagnostic
|
|
37
39
|
freeze
|
|
38
40
|
end
|
|
@@ -42,32 +44,36 @@ module Net
|
|
|
42
44
|
return batch.success? if policy == :strict
|
|
43
45
|
return false unless callback_errors.empty? && report_error.nil?
|
|
44
46
|
|
|
45
|
-
outcomes.any? { |
|
|
46
|
-
|
|
47
|
+
return false if policy == :verified && outcomes.any? { |outcome| outcome.success? && !server_verified?(outcome) }
|
|
48
|
+
|
|
49
|
+
outcomes.any? { |outcome| SUCCESS_STATUSES.include?(outcome.status) } &&
|
|
50
|
+
outcomes.all? { |outcome| SUCCESS_STATUSES.include?(outcome.status) || IGNORABLE_STATUSES.include?(outcome.status) }
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def with(**attributes)
|
|
50
54
|
self.class.new(batch.with(**attributes), policy: policy, duration_ms: duration_ms,
|
|
51
|
-
report_diagnostic: report_diagnostic)
|
|
55
|
+
report_diagnostic: report_diagnostic, log_directory: log_directory)
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def with_report_error(error, location: nil)
|
|
55
59
|
diagnostic = Diagnostic.from(error, phase: :report)
|
|
56
60
|
self.class.new(batch.with(report_location: location, report_error: diagnostic.error_type),
|
|
57
|
-
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic)
|
|
61
|
+
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic, log_directory: log_directory)
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
# 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
|
|
61
65
|
def summary
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
batch_data = batch_summary
|
|
67
|
+
batch_data.merge(
|
|
64
68
|
schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
|
|
65
|
-
coverage: { complete: !outcomes.empty? && outcomes.all? { |
|
|
66
|
-
attempted: outcomes.count { |
|
|
67
|
-
skipped: outcomes.count { |
|
|
68
|
-
devices: device_summaries(
|
|
69
|
+
coverage: { complete: !outcomes.empty? && outcomes.all? { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
70
|
+
attempted: outcomes.count { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
71
|
+
skipped: outcomes.count { |outcome| !ATTEMPTED_STATUSES.include?(outcome.status) } },
|
|
72
|
+
devices: device_summaries(batch_data.fetch(:devices)),
|
|
69
73
|
callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
|
|
70
74
|
report_location: report_location, report_error: ErrorMetadata.type(report_error),
|
|
75
|
+
verification: mode == :tftp ? { verified: outcomes.count { |outcome| server_verified?(outcome) },
|
|
76
|
+
unverified: outcomes.count { |outcome| outcome.backup.is_a?(TftpReceipt) && !server_verified?(outcome) } } : nil,
|
|
71
77
|
report_diagnostic: report_diagnostic&.to_h
|
|
72
78
|
)
|
|
73
79
|
end
|
|
@@ -83,28 +89,44 @@ module Net
|
|
|
83
89
|
total: outcomes.size,
|
|
84
90
|
succeeded: outcomes.count(&:success?),
|
|
85
91
|
partial: outcomes.count(&:partial?),
|
|
86
|
-
failed: outcomes.count { |
|
|
92
|
+
failed: outcomes.count { |outcome| outcome.status == :failed },
|
|
87
93
|
skipped: outcomes.count(&:skipped?),
|
|
88
94
|
counts: counts,
|
|
89
95
|
callback_errors: callback_errors,
|
|
90
|
-
devices: outcomes.map do |
|
|
91
|
-
{ host:
|
|
92
|
-
vendor:
|
|
93
|
-
status:
|
|
94
|
-
bytes:
|
|
95
|
-
sha256:
|
|
96
|
-
change:
|
|
97
|
-
previous_sha256:
|
|
98
|
-
started_at:
|
|
99
|
-
duration_ms:
|
|
100
|
-
error_code:
|
|
96
|
+
devices: outcomes.map do |outcome|
|
|
97
|
+
{ host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
98
|
+
vendor: outcome.device.vendor,
|
|
99
|
+
status: outcome.status, path: outcome.backup&.path,
|
|
100
|
+
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
101
|
+
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
102
|
+
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
103
|
+
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
104
|
+
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
105
|
+
duration_ms: outcome.duration_ms,
|
|
106
|
+
error_code: outcome.error_code, error_type: outcome.error_type }
|
|
101
107
|
end
|
|
102
108
|
}
|
|
103
109
|
end
|
|
104
110
|
|
|
111
|
+
def server_verified?(outcome)
|
|
112
|
+
outcome.backup.is_a?(TftpReceipt) && outcome.backup.verification == :server_verified
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tftp_summary(outcome)
|
|
116
|
+
receipt = outcome.backup if outcome.backup.is_a?(TftpReceipt)
|
|
117
|
+
{ remote_path: receipt&.path, server: receipt&.server,
|
|
118
|
+
configuration_kind: receipt&.configuration_kind, source_file: receipt&.source_file,
|
|
119
|
+
format: receipt&.format, verification: receipt&.verification,
|
|
120
|
+
server_file_verified: server_verified?(outcome), server_sha256: receipt&.server_sha256,
|
|
121
|
+
local_file: receipt&.local_path, server_archive_file: receipt&.archive_path,
|
|
122
|
+
bytes: receipt&.server_bytes, sha256: receipt&.server_sha256 }
|
|
123
|
+
end
|
|
124
|
+
|
|
105
125
|
def device_summaries(entries)
|
|
106
|
-
entries.zip(outcomes).map do |entry,
|
|
107
|
-
diagnostic =
|
|
126
|
+
entries.zip(outcomes).map do |entry, outcome|
|
|
127
|
+
diagnostic = outcome.diagnostic || Diagnostic.new(error_code: outcome.error_code, error_type: outcome.error_type)
|
|
128
|
+
entry = entry.merge(tftp_summary(outcome)) if mode == :tftp
|
|
129
|
+
entry[:session_log] = File.join(log_directory, "#{outcome.device.host.tr(":", "_")}.log") if log_directory && outcome.device.host && outcome.started_at
|
|
108
130
|
entry.merge(error_code: diagnostic.error_code, error_type: diagnostic.error_type,
|
|
109
131
|
diagnostic: diagnostic.to_h.except(:error_code, :error_type))
|
|
110
132
|
end
|
|
@@ -10,7 +10,7 @@ module Net
|
|
|
10
10
|
module Netdisco
|
|
11
11
|
module ResultStore
|
|
12
12
|
# 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
|
|
13
|
-
class
|
|
13
|
+
class Json
|
|
14
14
|
# 以临时文件和重命名原子写入私有 JSON 报告。
|
|
15
15
|
def write(batch, directory:)
|
|
16
16
|
destination = File.expand_path(directory)
|
|
@@ -21,6 +21,8 @@ module Net
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
Text = Json # Compatibility with the former name of the JSON writer.
|
|
25
|
+
|
|
24
26
|
# 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
|
|
25
27
|
# 仓储须实现 create!,本项目不负责数据库表结构。
|
|
26
28
|
class Database
|
|
@@ -109,11 +109,14 @@ module Net
|
|
|
109
109
|
return match[:connector] if match
|
|
110
110
|
return @vendor_overrides[label] if @vendor_overrides.key?(label)
|
|
111
111
|
|
|
112
|
+
# 厂商标签可能陈旧;仅在操作系统和带品牌型号同时确认时纠正。
|
|
113
|
+
return h3c_connector(os, model) if os == "comware" && model.match?(/\Ah3c\s+\S/)
|
|
114
|
+
|
|
112
115
|
case label
|
|
113
|
-
when /\bh3c\b/ then (os
|
|
116
|
+
when /\bh3c\b/ then h3c_connector(os, model)
|
|
114
117
|
when /\bcisco\b/
|
|
115
118
|
return :cisco_nxos if os.match?(/nx\s*os|nexus/) || model.match?(/\bnexus\b|\bn\s*[3579]k\b/)
|
|
116
|
-
|
|
119
|
+
:cisco_ios if os.match?(/\bios\b|\bios\s*xe\b/)
|
|
117
120
|
when /\bradware\b/ then :radware
|
|
118
121
|
when /palo\s*alto|paloalto/ then :palo_alto
|
|
119
122
|
when /\bhuawei\b/ then :huawei
|
|
@@ -132,6 +135,11 @@ module Net
|
|
|
132
135
|
|
|
133
136
|
private
|
|
134
137
|
|
|
138
|
+
def h3c_connector(os, model)
|
|
139
|
+
wireless = os.match?(/wireless|wlan/) || model.match?(/\A(?:h3c\s+)?(?:wx|ac)\s*\d/)
|
|
140
|
+
wireless ? :h3c_wireless : :h3c
|
|
141
|
+
end
|
|
142
|
+
|
|
135
143
|
# 校验并标准化管理地址列表。
|
|
136
144
|
def string_list(values)
|
|
137
145
|
raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
|