net-connector 0.4.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 +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- metadata +517 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
# 基于已验证的清单快照组织有上限的并发备份。
|
|
10
|
+
class Fleet
|
|
11
|
+
# 组装清单客户端、规则、凭据与结果存储。
|
|
12
|
+
def initialize(client: nil, settings: Settings.new, rules: nil, credentials: nil, connector_factory: nil,
|
|
13
|
+
result_store: ResultStore::Text.new)
|
|
14
|
+
@settings = settings
|
|
15
|
+
@client = client || settings.client
|
|
16
|
+
@rules = rules || settings.rules
|
|
17
|
+
@credentials = credentials || settings.method(:credentials_for)
|
|
18
|
+
@connector_factory = connector_factory || ->(device, connection_settings) { device.connector(**connection_settings) }
|
|
19
|
+
@result_store = result_store
|
|
20
|
+
raise ArgumentError, "credentials must respond to call" unless @credentials.respond_to?(:call)
|
|
21
|
+
raise ArgumentError, "result_store must respond to write" if result_store && !result_store.respond_to?(:write)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 拉取并标记重复管理地址的设备清单。
|
|
25
|
+
def devices
|
|
26
|
+
rows = @client.devices
|
|
27
|
+
raise Client::Error, "Netdisco returned an invalid device inventory" unless rows.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
devices = rows.map { |row| Device.from_row(row, rules: @rules) }
|
|
30
|
+
duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
|
|
31
|
+
devices.map do |device|
|
|
32
|
+
duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device
|
|
33
|
+
end.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 规划本地配置备份的设备集合。
|
|
37
|
+
def plan_backup(limit_per_vendor: nil)
|
|
38
|
+
Planner.new(devices).call(mode: :backup, limit_per_vendor: limit_per_vendor)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 规划 TFTP 配置备份的设备集合。
|
|
42
|
+
def plan_tftp_backup(limit_per_vendor: 5)
|
|
43
|
+
Planner.new(devices).call(mode: :tftp, limit_per_vendor: limit_per_vendor)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 先拉取完整清单,再并发采集并保存设备配置。
|
|
47
|
+
def backup_all(directory: @settings.backup_directory, concurrency: @settings.concurrency,
|
|
48
|
+
limit_per_vendor: nil, plan: nil, on_start: nil, on_result: nil, on_change: nil)
|
|
49
|
+
raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
|
|
50
|
+
Planner.validate_limit!(limit_per_vendor)
|
|
51
|
+
validate_callback!(on_result, :on_result)
|
|
52
|
+
validate_callback!(on_start, :on_start)
|
|
53
|
+
validate_callback!(on_change, :on_change)
|
|
54
|
+
|
|
55
|
+
plan ||= plan_backup(limit_per_vendor: limit_per_vendor)
|
|
56
|
+
validate_plan!(plan, :backup)
|
|
57
|
+
callbacks = [on_result]
|
|
58
|
+
callbacks << ->(item) { on_change.call(item) if item.backup.is_a?(Backup) && item.backup.changed? } if on_change
|
|
59
|
+
target_directory = File.expand_path(directory)
|
|
60
|
+
run_batch(:backup, plan, concurrency: concurrency, report_directory: directory,
|
|
61
|
+
output_directory: target_directory, on_start: on_start, on_result: callbacks.compact) do |device, log_directory|
|
|
62
|
+
backup_one(device, target_directory, log_directory)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 让设备主动导出配置,避免 PAN-OS 固定远端文件名在同批任务中冲突。
|
|
67
|
+
def tftp_backup_all(server:, source_files: {}, concurrency: @settings.concurrency,
|
|
68
|
+
limit_per_vendor: 5, vrfs: {}, on_start: nil, on_result: nil, plan: nil,
|
|
69
|
+
report_directory: @settings.backup_directory)
|
|
70
|
+
TftpTarget.new(host: server, path: "preflight.cfg")
|
|
71
|
+
raise ArgumentError, "source_files must be a Hash" unless source_files.is_a?(Hash)
|
|
72
|
+
source_files.each_value { |source_file| TftpTarget.validate_source_file!(source_file) }
|
|
73
|
+
validate_vrfs!(vrfs)
|
|
74
|
+
Planner.validate_limit!(limit_per_vendor)
|
|
75
|
+
validate_callback!(on_result, :on_result)
|
|
76
|
+
validate_callback!(on_start, :on_start)
|
|
77
|
+
|
|
78
|
+
plan ||= plan_tftp_backup(limit_per_vendor: limit_per_vendor)
|
|
79
|
+
validate_plan!(plan, :tftp)
|
|
80
|
+
run_batch(:tftp, plan, concurrency: concurrency, report_directory: report_directory,
|
|
81
|
+
on_start: on_start, on_result: on_result) do |device, log_directory|
|
|
82
|
+
tftp_backup_one(device, server, source_files, log_directory, vrfs)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# 统一组织设备任务、日志目录与报告;Worker 保证单台设备失败不影响其他设备。
|
|
89
|
+
def run_batch(mode, plan, concurrency:, report_directory:, on_start:, on_result:, output_directory: nil)
|
|
90
|
+
worker = Worker.new(concurrency: concurrency)
|
|
91
|
+
started_at = Time.now.utc
|
|
92
|
+
outcomes = plan.outcomes.dup
|
|
93
|
+
FileUtils.mkdir_p(output_directory, mode: 0o700) if output_directory && !plan.ready.empty?
|
|
94
|
+
log_directory = @settings.log_directory
|
|
95
|
+
FileUtils.mkdir_p(log_directory, mode: 0o700) if log_directory && !plan.ready.empty?
|
|
96
|
+
callback_errors = worker.run(plan.ready, outcomes: outcomes, on_start: on_start, on_result: on_result,
|
|
97
|
+
on_error: ->(device, error) { outcome(device, :failed, error: error) }) do |device|
|
|
98
|
+
yield device, log_directory
|
|
99
|
+
end
|
|
100
|
+
batch = Batch.new(mode: mode, outcomes: outcomes.freeze, started_at: started_at,
|
|
101
|
+
finished_at: Time.now.utc, callback_errors: callback_errors,
|
|
102
|
+
report_location: nil, report_error: nil)
|
|
103
|
+
save_report(batch, directory: report_directory)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 校验批量任务回调接口。
|
|
107
|
+
def validate_callback!(callback, name)
|
|
108
|
+
raise ArgumentError, "#{name} must respond to call" if callback && !callback.respond_to?(:call)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 保存批量报告,记录写入失败而不覆盖设备结果。
|
|
112
|
+
def save_report(batch, directory:)
|
|
113
|
+
return batch unless @result_store
|
|
114
|
+
|
|
115
|
+
batch.with(report_location: @result_store.write(batch, directory: directory))
|
|
116
|
+
rescue StandardError => error
|
|
117
|
+
batch.with(report_error: error.class.name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# 校验厂商 VRF 映射和名称。
|
|
121
|
+
def validate_vrfs!(vrfs)
|
|
122
|
+
unless vrfs.is_a?(Hash) && vrfs.all? { |vendor, value|
|
|
123
|
+
%i[cisco_nxos hillstone].include?(vendor) && value.is_a?(String) &&
|
|
124
|
+
value.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/)
|
|
125
|
+
}
|
|
126
|
+
raise ArgumentError, "vrfs must map supported vendor names to safe VRF names"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 确认传入计划属于当前备份模式。
|
|
131
|
+
def validate_plan!(plan, mode)
|
|
132
|
+
raise ArgumentError, "plan must be a #{mode} Netdisco plan" unless plan.is_a?(Plan) && plan.mode == mode
|
|
133
|
+
|
|
134
|
+
inventory = plan.inventory
|
|
135
|
+
ready = plan.ready
|
|
136
|
+
outcomes = plan.outcomes
|
|
137
|
+
unless inventory.is_a?(Array) && inventory.all?(Device) && ready.is_a?(Array) &&
|
|
138
|
+
outcomes.is_a?(Array) && outcomes.size == inventory.size
|
|
139
|
+
raise ArgumentError, "plan inventory and outcomes must have the same device slots"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
selected = {}
|
|
143
|
+
previous_index = -1
|
|
144
|
+
ready.each do |task|
|
|
145
|
+
unless task.is_a?(Array) && task.size == 2 && task.first.is_a?(Integer)
|
|
146
|
+
raise ArgumentError, "plan contains an invalid device task"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
index, device = task
|
|
150
|
+
unless index > previous_index && index < inventory.size && inventory[index].equal?(device) && device.ready?
|
|
151
|
+
raise ArgumentError, "plan tasks must reference ready inventory devices in order"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
selected[index] = true
|
|
155
|
+
previous_index = index
|
|
156
|
+
end
|
|
157
|
+
if mode == :tftp && ready.count { |_index, device| device.vendor == :palo_alto } > 1
|
|
158
|
+
raise ArgumentError, "plan cannot upload multiple Palo Alto devices to one TFTP filename"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
inventory.each_with_index do |device, index|
|
|
162
|
+
result = outcomes[index]
|
|
163
|
+
next if selected[index] && result.nil?
|
|
164
|
+
|
|
165
|
+
unless !selected[index] && valid_skipped_outcome?(result, device, mode)
|
|
166
|
+
raise ArgumentError, "plan outcome does not match its inventory slot"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# 未执行设备必须保留与清单一致的跳过原因,且不能带有执行产物。
|
|
172
|
+
def valid_skipped_outcome?(result, device, mode)
|
|
173
|
+
return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
|
|
174
|
+
result.error_code.nil? && result.error_type.nil? &&
|
|
175
|
+
result.started_at.nil? && result.finished_at.nil?
|
|
176
|
+
|
|
177
|
+
return result.status == device.issue unless device.ready?
|
|
178
|
+
|
|
179
|
+
result.status == :sample_limit ||
|
|
180
|
+
(mode == :tftp && device.vendor == :palo_alto && result.status == :remote_filename_collision)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 构造包含错误类型的单台设备结果。
|
|
184
|
+
def outcome(device, status, backup: nil, error: nil)
|
|
185
|
+
Outcome.new(device: device, status: status, backup: backup,
|
|
186
|
+
error_code: error.respond_to?(:code) ? error.code : nil,
|
|
187
|
+
error_type: error&.class&.name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# 执行单台设备的 TFTP 导出。
|
|
191
|
+
def tftp_backup_one(device, server, source_files, log_directory, vrfs)
|
|
192
|
+
run_one(device, log_directory, success_status: :reported_uploaded,
|
|
193
|
+
close_error_status: :reported_with_error, backup_class: TftpBackup) do |connector|
|
|
194
|
+
source_file = source_files.fetch(device.vendor, nil)
|
|
195
|
+
transfer_settings = { host: server, path: device.tftp_filename, source_file: source_file }
|
|
196
|
+
transfer_settings[:vrf] = vrfs.fetch(device.vendor) if vrfs.key?(device.vendor)
|
|
197
|
+
connector.tftp_backup(**transfer_settings)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# 执行单台设备的本地配置采集。
|
|
202
|
+
def backup_one(device, directory, log_directory)
|
|
203
|
+
destination = File.join(directory, device.backup_filename)
|
|
204
|
+
previous = Operations::SavedConfig.new(directory: directory).find(device.host, required: false)
|
|
205
|
+
previous_digest = Digest::SHA256.file(previous).hexdigest if previous && previous != destination
|
|
206
|
+
run_one(device, log_directory, success_status: :backed_up,
|
|
207
|
+
close_error_status: :saved_with_error, backup_class: Backup) do |connector|
|
|
208
|
+
backup = connector.backup(path: destination)
|
|
209
|
+
if backup.is_a?(Backup) && backup.change == :created && previous_digest
|
|
210
|
+
backup.with(previous_sha256: previous_digest,
|
|
211
|
+
change: backup.sha256 == previous_digest ? :unchanged : :changed)
|
|
212
|
+
else
|
|
213
|
+
backup
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# 隔离单台设备的凭据、连接、操作和关闭异常。
|
|
219
|
+
def run_one(device, log_directory, success_status:, close_error_status:, backup_class:)
|
|
220
|
+
connection_settings = @credentials.call(device)
|
|
221
|
+
return outcome(device, :missing_credentials) if connection_settings.nil?
|
|
222
|
+
unless connection_settings.is_a?(Hash) && connection_settings.keys.all?(Symbol) &&
|
|
223
|
+
connection_settings[:username].is_a?(String) && !connection_settings[:username].empty? &&
|
|
224
|
+
!connection_settings.key?(:host)
|
|
225
|
+
raise ArgumentError, "credential resolver must return settings with username and without host"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
log_name = device.host.tr(":", "_")
|
|
229
|
+
if log_directory
|
|
230
|
+
connection_settings = connection_settings.merge(log_file: File.join(log_directory, "#{log_name}.log"))
|
|
231
|
+
end
|
|
232
|
+
connector = @connector_factory.call(device, connection_settings)
|
|
233
|
+
backup = nil
|
|
234
|
+
failure = nil
|
|
235
|
+
begin
|
|
236
|
+
result = yield connector
|
|
237
|
+
raise TypeError, "backup operation did not return #{backup_class}" unless result.is_a?(backup_class)
|
|
238
|
+
|
|
239
|
+
backup = result
|
|
240
|
+
rescue StandardError => error
|
|
241
|
+
failure = error
|
|
242
|
+
ensure
|
|
243
|
+
begin
|
|
244
|
+
connector.close
|
|
245
|
+
rescue StandardError => error
|
|
246
|
+
failure ||= error
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
return outcome(device, success_status, backup: backup) unless failure
|
|
250
|
+
|
|
251
|
+
outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
|
|
252
|
+
rescue StandardError => error
|
|
253
|
+
outcome(device, :failed, error: error)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
module Netdisco
|
|
6
|
+
# 固定一份清单上的选择结果与跳过原因,供预览和执行复用。
|
|
7
|
+
Plan = Data.define(:mode, :inventory, :ready, :outcomes) do
|
|
8
|
+
# Data 只冻结对象自身;这里复制并冻结可能由调用方修改的计划容器。
|
|
9
|
+
def initialize(mode:, inventory:, ready:, outcomes:)
|
|
10
|
+
unless inventory.is_a?(Array) && ready.is_a?(Array) && outcomes.is_a?(Array)
|
|
11
|
+
raise ArgumentError, "plan inventory, ready tasks, and outcomes must be Arrays"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
tasks = ready.map { |task| task.is_a?(Array) ? task.dup.freeze : task }.freeze
|
|
15
|
+
super(mode: mode, inventory: inventory.dup.freeze, ready: tasks, outcomes: outcomes.dup.freeze)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 按清单顺序返回选中的设备。
|
|
19
|
+
def selected = ready.map(&:last)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 根据设备就绪状态、厂商采样及远端文件冲突建立备份计划。
|
|
23
|
+
class Planner
|
|
24
|
+
# 保存本次规划使用的清单快照。
|
|
25
|
+
def initialize(inventory)
|
|
26
|
+
@inventory = inventory.dup.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 生成本地或 TFTP 备份计划,明确记录每台未执行设备的原因。
|
|
30
|
+
def call(mode:, limit_per_vendor:)
|
|
31
|
+
self.class.validate_limit!(limit_per_vendor)
|
|
32
|
+
candidates = @inventory.each_with_index.select { |device, _index| device.ready? }
|
|
33
|
+
selected = if limit_per_vendor
|
|
34
|
+
candidates.group_by { |device, _index| device.vendor }.values.flat_map do |entries|
|
|
35
|
+
entries.sort_by { |device, _index| device.host }.first(limit_per_vendor)
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
candidates
|
|
39
|
+
end
|
|
40
|
+
# PAN-OS 导出使用固定远端文件名,同批只允许一台设备写入。
|
|
41
|
+
palo_alto_index = selected.find { |device, _index| device.vendor == :palo_alto }&.last if mode == :tftp
|
|
42
|
+
selected_indices = selected.to_h { |_device, index| [index, true] }
|
|
43
|
+
outcomes = Array.new(@inventory.size)
|
|
44
|
+
ready = @inventory.each_with_index.filter_map do |device, index|
|
|
45
|
+
if !device.ready?
|
|
46
|
+
outcomes[index] = skipped(device, device.issue)
|
|
47
|
+
nil
|
|
48
|
+
elsif mode == :tftp && device.vendor == :palo_alto && index != palo_alto_index
|
|
49
|
+
outcomes[index] = skipped(device, :remote_filename_collision)
|
|
50
|
+
nil
|
|
51
|
+
elsif selected_indices.key?(index)
|
|
52
|
+
[index, device].freeze
|
|
53
|
+
else
|
|
54
|
+
outcomes[index] = skipped(device, :sample_limit)
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
Plan.new(mode: mode, inventory: @inventory, ready: ready.freeze, outcomes: outcomes.freeze)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 限制单厂商采样数,空值表示选择所有就绪设备。
|
|
62
|
+
def self.validate_limit!(limit)
|
|
63
|
+
return if limit.nil? || (limit.is_a?(Integer) && (1..5).cover?(limit))
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "limit_per_vendor must be nil or an Integer in 1..5"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# 构建计划阶段无需设备连接的跳过结果。
|
|
71
|
+
def skipped(device, status)
|
|
72
|
+
Outcome.new(device: device, status: status, backup: nil, error_code: nil, error_type: nil)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "../operations/private_file"
|
|
7
|
+
|
|
8
|
+
module Net
|
|
9
|
+
module Connector
|
|
10
|
+
module Netdisco
|
|
11
|
+
module ResultStore
|
|
12
|
+
# 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
|
|
13
|
+
class Text
|
|
14
|
+
# 以临时文件和重命名原子写入私有 JSON 报告。
|
|
15
|
+
def write(batch, directory:)
|
|
16
|
+
destination = File.expand_path(directory)
|
|
17
|
+
FileUtils.mkdir_p(destination, mode: 0o700)
|
|
18
|
+
filename = "netdisco-#{batch.mode}-#{batch.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
|
|
19
|
+
path = File.join(destination, filename)
|
|
20
|
+
Operations::PrivateFile.write(path, JSON.pretty_generate(batch.summary))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
|
|
25
|
+
# 仓储须实现 create!,本项目不负责数据库表结构。
|
|
26
|
+
class Database
|
|
27
|
+
# 校验并保存调用方提供的数据库仓储。
|
|
28
|
+
def initialize(repository:)
|
|
29
|
+
raise ArgumentError, "repository must respond to create!" unless repository.respond_to?(:create!)
|
|
30
|
+
|
|
31
|
+
@repository = repository
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 把批量摘要写入调用方仓储并返回记录位置。
|
|
35
|
+
def write(batch, **_options)
|
|
36
|
+
record = @repository.create!(batch.summary)
|
|
37
|
+
record.respond_to?(:id) ? "database:#{record.id}" : "database"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Netdisco
|
|
8
|
+
# 将 Netdisco 标签映射到连接器并筛选清单。
|
|
9
|
+
class Rules
|
|
10
|
+
# 校验并保存清单过滤和厂商映射规则。
|
|
11
|
+
def initialize(include_hosts: [], exclude_hosts: [], include_vendors: [], vendor_overrides: {},
|
|
12
|
+
host_overrides: {}, mappings: [])
|
|
13
|
+
@include_hosts = string_list(include_hosts)
|
|
14
|
+
@exclude_hosts = string_list(exclude_hosts)
|
|
15
|
+
raise ArgumentError, "include_vendors must be an Array" unless include_vendors.is_a?(Array)
|
|
16
|
+
|
|
17
|
+
@include_vendors = include_vendors.map do |vendor|
|
|
18
|
+
raise ArgumentError, "include_vendors contains an invalid value" unless vendor.is_a?(String) || vendor.is_a?(Symbol)
|
|
19
|
+
|
|
20
|
+
vendor.to_sym
|
|
21
|
+
end.freeze
|
|
22
|
+
unless @include_vendors.all? { |vendor| Net::Connector.vendors.include?(vendor) }
|
|
23
|
+
raise ArgumentError, "include_vendors contains an unsupported connector"
|
|
24
|
+
end
|
|
25
|
+
unless vendor_overrides.is_a?(Hash)
|
|
26
|
+
raise ArgumentError, "vendor_overrides must be a Hash"
|
|
27
|
+
end
|
|
28
|
+
@vendor_overrides = vendor_overrides.to_h do |label, vendor|
|
|
29
|
+
unless label.is_a?(String) && vendor.is_a?(String) && !label.empty?
|
|
30
|
+
raise ArgumentError, "vendor_overrides must map labels to connector names"
|
|
31
|
+
end
|
|
32
|
+
key = normalize(label)
|
|
33
|
+
value = vendor.to_sym
|
|
34
|
+
raise ArgumentError, "vendor_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(value)
|
|
35
|
+
|
|
36
|
+
[key, value]
|
|
37
|
+
end.freeze
|
|
38
|
+
raise ArgumentError, "host_overrides must be a Hash" unless host_overrides.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
@host_overrides = host_overrides.to_h do |host, vendor|
|
|
41
|
+
unless host.is_a?(String) && !host.include?("/") && vendor.is_a?(String)
|
|
42
|
+
raise ArgumentError, "host_overrides must map IP addresses to connector names"
|
|
43
|
+
end
|
|
44
|
+
begin
|
|
45
|
+
address = IPAddr.new(host).to_s
|
|
46
|
+
rescue IPAddr::Error
|
|
47
|
+
raise ArgumentError, "host_overrides contains an invalid IP address"
|
|
48
|
+
end
|
|
49
|
+
connector = vendor.to_sym
|
|
50
|
+
raise ArgumentError, "host_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
|
|
51
|
+
|
|
52
|
+
[address, connector]
|
|
53
|
+
end.freeze
|
|
54
|
+
raise ArgumentError, "mappings must be an Array" unless mappings.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
@mappings = mappings.map do |rule|
|
|
57
|
+
unless rule.is_a?(Hash) && rule["vendor"].is_a?(String) && !rule["vendor"].empty? &&
|
|
58
|
+
rule["connector"].is_a?(String) &&
|
|
59
|
+
(rule.keys - %w[vendor os model_prefix connector]).empty? &&
|
|
60
|
+
%w[os model_prefix].all? { |key| rule[key].nil? || rule[key].is_a?(String) }
|
|
61
|
+
raise ArgumentError, "each mapping requires vendor and connector strings"
|
|
62
|
+
end
|
|
63
|
+
connector = rule.fetch("connector").to_sym
|
|
64
|
+
raise ArgumentError, "mapping contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
|
|
65
|
+
|
|
66
|
+
{ vendor: normalize(rule.fetch("vendor")), os: rule["os"] && normalize(rule["os"]),
|
|
67
|
+
model_prefix: rule["model_prefix"] && normalize(rule["model_prefix"]), connector: connector }.freeze
|
|
68
|
+
end.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 根据地址、显式规则和厂商信息识别连接器。
|
|
72
|
+
def resolve(row)
|
|
73
|
+
begin
|
|
74
|
+
address = IPAddr.new(row["ip"]).to_s
|
|
75
|
+
return @host_overrides.fetch(address) if @host_overrides.key?(address)
|
|
76
|
+
rescue IPAddr::Error, TypeError
|
|
77
|
+
# 无效的管理地址交由 Device.from_row 标记。
|
|
78
|
+
end
|
|
79
|
+
label = normalize(row["vendor"])
|
|
80
|
+
os = normalize(row["os"])
|
|
81
|
+
model = normalize(row["model"])
|
|
82
|
+
match = @mappings.find do |rule|
|
|
83
|
+
rule[:vendor] == label && (rule[:os].nil? || rule[:os] == os) &&
|
|
84
|
+
(rule[:model_prefix].nil? || model.start_with?(rule[:model_prefix]))
|
|
85
|
+
end
|
|
86
|
+
return match[:connector] if match
|
|
87
|
+
return @vendor_overrides[label] if @vendor_overrides.key?(label)
|
|
88
|
+
|
|
89
|
+
case label
|
|
90
|
+
when /\bh3c\b/ then (os.match?(/wireless|wlan/) || model.match?(/\A(?:wx|ac)\s*\d/)) ? :h3c_wireless : :h3c
|
|
91
|
+
when /\bcisco\b/
|
|
92
|
+
return :cisco_nxos if os.match?(/nx\s*os|nexus/) || model.match?(/\bnexus\b|\bn\s*[3579]k\b/)
|
|
93
|
+
return :cisco_ios if os.match?(/\bios\b|\bios\s*xe\b/)
|
|
94
|
+
when /\bradware\b/ then :radware
|
|
95
|
+
when /palo\s*alto|paloalto/ then :palo_alto
|
|
96
|
+
when /\bhuawei\b/ then :huawei
|
|
97
|
+
when /\bhillstone\b/ then :hillstone
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 判断设备是否通过地址和厂商过滤。
|
|
102
|
+
def selected?(host, vendor)
|
|
103
|
+
return false if @exclude_hosts.include?(host)
|
|
104
|
+
return false if !@include_hosts.empty? && !@include_hosts.include?(host)
|
|
105
|
+
return false if !@include_vendors.empty? && !@include_vendors.include?(vendor)
|
|
106
|
+
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# 校验并标准化管理地址列表。
|
|
113
|
+
def string_list(values)
|
|
114
|
+
raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
|
|
115
|
+
|
|
116
|
+
values.map do |value|
|
|
117
|
+
raise ArgumentError, "host list entries must be Strings" unless value.is_a?(String) && !value.empty?
|
|
118
|
+
raise ArgumentError, "host list entries must be IP addresses" if value.include?("/")
|
|
119
|
+
|
|
120
|
+
IPAddr.new(value).to_s.freeze
|
|
121
|
+
rescue IPAddr::Error
|
|
122
|
+
raise ArgumentError, "host list entries must be IP addresses"
|
|
123
|
+
end.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 将设备标签转为便于匹配的小写词串。
|
|
127
|
+
def normalize(value) = value.to_s.downcase.gsub(/[^a-z0-9]+/, " ").strip
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|