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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +112 -50
  5. data/docs/VERIFICATION.md +42 -1
  6. data/docs/architecture.md +15 -2
  7. data/examples/backup.yml +40 -0
  8. data/lib/net/connector/device/base.rb +30 -38
  9. data/lib/net/connector/device/profile/builder.rb +8 -8
  10. data/lib/net/connector/device/profile.rb +2 -2
  11. data/lib/net/connector/device/running_config.rb +2 -2
  12. data/lib/net/connector/device/tftp/receipt.rb +12 -3
  13. data/lib/net/connector/device/tftp.rb +7 -7
  14. data/lib/net/connector/device/topology.rb +4 -4
  15. data/lib/net/connector/engine/command.rb +2 -2
  16. data/lib/net/connector/engine/configuration.rb +5 -2
  17. data/lib/net/connector/engine/execution.rb +25 -14
  18. data/lib/net/connector/engine/known_hosts.rb +112 -0
  19. data/lib/net/connector/engine/log.rb +10 -7
  20. data/lib/net/connector/engine/session.rb +117 -128
  21. data/lib/net/connector/engine/transport.rb +32 -7
  22. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  23. data/lib/net/connector/netdisco/batch.rb +1 -1
  24. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  25. data/lib/net/connector/netdisco/cli.rb +13 -26
  26. data/lib/net/connector/netdisco/client.rb +6 -6
  27. data/lib/net/connector/netdisco/config_file.rb +20 -19
  28. data/lib/net/connector/netdisco/connection.rb +38 -0
  29. data/lib/net/connector/netdisco/database_client.rb +2 -2
  30. data/lib/net/connector/netdisco/device.rb +11 -1
  31. data/lib/net/connector/netdisco/fleet.rb +68 -47
  32. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  33. data/lib/net/connector/netdisco/plan.rb +14 -9
  34. data/lib/net/connector/netdisco/planner.rb +18 -14
  35. data/lib/net/connector/netdisco/progress.rb +253 -0
  36. data/lib/net/connector/netdisco/report/files.rb +41 -0
  37. data/lib/net/connector/netdisco/report/text.rb +45 -0
  38. data/lib/net/connector/netdisco/report.rb +53 -31
  39. data/lib/net/connector/netdisco/result_store.rb +3 -1
  40. data/lib/net/connector/netdisco/rules.rb +10 -2
  41. data/lib/net/connector/netdisco/settings.rb +118 -68
  42. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  43. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  44. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  45. data/lib/net/connector/netdisco/worker.rb +8 -3
  46. data/lib/net/connector/netdisco.rb +10 -0
  47. data/lib/net/connector/storage/backup_lock.rb +1 -1
  48. data/lib/net/connector/storage/batch_directory.rb +29 -0
  49. data/lib/net/connector/storage.rb +1 -0
  50. data/lib/net/connector/vendor/h3c.rb +2 -1
  51. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  53. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  54. data/lib/net/connector/vendor/radware.rb +4 -3
  55. data/lib/net/connector/version.rb +1 -1
  56. data/lib/net/connector.rb +2 -2
  57. metadata +42 -111
  58. /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
- candidates = @inventory.each_with_index.select { |device, _index| device.ready? }
19
- selected = if limit_per_vendor
20
- candidates.group_by { |device, _index| device.vendor }.values.flat_map do |entries|
21
- entries.sort_by { |device, _index| device.host }.first(limit_per_vendor)
22
- end
23
- else
24
- candidates
25
- end
26
- selected_indices = selected.to_h { |_device, index| [index, true] }
27
- filenames = {}
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
- selected.each { |device, index| filenames[device.tftp_filename] ||= index }
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 && filenames.fetch(device.tftp_filename) != index
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
- SUCCESS = %i[backed_up reported_uploaded].freeze
13
- ATTEMPTED = [*SUCCESS, :failed, :saved_with_error, :reported_with_error].freeze
14
- IGNORABLE = %i[filtered sample_limit].freeze
15
- private_constant :SUCCESS, :ATTEMPTED, :IGNORABLE
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 selected" unless %i[strict selected].include?(policy)
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? { |item| SUCCESS.include?(item.status) } &&
46
- outcomes.all? { |item| SUCCESS.include?(item.status) || IGNORABLE.include?(item.status) }
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
- data = batch_summary
63
- data.merge(
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? { |item| ATTEMPTED.include?(item.status) },
66
- attempted: outcomes.count { |item| ATTEMPTED.include?(item.status) },
67
- skipped: outcomes.count { |item| !ATTEMPTED.include?(item.status) } },
68
- devices: device_summaries(data.fetch(:devices)),
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 { |item| item.status == :failed },
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 |item|
91
- { host: item.device.host || item.device.source_ip, name: item.device.name,
92
- vendor: item.device.vendor,
93
- status: item.status, path: item.backup&.path,
94
- bytes: item.backup.is_a?(Backup) ? item.backup.bytes : nil,
95
- sha256: item.backup.is_a?(Backup) ? item.backup.sha256 : nil,
96
- change: item.backup.is_a?(Backup) ? item.backup.change : nil,
97
- previous_sha256: item.backup.is_a?(Backup) ? item.backup.previous_sha256 : nil,
98
- started_at: item.started_at&.iso8601, finished_at: item.finished_at&.iso8601,
99
- duration_ms: item.duration_ms,
100
- error_code: item.error_code, error_type: item.error_type }
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, item|
107
- diagnostic = item.diagnostic || Diagnostic.new(error_code: item.error_code, error_type: item.error_type)
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 Text
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.match?(/wireless|wlan/) || model.match?(/\A(?:wx|ac)\s*\d/)) ? :h3c_wireless : :h3c
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
- return :cisco_ios if os.match?(/\bios\b|\bios\s*xe\b/)
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)