net-connector 0.6.0 → 0.7.1
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 +58 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +112 -50
- data/docs/RELEASING.md +11 -8
- 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 +52 -11
- 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
|
@@ -23,21 +23,21 @@ module Net
|
|
|
23
23
|
# 在追加正文前计数;不能靠 Content-Length 或 JSON 解析后的大小限制内存。
|
|
24
24
|
def consume_bytes(size, response_bytes:)
|
|
25
25
|
remaining
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
validate_limit!(:max_response_bytes, response_bytes + size)
|
|
27
|
+
validate_limit!(:max_inventory_bytes, @bytes + size)
|
|
28
28
|
@bytes += size
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# 去重前的记录同样占用清单内存;兼容查询也使用这个累计计数。
|
|
32
32
|
def consume_devices(size)
|
|
33
33
|
remaining
|
|
34
|
-
|
|
34
|
+
validate_limit!(:max_devices, @devices + size)
|
|
35
35
|
@devices += size
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def validate_limit!(name, value)
|
|
41
41
|
return if value <= @options.fetch(name)
|
|
42
42
|
|
|
43
43
|
raise Client::Error.new("Netdisco inventory exceeded #{name}", code: name), cause: nil
|
|
@@ -4,15 +4,18 @@ module Net
|
|
|
4
4
|
module Connector
|
|
5
5
|
module Netdisco
|
|
6
6
|
# 固定一份清单上的选择结果与跳过原因,供预览和执行复用。
|
|
7
|
-
Plan = Data.define(:mode, :inventory, :ready, :outcomes) do
|
|
7
|
+
Plan = Data.define(:mode, :inventory, :ready, :outcomes, :allow_fixed_name_reuse) do
|
|
8
8
|
# Data 只冻结对象自身;这里复制并冻结可能由调用方修改的计划容器。
|
|
9
|
-
def initialize(mode:, inventory:, ready:, outcomes:)
|
|
9
|
+
def initialize(mode:, inventory:, ready:, outcomes:, allow_fixed_name_reuse: false)
|
|
10
10
|
unless inventory.is_a?(Array) && ready.is_a?(Array) && outcomes.is_a?(Array)
|
|
11
11
|
raise ArgumentError, "plan inventory, ready tasks, and outcomes must be Arrays"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
tasks = ready.map { |task| task.is_a?(Array) ? task.dup.freeze : task }.freeze
|
|
15
|
-
|
|
15
|
+
raise ArgumentError, "allow_fixed_name_reuse must be boolean" unless [true, false].include?(allow_fixed_name_reuse)
|
|
16
|
+
|
|
17
|
+
super(mode: mode, inventory: inventory.dup.freeze, ready: tasks, outcomes: outcomes.dup.freeze,
|
|
18
|
+
allow_fixed_name_reuse: allow_fixed_name_reuse)
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# 按清单顺序返回选中的设备。
|
|
@@ -26,7 +29,7 @@ module Net
|
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
selected_indices = {}
|
|
29
|
-
|
|
32
|
+
selected_filenames = {}
|
|
30
33
|
previous_index = -1
|
|
31
34
|
ready.each do |task|
|
|
32
35
|
unless task.is_a?(Array) && task.size == 2 && task.first.is_a?(Integer)
|
|
@@ -39,9 +42,11 @@ module Net
|
|
|
39
42
|
end
|
|
40
43
|
if mode == :tftp
|
|
41
44
|
filename = device.tftp_filename
|
|
42
|
-
|
|
45
|
+
if selected_filenames.key?(filename) && !(allow_fixed_name_reuse && device.vendor == :palo_alto)
|
|
46
|
+
raise ArgumentError, "plan cannot upload multiple devices to one TFTP filename"
|
|
47
|
+
end
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
selected_filenames[filename] = true
|
|
45
50
|
end
|
|
46
51
|
selected_indices[index] = true
|
|
47
52
|
previous_index = index
|
|
@@ -51,7 +56,7 @@ module Net
|
|
|
51
56
|
result = outcomes[index]
|
|
52
57
|
next if selected_indices[index] && result.nil?
|
|
53
58
|
|
|
54
|
-
unless !selected_indices[index] && valid_skipped_outcome?(result, device,
|
|
59
|
+
unless !selected_indices[index] && valid_skipped_outcome?(result, device, selected_filenames)
|
|
55
60
|
raise ArgumentError, "plan outcome does not match its inventory slot"
|
|
56
61
|
end
|
|
57
62
|
end
|
|
@@ -61,7 +66,7 @@ module Net
|
|
|
61
66
|
private
|
|
62
67
|
|
|
63
68
|
# 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
|
|
64
|
-
def valid_skipped_outcome?(result, device,
|
|
69
|
+
def valid_skipped_outcome?(result, device, selected_filenames)
|
|
65
70
|
return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
|
|
66
71
|
result.error_code.nil? && result.error_type.nil? &&
|
|
67
72
|
result.started_at.nil? && result.finished_at.nil?
|
|
@@ -69,7 +74,7 @@ module Net
|
|
|
69
74
|
return result.status == device.issue unless device.ready?
|
|
70
75
|
|
|
71
76
|
result.status == :sample_limit ||
|
|
72
|
-
(mode == :tftp && result.status == :remote_filename_collision &&
|
|
77
|
+
(mode == :tftp && result.status == :remote_filename_collision && selected_filenames.key?(device.tftp_filename))
|
|
73
78
|
end
|
|
74
79
|
end
|
|
75
80
|
end
|
|
@@ -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
|