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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. 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