netstack 0.1.1 → 0.2.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 (279) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -3
  3. data/{LICENSE.txt → LICENSE} +7 -6
  4. data/NOTICE +25 -0
  5. data/README.md +58 -20
  6. data/docs/acceptance.md +92 -0
  7. data/docs/backups.md +100 -0
  8. data/docs/configuration.md +198 -0
  9. data/docs/connector.md +187 -0
  10. data/docs/ddi.md +142 -0
  11. data/docs/deployment.md +47 -0
  12. data/docs/foundations.md +45 -0
  13. data/docs/implementation.md +68 -0
  14. data/docs/inventory.md +42 -0
  15. data/docs/monitoring.md +227 -0
  16. data/docs/motor_integration.md +96 -0
  17. data/docs/performance.md +39 -0
  18. data/docs/release.md +20 -0
  19. data/docs/vxlan.md +91 -0
  20. data/docs/wireless.md +81 -0
  21. data/lib/netstack/backups/artifact.rb +23 -0
  22. data/lib/netstack/backups/diff.rb +15 -0
  23. data/lib/netstack/backups/repository.rb +77 -0
  24. data/lib/netstack/backups/request.rb +78 -0
  25. data/lib/netstack/circuits/business_calendar.rb +51 -0
  26. data/lib/netstack/circuits/capacity_review.rb +191 -0
  27. data/lib/netstack/circuits/endpoint.rb +25 -0
  28. data/lib/netstack/circuits/monitoring_plan.rb +212 -0
  29. data/lib/netstack/configuration/artifact.rb +85 -0
  30. data/lib/netstack/configuration/artifact_resolver.rb +30 -0
  31. data/lib/netstack/configuration/bootstrap/payload.rb +67 -0
  32. data/lib/netstack/configuration/bootstrap/validator.rb +224 -0
  33. data/lib/netstack/configuration/catalog.rb +60 -0
  34. data/lib/netstack/configuration/generator.rb +87 -0
  35. data/lib/netstack/configuration/input_contract.rb +82 -0
  36. data/lib/netstack/configuration/payload.rb +168 -0
  37. data/lib/netstack/configuration/reader.rb +124 -0
  38. data/lib/netstack/configuration/support.rb +30 -0
  39. data/lib/netstack/configuration/system_configuration.rb +56 -0
  40. data/lib/netstack/configuration/template_manager.rb +174 -0
  41. data/lib/netstack/configuration/template_store.rb +40 -0
  42. data/lib/netstack/configuration/validator.rb +566 -0
  43. data/lib/netstack/connector/authentication.rb +77 -0
  44. data/lib/netstack/connector/authentication_error.rb +11 -0
  45. data/lib/netstack/connector/backup.rb +19 -0
  46. data/lib/netstack/connector/backup_busy.rb +11 -0
  47. data/lib/netstack/connector/backup_persistence_error.rb +32 -0
  48. data/lib/netstack/connector/command.rb +63 -0
  49. data/lib/netstack/connector/command_result.rb +24 -0
  50. data/lib/netstack/connector/command_timeout.rb +11 -0
  51. data/lib/netstack/connector/configuration.rb +138 -0
  52. data/lib/netstack/connector/connection_closed.rb +11 -0
  53. data/lib/netstack/connector/connection_error.rb +11 -0
  54. data/lib/netstack/connector/device.rb +284 -0
  55. data/lib/netstack/connector/device_error.rb +11 -0
  56. data/lib/netstack/connector/dialogue.rb +55 -0
  57. data/lib/netstack/connector/error.rb +44 -0
  58. data/lib/netstack/connector/error_metadata.rb +58 -0
  59. data/lib/netstack/connector/event.rb +24 -0
  60. data/lib/netstack/connector/execution.rb +110 -0
  61. data/lib/netstack/connector/interaction.rb +55 -0
  62. data/lib/netstack/connector/internal_error.rb +11 -0
  63. data/lib/netstack/connector/known_hosts.rb +128 -0
  64. data/lib/netstack/connector/local_backup.rb +63 -0
  65. data/lib/netstack/connector/log/event.rb +65 -0
  66. data/lib/netstack/connector/log/formatter.rb +18 -0
  67. data/lib/netstack/connector/log/messages.rb +54 -0
  68. data/lib/netstack/connector/log/redacting_writer.rb +33 -0
  69. data/lib/netstack/connector/log/transcript.rb +35 -0
  70. data/lib/netstack/connector/log.rb +262 -0
  71. data/lib/netstack/connector/log_error.rb +11 -0
  72. data/lib/netstack/connector/login_timeout.rb +11 -0
  73. data/lib/netstack/connector/output_limit_exceeded.rb +11 -0
  74. data/lib/netstack/connector/parsing_error.rb +11 -0
  75. data/lib/netstack/connector/profile/builder.rb +238 -0
  76. data/lib/netstack/connector/profile.rb +225 -0
  77. data/lib/netstack/connector/prompt_error.rb +11 -0
  78. data/lib/netstack/connector/recovery.rb +59 -0
  79. data/lib/netstack/connector/redactor.rb +100 -0
  80. data/lib/netstack/connector/response.rb +25 -0
  81. data/lib/netstack/connector/response_reader.rb +140 -0
  82. data/lib/netstack/connector/result.rb +50 -0
  83. data/lib/netstack/connector/running_config/rendered.rb +16 -0
  84. data/lib/netstack/connector/running_config/strategy.rb +28 -0
  85. data/lib/netstack/connector/running_config.rb +113 -0
  86. data/lib/netstack/connector/save_config.rb +23 -0
  87. data/lib/netstack/connector/script.rb +59 -0
  88. data/lib/netstack/connector/script_error.rb +11 -0
  89. data/lib/netstack/connector/script_output_limit_exceeded.rb +11 -0
  90. data/lib/netstack/connector/session.rb +414 -0
  91. data/lib/netstack/connector/session_busy.rb +11 -0
  92. data/lib/netstack/connector/storage/backup_lock.rb +117 -0
  93. data/lib/netstack/connector/storage/private_file.rb +111 -0
  94. data/lib/netstack/connector/storage/safe_file.rb +64 -0
  95. data/lib/netstack/connector/storage/saved_config.rb +76 -0
  96. data/lib/netstack/connector/terminal_renderer.rb +186 -0
  97. data/lib/netstack/connector/terminal_text.rb +32 -0
  98. data/lib/netstack/connector/textfsm.rb +71 -0
  99. data/lib/netstack/connector/tftp/file_upload.rb +53 -0
  100. data/lib/netstack/connector/tftp/strategy.rb +67 -0
  101. data/lib/netstack/connector/tftp.rb +116 -0
  102. data/lib/netstack/connector/tftp_completion_error.rb +36 -0
  103. data/lib/netstack/connector/tftp_receipt.rb +54 -0
  104. data/lib/netstack/connector/tftp_target.rb +65 -0
  105. data/lib/netstack/connector/topology/checkpoint_error.rb +16 -0
  106. data/lib/netstack/connector/topology/deployment_adapter.rb +163 -0
  107. data/lib/netstack/connector/topology/execution_result.rb +34 -0
  108. data/lib/netstack/connector/topology/immediate_strategy.rb +39 -0
  109. data/lib/netstack/connector/topology/interface_description.rb +46 -0
  110. data/lib/netstack/connector/topology/interface_name.rb +60 -0
  111. data/lib/netstack/connector/topology/plan.rb +105 -0
  112. data/lib/netstack/connector/topology/strategy.rb +76 -0
  113. data/lib/netstack/connector/topology.rb +255 -0
  114. data/lib/netstack/connector/transport_error.rb +11 -0
  115. data/lib/netstack/connector/transports.rb +201 -0
  116. data/lib/netstack/connector/underlying_error.rb +25 -0
  117. data/lib/netstack/connector/unsupported_operation.rb +11 -0
  118. data/lib/netstack/connector/vendors/cisco_ios/running_config.rb +22 -0
  119. data/lib/netstack/connector/vendors/cisco_ios/tftp_backup.rb +41 -0
  120. data/lib/netstack/connector/vendors/cisco_ios/topology.rb +54 -0
  121. data/lib/netstack/connector/vendors/cisco_ios.rb +44 -0
  122. data/lib/netstack/connector/vendors/cisco_nxos/running_config.rb +25 -0
  123. data/lib/netstack/connector/vendors/cisco_nxos/tftp_backup.rb +43 -0
  124. data/lib/netstack/connector/vendors/cisco_nxos.rb +51 -0
  125. data/lib/netstack/connector/vendors/h3c/tftp_backup.rb +34 -0
  126. data/lib/netstack/connector/vendors/h3c/topology.rb +72 -0
  127. data/lib/netstack/connector/vendors/h3c.rb +75 -0
  128. data/lib/netstack/connector/vendors/h3c_wireless.rb +13 -0
  129. data/lib/netstack/connector/vendors/hillstone/running_config.rb +16 -0
  130. data/lib/netstack/connector/vendors/hillstone/tftp_backup.rb +55 -0
  131. data/lib/netstack/connector/vendors/hillstone/topology.rb +64 -0
  132. data/lib/netstack/connector/vendors/hillstone.rb +39 -0
  133. data/lib/netstack/connector/vendors/huawei/tftp_backup.rb +26 -0
  134. data/lib/netstack/connector/vendors/huawei.rb +62 -0
  135. data/lib/netstack/connector/vendors/palo_alto/running_config.rb +77 -0
  136. data/lib/netstack/connector/vendors/palo_alto/tftp_backup.rb +49 -0
  137. data/lib/netstack/connector/vendors/palo_alto/topology.rb +77 -0
  138. data/lib/netstack/connector/vendors/palo_alto.rb +39 -0
  139. data/lib/netstack/connector/vendors/radware/running_config.rb +21 -0
  140. data/lib/netstack/connector/vendors/radware/tftp_backup.rb +49 -0
  141. data/lib/netstack/connector/vendors/radware/topology.rb +19 -0
  142. data/lib/netstack/connector/vendors/radware.rb +59 -0
  143. data/lib/netstack/connector/write_timeout.rb +11 -0
  144. data/lib/netstack/connector.rb +32 -0
  145. data/lib/netstack/ddi/applier.rb +107 -0
  146. data/lib/netstack/ddi/deployment.rb +137 -0
  147. data/lib/netstack/ddi/inputs.rb +61 -0
  148. data/lib/netstack/ddi/manager.rb +197 -0
  149. data/lib/netstack/ddi/plan.rb +99 -0
  150. data/lib/netstack/ddi/scope.rb +127 -0
  151. data/lib/netstack/ddi/settings.rb +44 -0
  152. data/lib/netstack/ddi/state.rb +166 -0
  153. data/lib/netstack/deployment/checkpoint_error.rb +14 -0
  154. data/lib/netstack/deployment/cli_adapter.rb +64 -0
  155. data/lib/netstack/deployment/executor.rb +131 -0
  156. data/lib/netstack/deployment/plan.rb +82 -0
  157. data/lib/netstack/deployment/receipt.rb +78 -0
  158. data/lib/netstack/deployment/recovery.rb +19 -0
  159. data/lib/netstack/deployment/step.rb +16 -0
  160. data/lib/netstack/dimension.rb +30 -0
  161. data/lib/netstack/error.rb +14 -0
  162. data/lib/netstack/input_error.rb +9 -0
  163. data/lib/netstack/integrations/bluecat/client.rb +84 -0
  164. data/lib/netstack/integrations/http/client.rb +146 -0
  165. data/lib/netstack/integrations/http/error.rb +10 -0
  166. data/lib/netstack/integrations/http/response.rb +28 -0
  167. data/lib/netstack/integrations/http_delivery/adapter.rb +51 -0
  168. data/lib/netstack/integrations/http_delivery/client.rb +127 -0
  169. data/lib/netstack/integrations/http_delivery/result.rb +27 -0
  170. data/lib/netstack/integrations/oxidized/client.rb +89 -0
  171. data/lib/netstack/integrations/zabbix/client.rb +211 -0
  172. data/lib/netstack/integrations/zabbix/error.rb +10 -0
  173. data/lib/netstack/inventory/address.rb +30 -0
  174. data/lib/netstack/inventory/changes.rb +13 -0
  175. data/lib/netstack/inventory/cli_parser.rb +75 -0
  176. data/lib/netstack/inventory/collector.rb +99 -0
  177. data/lib/netstack/inventory/device.rb +14 -0
  178. data/lib/netstack/inventory/interface.rb +25 -0
  179. data/lib/netstack/inventory/neighbor.rb +15 -0
  180. data/lib/netstack/inventory/reconciler.rb +74 -0
  181. data/lib/netstack/inventory/snapshot.rb +69 -0
  182. data/lib/netstack/inventory/transceiver.rb +23 -0
  183. data/lib/netstack/ipv4.rb +204 -0
  184. data/lib/netstack/ipv6.rb +88 -0
  185. data/lib/netstack/monitoring/applier.rb +179 -0
  186. data/lib/netstack/monitoring/device_plan.rb +23 -0
  187. data/lib/netstack/monitoring/host_plan.rb +152 -0
  188. data/lib/netstack/monitoring/inputs.rb +73 -0
  189. data/lib/netstack/monitoring/measurements.rb +89 -0
  190. data/lib/netstack/monitoring/mutation.rb +37 -0
  191. data/lib/netstack/monitoring/plan.rb +69 -0
  192. data/lib/netstack/monitoring/probe_plan.rb +19 -0
  193. data/lib/netstack/monitoring/state_comparison.rb +37 -0
  194. data/lib/netstack/range_set.rb +181 -0
  195. data/lib/netstack/routing/lookup.rb +46 -0
  196. data/lib/netstack/routing/lookup_result.rb +14 -0
  197. data/lib/netstack/routing/route.rb +32 -0
  198. data/lib/netstack/routing/snapshot.rb +23 -0
  199. data/lib/netstack/target.rb +15 -0
  200. data/lib/netstack/unsupported_capability.rb +9 -0
  201. data/lib/netstack/values.rb +70 -0
  202. data/lib/netstack/version.rb +1 -1
  203. data/lib/netstack/vxlan/auditor.rb +187 -0
  204. data/lib/netstack/vxlan/cisco_nxos/config_parser.rb +327 -0
  205. data/lib/netstack/vxlan/cisco_nxos/deployment_adapter.rb +63 -0
  206. data/lib/netstack/vxlan/cisco_nxos/runtime_parser.rb +182 -0
  207. data/lib/netstack/vxlan/cisco_nxos/segment_renderer.rb +42 -0
  208. data/lib/netstack/vxlan/collector.rb +77 -0
  209. data/lib/netstack/vxlan/fabric.rb +29 -0
  210. data/lib/netstack/vxlan/finding.rb +20 -0
  211. data/lib/netstack/vxlan/node.rb +51 -0
  212. data/lib/netstack/vxlan/route_policy.rb +57 -0
  213. data/lib/netstack/vxlan/runtime_result.rb +24 -0
  214. data/lib/netstack/vxlan/segment.rb +35 -0
  215. data/lib/netstack/vxlan/segment_plan.rb +25 -0
  216. data/lib/netstack/vxlan/segment_planner.rb +156 -0
  217. data/lib/netstack/vxlan/snapshot.rb +34 -0
  218. data/lib/netstack/vxlan/validation.rb +58 -0
  219. data/lib/netstack/vxlan/verifier.rb +52 -0
  220. data/lib/netstack/vxlan/vpc_domain.rb +20 -0
  221. data/lib/netstack/wireless/access_point.rb +12 -0
  222. data/lib/netstack/wireless/ap_group.rb +12 -0
  223. data/lib/netstack/wireless/assessment.rb +13 -0
  224. data/lib/netstack/wireless/assessor.rb +83 -0
  225. data/lib/netstack/wireless/authentication_connection.rb +12 -0
  226. data/lib/netstack/wireless/bss.rb +12 -0
  227. data/lib/netstack/wireless/client.rb +12 -0
  228. data/lib/netstack/wireless/collector.rb +191 -0
  229. data/lib/netstack/wireless/configuration.rb +13 -0
  230. data/lib/netstack/wireless/finding.rb +12 -0
  231. data/lib/netstack/wireless/h3c/config_parser.rb +229 -0
  232. data/lib/netstack/wireless/h3c/lldp_parser.rb +69 -0
  233. data/lib/netstack/wireless/h3c/runtime_parser.rb +240 -0
  234. data/lib/netstack/wireless/huawei/runtime_parser.rb +83 -0
  235. data/lib/netstack/wireless/invalid_snapshot.rb +11 -0
  236. data/lib/netstack/wireless/neighbor.rb +12 -0
  237. data/lib/netstack/wireless/normalizer.rb +90 -0
  238. data/lib/netstack/wireless/parse_result.rb +13 -0
  239. data/lib/netstack/wireless/radio.rb +12 -0
  240. data/lib/netstack/wireless/reconciler.rb +115 -0
  241. data/lib/netstack/wireless/reconciliation.rb +11 -0
  242. data/lib/netstack/wireless/security_policy.rb +12 -0
  243. data/lib/netstack/wireless/service_template.rb +12 -0
  244. data/lib/netstack/wireless/service_template_bindings.rb +56 -0
  245. data/lib/netstack/wireless/snapshot.rb +150 -0
  246. data/lib/netstack/wireless/state.rb +23 -0
  247. data/lib/netstack/wireless/view.rb +154 -0
  248. data/lib/netstack.rb +10 -3
  249. data/resources/configuration/cisco-bootstrap.erb +118 -0
  250. data/resources/configuration/cisco-distributed-gateway.erb +40 -0
  251. data/resources/configuration/cisco-n9k-vpc.erb +62 -0
  252. data/resources/configuration/cisco-server-port.erb +43 -0
  253. data/resources/configuration/h3c-bootstrap.erb +108 -0
  254. data/resources/configuration/h3c-irf.erb +57 -0
  255. data/resources/configuration/h3c-server-port.erb +36 -0
  256. data/resources/configuration/hillstone-ipsec-vpn.erb +68 -0
  257. data/resources/configuration/hillstone-policy.erb +31 -0
  258. data/resources/configuration/paloalto-ipsec-vpn.erb +39 -0
  259. data/resources/configuration/radware-load-balancer.erb +26 -0
  260. data/resources/textfsm/cisco_cdp_neighbors_detail.textfsm +7 -0
  261. data/resources/textfsm/cisco_ios_running_config_interfaces.textfsm +12 -0
  262. data/resources/textfsm/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  263. data/resources/textfsm/h3c_interface_descriptions.textfsm +11 -0
  264. data/resources/textfsm/h3c_lldp_local_first.textfsm +8 -0
  265. data/resources/textfsm/h3c_lldp_name_first.textfsm +8 -0
  266. data/resources/textfsm/hillstone_interface_descriptions.textfsm +11 -0
  267. data/resources/textfsm/hillstone_lldp_neighbors.textfsm +7 -0
  268. data/resources/textfsm/index +8 -0
  269. data/resources/textfsm/palo_alto_interface_descriptions.textfsm +6 -0
  270. data/resources/textfsm/palo_alto_lldp_neighbors.textfsm +11 -0
  271. data/resources/textfsm/radware_port_names.textfsm +13 -0
  272. metadata +588 -27
  273. data/.rubocop.yml +0 -13
  274. data/CODE_OF_CONDUCT.md +0 -84
  275. data/Gemfile +0 -10
  276. data/Gemfile.lock +0 -40
  277. data/Rakefile +0 -8
  278. data/bin/console +0 -15
  279. data/bin/setup +0 -8
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ module H3c
6
+ class LldpParser
7
+ def parse(content, ap_name:, terminated: false)
8
+ ap_name = Values.text(ap_name, name: "LLDP AP name")
9
+ text = Normalizer.text(content)
10
+ neighbors = []
11
+ diagnostics = []
12
+ current = nil
13
+ local = nil
14
+ flush = lambda do
15
+ if current && current[:remote_chassis_id] && current[:remote_interface]
16
+ neighbors << Neighbor.new(key: Normalizer.key(ap_name, local, current[:remote_chassis_id], current[:remote_interface]),
17
+ ap_name: ap_name, local_interface: local, **current)
18
+ elsif current
19
+ diagnostics << { code: :incomplete_neighbor }
20
+ end
21
+ end
22
+ text.lines.each_with_index do |line, index|
23
+ next if line.strip.empty? || /\A\s*[-=]+\s*\z/.match?(line)
24
+ if (match = /LLDP neighbor-information of port \d+\[(.*?)\]\s*:?/i.match(line))
25
+ flush.call
26
+ local = match[1]
27
+ current = nil
28
+ elsif /\ALLDP neighbor index\s*:/i.match?(line.strip)
29
+ flush.call
30
+ current = {}
31
+ elsif (match = /\A\s*([^:]+):\s*(.*?)\s*\z/.match(line))
32
+ label, value = match.captures
33
+ next if /total|number of neighbors/i.match?(label)
34
+ unless local
35
+ diagnostics << { code: :missing_local_interface, line: index + 1 }
36
+ next
37
+ end
38
+ current ||= {}
39
+ case label.strip.downcase
40
+ when "chassis id" then current[:remote_chassis_id] = value.downcase
41
+ when "port id" then current[:remote_interface] = value
42
+ when "system name" then current[:remote_name] = value
43
+ when "management address"
44
+ begin
45
+ current[:management_address] = Normalizer.ip(Normalizer.value(value))
46
+ rescue InputError
47
+ diagnostics << { code: :invalid_management_address, line: index + 1 }
48
+ end
49
+ when "system description", "port description", "port vlan id", "vlan id", "maximum frame size", "chassis type", "port type", "time to live", "system capabilities supported", "system capabilities enabled"
50
+ (current[:attributes] ||= {})[label.strip.downcase.tr(" ", "_").to_sym] = value
51
+ else diagnostics << { code: :unrecognized_field, line: index + 1 }
52
+ end
53
+ elsif !/no (?:LLDP )?neighbors|total.*\b0\b/i.match?(line)
54
+ diagnostics << { code: :unrecognized_line, line: index + 1 }
55
+ end
56
+ end
57
+ flush.call
58
+ count = /(?:total(?: (?:LLDP )?neighbors)?|number of neighbors)[^\n:]*:\s*(\d+)\s*$/i.match(text)&.[](1)&.to_i
59
+ diagnostics << { code: :count_mismatch, expected: count, parsed: neighbors.size } if count && count != neighbors.size
60
+ zero = /no (?:LLDP )?neighbors|(?:total|number of neighbors)[^\n]*:\s*0\s*$/i.match?(text)
61
+ diagnostics << { code: :missing_completeness_evidence } unless zero || (terminated && !neighbors.empty?)
62
+ ParseResult.new(records: neighbors, status: diagnostics.empty? ? :complete : neighbors.empty? ? :failed : :partial, diagnostics: diagnostics)
63
+ rescue InputError => error
64
+ ParseResult.new(records: [], status: :failed, diagnostics: [{ code: :invalid_output, error: error.code }])
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ module H3c
6
+ # CLI grammar adapted from PDK::Inspection::Runtime::H3c::WirelessAp::Parser.
7
+ class RuntimeParser
8
+ AP_FIELDS = { "ap id" => :ap_id, "ap group name" => :group_name, "state" => :state, "model" => :model,
9
+ "mac address" => :mac_address, "ip address" => :ip_address, "client count" => :client_count,
10
+ "number of clients" => :client_count, "online clients" => :client_count, "online time" => :online_seconds }.freeze
11
+ RADIO_FIELDS = { "state" => :admin_state, "admin state" => :admin_state, "radio type" => :radio_type,
12
+ "channel" => :channel, "channel usage(%)" => :channel_usage, "channel usage" => :channel_usage,
13
+ "noise floor" => :noise_floor }.freeze
14
+ CLIENT_FIELDS = { "ap name" => :ap_name, "ap id" => :ap_id, "radio id" => :radio_id, "ssid" => :ssid,
15
+ "bssid" => :bssid, "ipv4 address" => :ip_address, "ip address" => :ip_address, "ipv6 address" => :ipv6_address,
16
+ "vlan id" => :vlan_id, "rssi" => :rssi, "online time" => :online_seconds }.freeze
17
+ CLIENT_AUTH = %w[username authentication\ method security\ mode akm\ mode cipher\ suite user\ authentication\ mode wpa3\ status pmf\ status].freeze
18
+ CLIENT_TERMINAL = %w[sm\ power\ save short\ gi\ for\ 20mhz stbc\ rx\ capability ldpc\ rx\ capability ofdma\ random\ access\ rus twt\ scheduled qos\ mode listen\ interval].freeze
19
+ CLIENT_ATTRIBUTES = %w[aid channel wireless\ mode channel\ bandwidth roam\ status btm\ mode].freeze
20
+ BSS_FIELDS = { "ap name" => :ap_name, "ap id" => :ap_id, "radio id" => :radio_id, "ssid" => :ssid,
21
+ "service template" => :service_template, "service template name" => :service_template,
22
+ "vlan id" => :vlan_id, "state" => :state, "client count" => :client_count,
23
+ "number of clients" => :client_count, "online clients" => :client_count }.freeze
24
+ AUTH_FIELDS = { "username" => :username, "user name" => :username, "access interface" => :interface,
25
+ "interface" => :interface, "ap name" => :ap_name, "ap id" => :ap_id, "radio id" => :radio_id,
26
+ "ssid" => :ssid, "bssid" => :bssid, "ip address" => :ip_address, "ipv4 address" => :ip_address,
27
+ "authentication domain" => :domain, "domain" => :domain, "authentication protocol" => :protocol,
28
+ "auth protocol" => :protocol, "access protocol" => :protocol, "protocol" => :protocol,
29
+ "authentication state" => :state, "state" => :state, "status" => :state,
30
+ "initial vlan" => :initial_vlan_id, "initial vlan id" => :initial_vlan_id,
31
+ "authorization vlan" => :authorized_vlan_id, "authorization vlan id" => :authorized_vlan_id,
32
+ "authorized vlan" => :authorized_vlan_id, "authorized vlan id" => :authorized_vlan_id,
33
+ "online time" => :online_seconds }.freeze
34
+
35
+ def parse(dimension, content, terminated: false)
36
+ text = Normalizer.text(content)
37
+ case dimension.to_sym
38
+ when :access_points, :radios then parse_access_points(text, dimension.to_sym, terminated: terminated)
39
+ when :clients then parse_blocks(text, :clients, /\Amac address\z/, terminated: terminated)
40
+ when :bsses then parse_blocks(text, :bsses, /\Abssid\z/, terminated: terminated)
41
+ when :dot1x_connections, :mab_connections
42
+ parse_blocks(text, dimension.to_sym, /\A(?:user )?mac address\z/, terminated: terminated)
43
+ when :blocked_clients then parse_blocked_clients(text, terminated: terminated)
44
+ else raise InputError, "unsupported H3c wireless dimension"
45
+ end
46
+ rescue InputError => error
47
+ ParseResult.new(records: [], status: :failed, diagnostics: [{ code: :invalid_output, error: error.code }])
48
+ end
49
+
50
+ private
51
+
52
+ def parse_access_points(text, dimension, terminated:)
53
+ aps = []
54
+ radios = []
55
+ diagnostics = []
56
+ current = radio = nil
57
+ each_field(text, diagnostics) do |label, value, line|
58
+ if label == "ap name"
59
+ current = { name: value }
60
+ aps << current
61
+ radio = nil
62
+ elsif current && (match = /\Aradio (\d+)\z/.match(label))
63
+ radio = { ap_name: current[:name], radio_id: match[1].to_i }
64
+ radios << radio
65
+ elsif radio && RADIO_FIELDS.key?(label)
66
+ safely(diagnostics, line) { assign(radio, RADIO_FIELDS.fetch(label), value) }
67
+ elsif current && AP_FIELDS.key?(label)
68
+ safely(diagnostics, line) { assign(current, AP_FIELDS.fetch(label), value) }
69
+ elsif current && label == "capwap data-tunnel status"
70
+ (current[:configuration] ||= {})[:capwap_data_tunnel_status] = value
71
+ elsif !summary?(label)
72
+ diagnostics << { code: :unrecognized_field, line: line }
73
+ end
74
+ end
75
+ records = if dimension == :access_points
76
+ aps.filter_map do |attributes|
77
+ next if attributes[:name].nil?
78
+
79
+ AccessPoint.new(key: Normalizer.key(attributes[:name]), **attributes)
80
+ end
81
+ else
82
+ radios.map { |attributes| Radio.new(key: Normalizer.key(attributes[:ap_name], attributes[:radio_id]), **attributes) }
83
+ end
84
+ total = total_count(text, :access_points)
85
+ diagnostics << { code: :count_mismatch, expected: total, parsed: aps.size } if total && total != aps.size
86
+ result(records, diagnostics, total: total, terminated: terminated, recognized: !aps.empty?)
87
+ end
88
+
89
+ def parse_blocks(text, dimension, start, terminated:)
90
+ blocks = []
91
+ current = nil
92
+ diagnostics = []
93
+ each_field(text, diagnostics) do |label, value, line|
94
+ if start.match?(label)
95
+ current = { (dimension == :bsses ? :bssid : :mac_address) => value }
96
+ blocks << current
97
+ elsif current
98
+ current[:fields] ||= []
99
+ current[:fields] << [label, value, line]
100
+ elsif !summary?(label)
101
+ diagnostics << { code: :unrecognized_field, line: line }
102
+ end
103
+ end
104
+ records = blocks.filter_map do |block|
105
+ safely(diagnostics, nil) do
106
+ fields = block.delete(:fields) || []
107
+ identity_field = dimension == :bsses ? :bssid : :mac_address
108
+ block[identity_field] = Normalizer.mac(block[identity_field])
109
+ raise InputError, "missing wireless identity" unless block[identity_field]
110
+
111
+ fields.each { |label, value, line| safely(diagnostics, line) { block_field(block, dimension, label, value, diagnostics, line) } }
112
+ build_record(block, dimension)
113
+ end
114
+ end
115
+ total = total_count(text, dimension)
116
+ diagnostics << { code: :count_mismatch, expected: total, parsed: records.size } if total && total != records.size
117
+ result(records, diagnostics, total: total, terminated: terminated)
118
+ end
119
+
120
+ def block_field(record, dimension, label, value, diagnostics, line)
121
+ mapping = dimension == :clients ? CLIENT_FIELDS : dimension == :bsses ? BSS_FIELDS : AUTH_FIELDS
122
+ if mapping.key?(label)
123
+ assign(record, mapping.fetch(label), value)
124
+ elsif dimension == :clients && CLIENT_AUTH.include?(label)
125
+ (record[:authentication] ||= {})[label.tr(" -", "__").to_sym] = value
126
+ elsif dimension == :clients && CLIENT_TERMINAL.include?(label)
127
+ (record[:terminal] ||= {})[label.tr(" -", "__").to_sym] = value
128
+ elsif dimension == :clients && CLIENT_ATTRIBUTES.include?(label)
129
+ (record[:attributes] ||= {})[label.tr(" -", "__").to_sym] = value
130
+ elsif dimension == :clients && ["rx/tx rate", "speed"].include?(label)
131
+ rates = value && Normalizer.rate_pair(value, default: label == "speed" ? "Kbps" : "Mbps")
132
+ if label == "speed"
133
+ (record[:attributes] ||= {})[:throughput_bps] = rates
134
+ else
135
+ record[:rx_rate_bps], record[:tx_rate_bps] = rates
136
+ end
137
+ elsif dimension == :bsses && ["security mode", "akm mode", "cipher suite", "bss id"].include?(label)
138
+ (record[:security] ||= {})[label.tr(" ", "_").to_sym] = value
139
+ elsif dimension != :clients && label == "authentication method"
140
+ record[:protocol] ||= value
141
+ else
142
+ diagnostics << { code: :unrecognized_field, line: line }
143
+ end
144
+ end
145
+
146
+ def build_record(attributes, dimension)
147
+ case dimension
148
+ when :clients
149
+ rssi = attributes[:rssi]
150
+ attributes[:rssi] = nil if rssi == 0
151
+ Client.new(key: Normalizer.key(attributes[:mac_address]), rssi_scale: rssi && rssi < 0 ? :dbm : :relative, **attributes)
152
+ when :bsses
153
+ Bss.new(key: Normalizer.key(attributes[:bssid]), **attributes)
154
+ else
155
+ kind = dimension == :dot1x_connections ? :dot1x : :mab
156
+ AuthenticationConnection.new(key: Normalizer.key(kind, attributes[:mac_address], attributes[:interface], attributes[:bssid]), kind: kind, **attributes)
157
+ end
158
+ end
159
+
160
+ def assign(record, key, value)
161
+ record[key] = case key
162
+ when :ap_id, :radio_id, :client_count then Normalizer.integer(value)
163
+ when :vlan_id, :initial_vlan_id, :authorized_vlan_id then Normalizer.integer(value, max: 4094)
164
+ when :mac_address, :bssid then Normalizer.mac(value)
165
+ when :ip_address, :ipv6_address then Normalizer.ip(value)
166
+ when :online_seconds then Normalizer.duration(value)
167
+ when :rssi then Normalizer.number(value, unit: "dBm")
168
+ when :noise_floor then Normalizer.number(value, unit: "dBm")&.then { |number| number.negative? ? number : nil }
169
+ when :channel_usage
170
+ number = Normalizer.number(value, unit: "%")
171
+ raise InputError, "invalid channel usage" if number && !(0..100).cover?(number)
172
+ number
173
+ else value
174
+ end
175
+ record[:channel_number] = Normalizer.channel(value) if key == :channel
176
+ end
177
+
178
+ def each_field(text, diagnostics)
179
+ text.lines.each_with_index do |line, index|
180
+ next if line.strip.empty? || /\A\s*[-=]+\s*\z/.match?(line)
181
+ match = /\A\s*([^:]+?)\s*:\s*(.*?)\s*\z/.match(line)
182
+ unless match
183
+ diagnostics << { code: :unrecognized_line, line: index + 1 }
184
+ next
185
+ end
186
+ yield match[1].strip.downcase.gsub(/\s+/, " "), Normalizer.value(match[2]), index + 1
187
+ end
188
+ end
189
+
190
+ def total_count(text, dimension)
191
+ noun = { access_points: "APs", clients: "clients", bsses: "BSSs?", dot1x_connections: "(?:connections|users|online users)", mab_connections: "(?:connections|users|online users)" }.fetch(dimension)
192
+ match = /(?:Total(?: number of)?\s+(?:#{noun})|Total)\s*:\s*(\d+)\s*$/i.match(text)
193
+ match && match[1].to_i
194
+ end
195
+
196
+ def summary?(label) = /\A(?:total|number of|connected|remaining|maximum|current number)\b/.match?(label)
197
+
198
+ def safely(diagnostics, line)
199
+ yield
200
+ rescue InputError
201
+ diagnostics << { code: :invalid_field, line: line }.compact
202
+ nil
203
+ end
204
+
205
+ def result(records, diagnostics, total:, terminated:, recognized: !records.empty?)
206
+ diagnostics << { code: :missing_completeness_evidence } unless total || (terminated && recognized)
207
+ status = diagnostics.empty? ? :complete : records.empty? ? :failed : :partial
208
+ ParseResult.new(records: records, status: status, diagnostics: diagnostics)
209
+ end
210
+
211
+ def parse_blocked_clients(text, terminated:)
212
+ records = []
213
+ diagnostics = []
214
+ explicit_zero = false
215
+ text.lines.each_with_index do |line, index|
216
+ next if line.strip.empty? || /\A\s*[-=]+\s*\z/.match?(line)
217
+ if /\A\s*No blocked clients\s*\z/i.match?(line)
218
+ explicit_zero = true
219
+ next
220
+ end
221
+ if /\A\s*(?:MAC address(?:\s+.*)?|(?:Client )?block list|Blocked clients|Total(?: number of)?(?:\s+\w+)*\s*:\s*\d+)\s*\z/i.match?(line)
222
+ next
223
+ end
224
+ match = /\A\s*((?:[0-9a-f]{4}-){2}[0-9a-f]{4}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})\s*(.*?)\s*\z/i.match(line)
225
+ if match
226
+ mac = Normalizer.mac(match[1])
227
+ records << { key: Normalizer.key(mac), mac_address: mac, reason: match[2] }
228
+ else
229
+ diagnostics << { code: :unrecognized_line, line: index + 1 }
230
+ end
231
+ end
232
+ count = /Total(?: number of)?(?:\s+\w+)*\s*:\s*(\d+)/i.match(text)&.[](1)&.to_i
233
+ count ||= 0 if explicit_zero
234
+ diagnostics << { code: :count_mismatch, expected: count, parsed: records.size } if count && count != records.size
235
+ result(records, diagnostics, total: count, terminated: terminated)
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ module Huawei
6
+ # Only the AP and station table formats evidenced by PDK are supported.
7
+ class RuntimeParser
8
+ def parse(dimension, content, terminated: false)
9
+ raise InputError, "unsupported Huawei wireless dimension" unless %i[access_points clients].include?(dimension.to_sym)
10
+
11
+ text = Normalizer.text(content)
12
+ records = []
13
+ diagnostics = []
14
+ header = false
15
+ total = nil
16
+ text.lines.each_with_index do |line, index|
17
+ row = line.strip
18
+ next if row.empty? || /\A[-=]+\z/.match?(row)
19
+ if (match = /\ATotal\s*:\s*(\d+)\s*\z/i.match(row))
20
+ total = match[1].to_i
21
+ next
22
+ end
23
+ if dimension.to_sym == :access_points ? /\AID\s+MAC\s+Name\s+Group\s+IP\s+Type\s+State\s+STA\s+Uptime\b/i.match?(row) : /\ASTA MAC\s+AP ID\s+Ap name\s+Rf\/WLAN\b/i.match?(row)
24
+ header = true
25
+ next
26
+ end
27
+ next if /\ATotal AP information:|\A(?:nor|fault|idle|stdby)\s*:/i.match?(row)
28
+ unless header
29
+ diagnostics << { code: :missing_header, line: index + 1 }
30
+ next
31
+ end
32
+ begin
33
+ records << (dimension.to_sym == :access_points ? access_point(row) : client(row))
34
+ rescue InputError
35
+ diagnostics << { code: :invalid_row, line: index + 1 }
36
+ end
37
+ end
38
+ diagnostics << { code: :missing_header } unless header
39
+ diagnostics << { code: :count_mismatch, expected: total, parsed: records.size } if total && total != records.size
40
+ diagnostics << { code: :missing_completeness_evidence } unless total || (terminated && header && !records.empty?)
41
+ ParseResult.new(records: records, status: diagnostics.empty? ? :complete : records.empty? ? :failed : :partial, diagnostics: diagnostics)
42
+ rescue InputError => error
43
+ ParseResult.new(records: [], status: :failed, diagnostics: [{ code: :invalid_output, error: error.code }])
44
+ end
45
+
46
+ private
47
+
48
+ def access_point(row)
49
+ columns = row.split(/\s+/)
50
+ raise InputError, "invalid AP table row" unless columns.size >= 9
51
+
52
+ id, mac, name, group, ip, model, state, clients, uptime = columns
53
+ state = { "nor" => "Normal", "normal" => "Normal", "fault" => "Fault", "idle" => "Idle", "stdby" => "Standby" }.fetch(state.downcase, state)
54
+ AccessPoint.new(key: Normalizer.key(name), name: name, ap_id: Normalizer.integer(id),
55
+ mac_address: Normalizer.mac(mac), group_name: group, ip_address: Normalizer.ip(Normalizer.value(ip)),
56
+ model: model, state: state, client_count: Normalizer.integer(clients), online_seconds: Normalizer.duration(Normalizer.value(uptime)))
57
+ end
58
+
59
+ def client(row)
60
+ columns = row.split(/\s+/)
61
+ raise InputError, "invalid station table row" unless columns.size == 15
62
+
63
+ mac, ap_id, ap_name, radio_wlan, band, type, rates, rssi, vlan, ipv4, ssid, state, ipv6, ac_id, online = columns
64
+ raise InputError, "invalid station identity" unless /\A\d+\/\d+\z/.match?(radio_wlan)
65
+
66
+ build_client(mac, ap_id, ap_name, radio_wlan, band, type, rates, rssi, vlan, ipv4, ssid, state, ipv6, ac_id, online)
67
+ end
68
+
69
+ def build_client(mac, ap_id, ap_name, radio_wlan, band, type, rates, rssi, vlan, ipv4, ssid, state, ipv6, ac_id, online)
70
+ address = Normalizer.mac(mac)
71
+ rx, tx = Normalizer.rate_pair(rates)
72
+ signal = Normalizer.number(Normalizer.value(rssi), unit: "dBm")
73
+ signal = nil if signal == 0
74
+ Client.new(key: Normalizer.key(address), mac_address: address, ap_id: Normalizer.integer(ap_id), ap_name: ap_name,
75
+ radio_id: Normalizer.integer(radio_wlan.split("/").first), ssid: ssid, vlan_id: Normalizer.integer(vlan, max: 4094),
76
+ ip_address: Normalizer.ip(Normalizer.value(ipv4)), ipv6_address: Normalizer.ip(Normalizer.value(ipv6)),
77
+ rssi: signal, rssi_scale: :dbm, online_seconds: Normalizer.duration(Normalizer.value(online)), rx_rate_bps: rx, tx_rate_bps: tx,
78
+ attributes: { band: band, wireless_mode: type, state: state, wlan_id: radio_wlan.split("/").last.to_i, ac_id: Normalizer.integer(ac_id) })
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class InvalidSnapshot < InputError
6
+ def initialize(message = "invalid wireless snapshot", **options)
7
+ super(message, code: :invalid_wireless_snapshot, **options)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Neighbor < Data.define(:key, :ap_name, :local_interface, :remote_chassis_id, :remote_interface, :remote_name, :management_address, :attributes)
6
+ def initialize(key:, ap_name:, local_interface:, remote_chassis_id:, remote_interface:, remote_name: nil, management_address: nil, attributes: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, ap_name: ap_name, local_interface: local_interface, remote_chassis_id: remote_chassis_id, remote_interface: remote_interface, remote_name: remote_name, management_address: management_address, attributes: attributes))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Netstack
6
+ module Wireless
7
+ module Normalizer
8
+ module_function
9
+
10
+ def text(content)
11
+ raise InputError, "wireless output must be text" unless content.is_a?(String)
12
+
13
+ result = content.dup.force_encoding(Encoding::UTF_8)
14
+ raise InputError, "invalid wireless output encoding" unless result.valid_encoding? && !result.include?("\0")
15
+
16
+ result.gsub(/\r\n?/, "\n")
17
+ end
18
+
19
+ def value(text)
20
+ normalized = text.strip
21
+ %w[N/A -- -].include?(normalized) || normalized.empty? ? nil : normalized
22
+ end
23
+
24
+ def mac(value)
25
+ return nil if value.nil?
26
+ unless /\A(?:[0-9a-f]{12}|(?:[0-9a-f]{4}[-.]){2}[0-9a-f]{4}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})\z/i.match?(value)
27
+ raise InputError, "invalid wireless MAC address"
28
+ end
29
+
30
+ value.delete("-.:").downcase.scan(/../).join(":")
31
+ end
32
+
33
+ def ip(value)
34
+ return nil if value.nil?
35
+ raise InputError, "invalid wireless IP address" if value.include?("/") || value.include?("%")
36
+
37
+ IPAddr.new(value).to_s
38
+ rescue IPAddr::InvalidAddressError
39
+ raise InputError, "invalid wireless IP address"
40
+ end
41
+
42
+ def integer(value, max: nil)
43
+ return nil if value.nil?
44
+
45
+ Values.integer(value, name: "wireless integer", min: 0, max: max)
46
+ end
47
+
48
+ def number(value, unit: nil)
49
+ return nil if value.nil?
50
+
51
+ pattern = unit ? /\A(-?\d+(?:\.\d+)?)\s*(?:#{Regexp.escape(unit)})?\z/i : /\A(-?\d+(?:\.\d+)?)\z/
52
+ match = pattern.match(value.to_s)
53
+ raise InputError, "invalid wireless number" unless match
54
+
55
+ Float(match[1])
56
+ end
57
+
58
+ def duration(value)
59
+ return nil if value.nil?
60
+ return value.split(":").map(&:to_i).zip([86_400, 3600, 60, 1]).sum { |a, b| a * b } if /\A\d+:\d{2}:\d{2}:\d{2}\z/.match?(value)
61
+
62
+ parts = value.scan(/(\d+)\s*(days?|hours?|minutes?|seconds?|[dhms])\b/i)
63
+ rest = value.gsub(/\d+\s*(?:days?|hours?|minutes?|seconds?|[dhms])\b/i, "").delete(" :,")
64
+ raise InputError, "invalid wireless duration" if parts.empty? || !rest.empty?
65
+
66
+ parts.sum { |n, unit| n.to_i * { "d" => 86_400, "h" => 3600, "m" => 60, "s" => 1 }.fetch(unit[0].downcase) }
67
+ end
68
+
69
+ def rate_pair(value, default: "Mbps")
70
+ match = /\A(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*([kmgt]?bps)?\z/i.match(value)
71
+ raise InputError, "invalid wireless rate" unless match
72
+
73
+ multiplier = { "bps" => 1, "kbps" => 1000, "mbps" => 1000_000, "gbps" => 1000_000_000, "tbps" => 1000_000_000_000 }.fetch((match[3] || default).downcase)
74
+ [match[1], match[2]].map { |number| (Float(number) * multiplier).round }
75
+ end
76
+
77
+ # A length-prefixed composite key cannot collide when names contain punctuation.
78
+ def key(*parts) = parts.map { |part| text = part.to_s; "#{text.bytesize}:#{text}" }.join("|")
79
+
80
+ def channel(value)
81
+ return nil if value.nil? || /\Aauto\z/i.match?(value)
82
+
83
+ match = /\A(\d+)(?:\s*\([^\n]*\))?\z/.match(value)
84
+ raise InputError, "invalid wireless channel" unless match
85
+
86
+ integer(match[1], max: 233)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class ParseResult < Data.define(:records, :status, :diagnostics)
6
+ def initialize(records:, status:, diagnostics: [])
7
+ raise InputError, "invalid parser status" unless %i[complete partial failed].include?(status)
8
+
9
+ super(records: Values.copy(records), status: status, diagnostics: Values.copy(diagnostics))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Radio < Data.define(:key, :ap_name, :radio_id, :admin_state, :radio_type, :channel, :channel_number, :channel_usage, :noise_floor, :configuration)
6
+ def initialize(key:, ap_name:, radio_id:, admin_state: nil, radio_type: nil, channel: nil, channel_number: nil, channel_usage: nil, noise_floor: nil, configuration: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, ap_name: ap_name, radio_id: radio_id, admin_state: admin_state, radio_type: radio_type, channel: channel, channel_number: channel_number, channel_usage: channel_usage, noise_floor: noise_floor, configuration: configuration))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ # 生成新的保留状态而不持久化;部分观察只合并已知字段,
6
+ # 不能删除缺失身份;时间水位用于拒绝过期输入。
7
+ class Reconciler
8
+ def call(previous:, observed:)
9
+ raise InvalidSnapshot, "wireless observation must be a Snapshot" unless observed.is_a?(Snapshot)
10
+ previous = State.from_snapshot(previous) if previous.is_a?(Snapshot)
11
+ previous ||= State.new(target: observed.target)
12
+ raise InvalidSnapshot, "wireless state must be a State" unless previous.is_a?(State)
13
+ if previous.target.key != observed.target.key || previous.target.platform != observed.target.platform || previous.target.host != observed.target.host
14
+ raise InvalidSnapshot, "wireless snapshots belong to different controllers"
15
+ end
16
+
17
+ records = previous.records.dup
18
+ watermarks = previous.watermarks.dup
19
+ changes = {}
20
+ observed.dimensions.each do |dimension|
21
+ prior = (records[dimension.name] || []).to_h { |record| [key(record), record] }
22
+ if dimension.status != :not_requested
23
+ raise InvalidSnapshot, "wireless observation has no collection time" unless dimension.completed_at
24
+ if watermarks[dimension.name] && dimension.completed_at < watermarks[dimension.name]
25
+ raise InvalidSnapshot, "stale wireless dimension"
26
+ end
27
+ watermarks[dimension.name] = dimension.completed_at
28
+ end
29
+ same_time = previous.watermarks[dimension.name] && dimension.completed_at == previous.watermarks[dimension.name]
30
+ incoming = dimension.records.to_h { |record| [key(record), record] }
31
+ if dimension.partial?
32
+ incoming = incoming.transform_values do |record|
33
+ old = prior[key(record)]
34
+ old ? merge_record(old, record) : record
35
+ end
36
+ end
37
+ next_records = if dimension.complete?
38
+ incoming
39
+ elsif dimension.partial?
40
+ prior.merge(incoming)
41
+ else
42
+ prior
43
+ end
44
+ raise InvalidSnapshot, "conflicting wireless observations at the same time" if same_time && next_records != prior
45
+ changes[dimension.name] = {
46
+ added: next_records.keys - prior.keys,
47
+ updated: (next_records.keys & prior.keys).select { |identity| next_records[identity] != prior[identity] },
48
+ removed: prior.keys - next_records.keys,
49
+ retained: prior.keys - incoming.keys,
50
+ status: dimension.status
51
+ }
52
+ records[dimension.name] = next_records.values
53
+ end
54
+ state = State.new(target: observed.target, records: records, watermarks: watermarks)
55
+ Reconciliation.new(state: state, changes: changes, observation: observed)
56
+ end
57
+
58
+ private
59
+
60
+ def key(record) = record.is_a?(Hash) ? record.fetch(:key) : record.key
61
+
62
+ def merge_record(old, current)
63
+ return old.merge(current.reject { |_, value| value.nil? }) if old.is_a?(Hash)
64
+ if current.is_a?(Configuration)
65
+ return Configuration.new(access_points: merge_hash(old.access_points, current.access_points),
66
+ ap_groups: merge_records(old.ap_groups, current.ap_groups), service_templates: merge_records(old.service_templates, current.service_templates),
67
+ global_settings: merge_hash(old.global_settings, current.global_settings),
68
+ blacklist: merge_mac_entries(old.blacklist, current.blacklist),
69
+ whitelist: merge_mac_entries(old.whitelist, current.whitelist))
70
+ end
71
+ current.class.new(**old.to_h.merge(current.to_h) do |_, before, after|
72
+ if after.nil?
73
+ before
74
+ elsif before.is_a?(Hash) && after.is_a?(Hash)
75
+ merge_hash(before, after)
76
+ elsif before.is_a?(Array) && after.is_a?(Array)
77
+ (before + after).uniq
78
+ else
79
+ after
80
+ end
81
+ end)
82
+ end
83
+
84
+ def merge_records(old, current)
85
+ values = old.to_h { |record| [record.key, record] }
86
+ current.each { |record| values[record.key] = values[record.key] ? merge_record(values[record.key], record) : record }
87
+ values.values
88
+ end
89
+
90
+ # 部分名单按 MAC 更新已观测字段,保留缺失字段和未观测身份,不能授权删除。
91
+ def merge_mac_entries(old, current)
92
+ values = old.to_h { |record| [record.fetch(:mac_address), record] }
93
+ current.each do |record|
94
+ identity = record.fetch(:mac_address)
95
+ values[identity] = values[identity] ? merge_record(values[identity], record) : record
96
+ end
97
+ values.values
98
+ end
99
+
100
+ def merge_hash(old, current)
101
+ old.merge(current) do |field, before, after|
102
+ if field == :service_templates && before.is_a?(Array) && after.is_a?(Array)
103
+ ServiceTemplateBindings.merge(before, after)
104
+ elsif before.is_a?(Hash) && after.is_a?(Hash)
105
+ merge_hash(before, after)
106
+ elsif before.is_a?(Array) && after.is_a?(Array)
107
+ (before + after).uniq
108
+ else
109
+ after.nil? ? before : after
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Reconciliation < Data.define(:state, :changes, :observation)
6
+ def initialize(state:, changes:, observation:)
7
+ super(state: Values.copy(state), changes: Values.copy(changes), observation: Values.copy(observation))
8
+ end
9
+ end
10
+ end
11
+ end