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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Netstack
6
+ # 按来源和范围组织观察结果与变更建议;持久化由宿主负责。
7
+ module Inventory
8
+ # 管理代码块内的设备连接,只读取已请求且受支持的维度。
9
+ # 单个维度失败不会使其他已完成的观察失效。
10
+ class Collector
11
+ DIMENSIONS = %i[device interfaces transceivers neighbors].freeze
12
+
13
+ def initialize(connector: Connector, parser: CliParser.new, clock: -> { Time.now.utc })
14
+ @connector = connector
15
+ @parser = parser
16
+ @clock = clock
17
+ end
18
+
19
+ def collect(target:, dimensions: %i[device interfaces neighbors], connection_options: {}, scope: {})
20
+ raise InputError, "collection target must be a Target" unless target.is_a?(Target)
21
+ unless dimensions.is_a?(Array) && dimensions.all? { |name| DIMENSIONS.include?(name) } && dimensions.uniq == dimensions
22
+ raise InputError, "unknown or duplicate inventory dimensions"
23
+ end
24
+ unless connection_options.is_a?(Hash) && !connection_options.key?(:host) && !connection_options.key?("host")
25
+ raise InputError, "connection options cannot override the target host"
26
+ end
27
+ result = DIMENSIONS.to_h { |name| [name, Dimension.new(name: name, status: :not_requested)] }
28
+ scope = Snapshot.new(target: target, source: "cli", dimensions: result, scope: scope).scope
29
+ dimensions.each { |name| result[name] = Dimension.new(name: name, status: :unsupported) }
30
+ supported = dimensions.select { |name| name == :neighbors || (name != :transceivers && @parser.supports?(target.platform)) }
31
+ return Snapshot.new(target: target, source: "cli", dimensions: result, scope: scope) if supported.empty?
32
+
33
+ begin
34
+ @connector.open(target.platform, host: target.host, **connection_options) do |device|
35
+ configuration = nil
36
+ configuration_error = nil
37
+ configuration_started = @clock.call
38
+ if (supported & %i[device interfaces]).any?
39
+ begin
40
+ configuration = device.running_config.value!
41
+ rescue StandardError => error
42
+ configuration_error = error
43
+ end
44
+ end
45
+ supported.each do |name|
46
+ next if name == :neighbors && !device.supports?(:neighbors)
47
+
48
+ started = name == :neighbors ? @clock.call : configuration_started
49
+ begin
50
+ raise configuration_error if configuration_error && name != :neighbors
51
+
52
+ records = collect_dimension(name, device, target, configuration)
53
+ if scope.key?(name)
54
+ keys = scope.fetch(name)
55
+ records = records.select { |record| keys.include?(record.key) }
56
+ end
57
+ evidence = name == :neighbors ? [] : [{ kind: :configuration, sha256: Digest::SHA256.hexdigest(configuration) }]
58
+ result[name] = Dimension.new(name: name, status: :complete, records: records, started_at: started,
59
+ completed_at: @clock.call, evidence: evidence)
60
+ rescue StandardError => error
61
+ result[name] = failure(name, error, started)
62
+ end
63
+ end
64
+ end
65
+ rescue StandardError => error
66
+ supported.each do |name|
67
+ result[name] = failure(name, error, @clock.call) unless result[name].complete?
68
+ end
69
+ end
70
+ Snapshot.new(target: target, source: "cli", dimensions: result, scope: scope)
71
+ end
72
+
73
+ private
74
+
75
+ def collect_dimension(name, device, target, configuration)
76
+ case name
77
+ when :device
78
+ command = target.platform.start_with?("cisco") ? "show version" : "display version"
79
+ version = device.execute_command(command).value!
80
+ [@parser.device(configuration: configuration, version: version, target: target)]
81
+ when :interfaces
82
+ @parser.interfaces(configuration: configuration, platform: target.platform)
83
+ when :neighbors
84
+ device.neighbors.map do |neighbor|
85
+ Neighbor.new(local_interface: neighbor.local_interface, remote_name: neighbor.neighbor_name,
86
+ remote_interface: neighbor.neighbor_interface, chassis_id: neighbor.chassis_id,
87
+ attributes: { protocol: neighbor.protocol })
88
+ end
89
+ end
90
+ end
91
+
92
+ def failure(name, error, started)
93
+ code = error.respond_to?(:code) ? error.code : :collection_failed
94
+ Dimension.new(name: name, status: :failed, started_at: started, completed_at: @clock.call,
95
+ diagnostics: [{ code: code }])
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class Device < Data.define(:key, :name, :serial, :model, :vendor, :os_version, :attributes)
6
+ def initialize(key:, name: nil, serial: nil, model: nil, vendor: nil, os_version: nil, attributes: {})
7
+ super(key: Values.text(key, name: "device key"), name: Values.text(name, name: "device name", optional: true),
8
+ serial: Values.text(serial, name: "serial", optional: true), model: Values.text(model, name: "model", optional: true),
9
+ vendor: Values.text(vendor, name: "vendor", optional: true), os_version: Values.text(os_version, name: "OS version", optional: true),
10
+ attributes: Values.copy(attributes))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class Interface < Data.define(:name, :description, :admin_status, :oper_status, :addresses, :attributes)
6
+ def initialize(name:, description: nil, admin_status: :unknown, oper_status: :unknown, addresses: [], attributes: {})
7
+ statuses = [admin_status, oper_status].map do |status|
8
+ status = status.to_sym if status.is_a?(String)
9
+ raise InputError, "invalid interface status" unless %i[up down unknown].include?(status)
10
+
11
+ status
12
+ end
13
+ unless addresses.is_a?(Array) && addresses.all? { |address| address.is_a?(Address) }
14
+ raise InputError, "interface addresses must be Address values"
15
+ end
16
+ raise InputError, "description must be text or nil" unless description.nil? || description.is_a?(String)
17
+
18
+ super(name: Values.text(name, name: "interface name"), description: Values.copy(description),
19
+ admin_status: statuses[0], oper_status: statuses[1], addresses: addresses.dup.freeze, attributes: Values.copy(attributes))
20
+ end
21
+
22
+ def key = name.downcase
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class Neighbor < Data.define(:local_interface, :remote_name, :remote_interface, :chassis_id, :attributes)
6
+ def initialize(local_interface:, remote_name:, remote_interface:, chassis_id: nil, attributes: {})
7
+ super(local_interface: Values.text(local_interface, name: "local interface"), remote_name: Values.text(remote_name, name: "remote name"),
8
+ remote_interface: Values.text(remote_interface, name: "remote interface"),
9
+ chassis_id: Values.text(chassis_id, name: "chassis id", optional: true), attributes: Values.copy(attributes))
10
+ end
11
+
12
+ def key = [local_interface.downcase, chassis_id || remote_name.downcase, remote_interface.downcase].freeze
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ # 只计算变更,不写入宿主记录;仅完整维度可以提出删除,
6
+ # 并且删除必须限定在采集来源与明确的身份范围内。
7
+ class Reconciler
8
+ # current contains host records keyed by dimension. Fields owned by people stay with the host.
9
+ # A row has :key, :attributes, :managed_fields and :source; collection never guesses ownership.
10
+ def call(current:, snapshot:, target:)
11
+ unless snapshot.is_a?(Snapshot) && target.is_a?(Target) &&
12
+ target == snapshot.target
13
+ raise InputError, "snapshot target has changed"
14
+ end
15
+ raise InputError, "current inventory must be a Hash" unless current.is_a?(Hash)
16
+
17
+ changes = {}
18
+ skipped = {}
19
+ snapshot.dimensions.each do |name, dimension|
20
+ unless dimension.complete?
21
+ skipped[name] = dimension.status
22
+ next
23
+ end
24
+ rows = current.fetch(name, [])
25
+ validate_rows!(rows)
26
+ indexed = rows.to_h { |row| [identity(name, row.fetch(:key)), row] }
27
+ raise InputError, "duplicate current inventory identity" unless indexed.size == rows.size
28
+
29
+ updates = []
30
+ creates = []
31
+ observed = {}
32
+ dimension.records.each do |record|
33
+ key = identity(name, record.key)
34
+ existing = indexed[key]
35
+ attributes = record.to_h.reject { |field, _| field == :key }
36
+ if existing && existing[:source] == snapshot.source
37
+ fields = existing.fetch(:managed_fields)
38
+ desired = existing.fetch(:attributes).merge(attributes.select { |field, _| fields.include?(field) })
39
+ # Host record identity and display spelling remain stable for interface references.
40
+ desired[:name] = existing[:attributes][:name] if name == :interfaces && existing[:attributes].key?(:name)
41
+ updates << { key: existing[:key], attributes: desired } if desired != existing[:attributes]
42
+ elsif !existing
43
+ creates << { key: record.key, attributes: attributes, source: snapshot.source }
44
+ end
45
+ observed[key] = true
46
+ end
47
+ scope = snapshot.scope[name]&.to_h { |key| [key, true] }
48
+ removals = rows.filter_map do |row|
49
+ key = identity(name, row[:key])
50
+ in_scope = scope.nil? || scope.key?(key)
51
+ row[:key] if in_scope && row[:source] == snapshot.source && !observed.key?(key)
52
+ end
53
+ changes[name] = { create: creates, update: updates, remove: removals }
54
+ end
55
+ Changes.new(target: target, source: snapshot.source, dimensions: changes, skipped: skipped)
56
+ end
57
+
58
+ private
59
+
60
+ def identity(name, key)
61
+ Snapshot.identity(name, key)
62
+ end
63
+
64
+ def validate_rows!(rows)
65
+ unless rows.is_a?(Array) && rows.all? do |row|
66
+ row.is_a?(Hash) && row.key?(:key) && row[:attributes].is_a?(Hash) &&
67
+ row[:managed_fields].is_a?(Array) && row[:managed_fields].all? { |field| field.is_a?(Symbol) } && row[:source].is_a?(String)
68
+ end
69
+ raise InputError, "invalid current inventory records"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ # 同时校验记录身份、目标归属和采集范围。
6
+ # 未提供范围表示整个维度;空范围表示不包含任何身份。
7
+ class Snapshot < Data.define(:target, :source, :dimensions, :scope)
8
+ RECORD_TYPES = { device: Device, interfaces: Interface, transceivers: Transceiver, neighbors: Neighbor }.freeze
9
+
10
+ def initialize(target:, source:, dimensions:, scope: {})
11
+ raise InputError, "snapshot target must be a Target" unless target.is_a?(Target)
12
+ raise InputError, "snapshot dimensions must be a Hash" unless dimensions.is_a?(Hash)
13
+ raise InputError, "snapshot scope must be a Hash" unless scope.is_a?(Hash)
14
+
15
+ normalized_scope = scope.each_with_object({}) do |(name, keys), result|
16
+ name = name.to_sym if name.is_a?(String)
17
+ unless RECORD_TYPES.key?(name) && keys.is_a?(Array) && !result.key?(name)
18
+ raise InputError, "scope must contain dimension identity arrays"
19
+ end
20
+ result[name] = keys.map { |key| self.class.identity(name, key) }
21
+ raise InputError, "duplicate inventory scope identity" unless result[name].uniq.size == result[name].size
22
+ end
23
+
24
+ normalized = {}
25
+ dimensions.each do |name, dimension|
26
+ name = name.to_sym if name.is_a?(String)
27
+ unless RECORD_TYPES.key?(name) && dimension.is_a?(Dimension) && dimension.name == name && !normalized.key?(name)
28
+ raise InputError, "invalid snapshot dimension"
29
+ end
30
+ unless dimension.records.all? { |record| record.is_a?(RECORD_TYPES.fetch(name)) }
31
+ raise InputError, "invalid inventory record type"
32
+ end
33
+ identities = dimension.records.map(&:key)
34
+ raise InputError, "duplicate inventory identity" unless identities.uniq.size == identities.size
35
+ if name == :device && dimension.records.any? { |record| record.key != target.key }
36
+ raise InputError, "device record does not belong to target"
37
+ end
38
+ if name == :device && dimension.complete? && dimension.records.size != 1
39
+ raise InputError, "a complete device dimension must identify the target"
40
+ end
41
+ if normalized_scope.key?(name) && (identities - normalized_scope[name]).any?
42
+ raise InputError, "inventory contains records outside the collected scope"
43
+ end
44
+
45
+ normalized[name] = dimension
46
+ end
47
+ super(target: target, source: Values.text(source, name: "snapshot source"), dimensions: normalized.freeze, scope: Values.copy(normalized_scope))
48
+ end
49
+
50
+ def self.identity(name, key)
51
+ case name
52
+ when :device then Values.text(key, name: "device identity")
53
+ when :interfaces then Values.text(key, name: "interface identity").downcase.freeze
54
+ when :transceivers then Values.integer(key, name: "transceiver identity", min: 1)
55
+ when :neighbors
56
+ raise InputError, "neighbor identity must contain three text components" unless key.is_a?(Array) && key.size == 3
57
+ key.each { |part| Values.text(part, name: "neighbor identity") }
58
+ [key[0].strip.downcase, key[1].strip, key[2].strip.downcase].map(&:freeze).freeze
59
+ else raise InputError, "unknown inventory identity dimension"
60
+ end
61
+ end
62
+
63
+ def complete?
64
+ requested = dimensions.values.reject { |dimension| dimension.status == :not_requested }
65
+ !requested.empty? && requested.all?(&:complete?)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class Transceiver < Data.define(:interface_index, :interface_name, :vendor, :model, :serial, :measurements)
6
+ def initialize(interface_index:, interface_name:, vendor: nil, model: nil, serial: nil, measurements: {})
7
+ raise InputError, "transceiver measurements must be a Hash" unless measurements.is_a?(Hash)
8
+
9
+ measurements.each_value do |value|
10
+ unless value.nil? || (value.is_a?(Numeric) && value.real? && value.finite?)
11
+ raise InputError, "measurements must be finite numbers or nil"
12
+ end
13
+ end
14
+ super(interface_index: Values.integer(interface_index, name: "interface index", min: 1),
15
+ interface_name: Values.text(interface_name, name: "interface name"), vendor: Values.text(vendor, name: "vendor", optional: true),
16
+ model: Values.text(model, name: "model", optional: true), serial: Values.text(serial, name: "serial", optional: true),
17
+ measurements: Values.copy(measurements))
18
+ end
19
+
20
+ def key = interface_index
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "range_set"
5
+
6
+ module Netstack
7
+ # 在标准 IPAddr 能力上补充防火墙所需的 IPv4 和服务范围运算。
8
+ class IPv4 < ::IPAddr
9
+ IPV4_MAX = ::IPAddr::IN4MASK
10
+ PORT_MAX = (1 << 16) - 1
11
+ SERVICE_MAX = (1 << 24) - 1
12
+ # 服务编码中反复使用的协议号集中命名,避免上层散落裸数字。
13
+ ANY_PROTOCOL = 0
14
+ ICMP = 1
15
+ TCP = 6
16
+ UDP = 17
17
+ PROTOCOL_NUMBERS = { "tcp" => TCP, "udp" => UDP, "icmp" => ICMP,
18
+ "ms-rpc-tcp" => TCP, "ms-rpc-udp" => UDP }.freeze
19
+
20
+ # 规范化 IPv4 输入,并拒绝 IPv6、非法前缀及非十进制设备写法。
21
+ def initialize(address = "0.0.0.0", family = Socket::AF_INET)
22
+ unless [ Socket::AF_INET, Socket::AF_UNSPEC ].include?(family)
23
+ raise ArgumentError, "IPv4 helpers require IPv4"
24
+ end
25
+ if address.is_a?(String)
26
+ address = address.sub(/\Aany\z/i, "0.0.0.0/0").sub(/\Aany(?=\/)/i, "0.0.0.0")
27
+ unless /\A[0-9]+(?:\.[0-9]+){3}(?:\/(?:[0-9]+|[0-9]+(?:\.[0-9]+){3}))?\z/.match?(address)
28
+ raise ArgumentError, "Invalid IPv4 expression: #{address.inspect}"
29
+ end
30
+ # 设备格式把带前导零的八位组和前缀都按十进制解释。
31
+ address = address.gsub(/\b0+(?=[0-9])/, "")
32
+ elsif !address.is_a?(Integer)
33
+ raise ArgumentError, "IPv4 input must be a string or integer"
34
+ end
35
+ super(address, Socket::AF_INET)
36
+ end
37
+
38
+ # 把地址转换成包含首尾整数的标准集合,供范围运算复用。
39
+ def to_set
40
+ raise ArgumentError, "IPv4 helpers require IPv4" unless ipv4?
41
+ range = to_range
42
+ RangeSet.new(range.begin.to_i..range.end.to_i)
43
+ end
44
+
45
+ class << self
46
+ # 接口地址保留主机位;不能直接用 IPAddr.new("host/prefix").to_s。
47
+ def host_cidr(expression, mask = nil)
48
+ host, prefix = string(expression, label: "Interface address").split("/", 2)
49
+ "#{from_i(to_i(host))}/#{prefix_length(mask || prefix || 32)}"
50
+ end
51
+
52
+ # 路由目的统一归一化为网络地址和前缀。
53
+ def network_cidr(expression, mask = nil)
54
+ address = new(host_cidr(expression, mask))
55
+ "#{address}/#{address.prefix}"
56
+ end
57
+
58
+ # 取接口 CIDR 中的主机地址,供 NAT 出口使用。
59
+ def host(expression) = host_cidr(expression).split("/", 2).first
60
+
61
+ # 把不带前缀的 IPv4 文本转换为整数。
62
+ def to_i(address)
63
+ unless address.is_a?(String) && !address.include?("/")
64
+ raise ArgumentError, "Invalid IPv4 address: #{address.inspect}"
65
+ end
66
+ new(address).to_i
67
+ end
68
+
69
+ # 把 IPv4 整数转换为点分十进制文本。
70
+ def from_i(number)
71
+ value = integer(number, max: IPV4_MAX, label: "IPv4 integer")
72
+ new(value).to_s
73
+ end
74
+
75
+ # 把前缀长度或掩码转换为规范前缀长度。
76
+ def prefix_length(mask)
77
+ mask = integer(mask, max: 32, label: "Prefix length") unless mask.is_a?(String)
78
+ new("0.0.0.0/#{mask}").prefix
79
+ end
80
+
81
+ # 根据前缀长度生成点分十进制网络掩码。
82
+ def netmask(mask) = new(IPV4_MAX).mask(prefix_length(mask)).to_s
83
+
84
+ # 把通配符掩码转换为网络掩码。
85
+ def wildcard_mask(wildcard)
86
+ unless wildcard.is_a?(String) && /\A[0-9]+(?:\.[0-9]+){3}\z/.match?(wildcard)
87
+ raise ArgumentError, "Invalid IPv4 wildcard"
88
+ end
89
+ (~new(wildcard)).to_s
90
+ end
91
+
92
+ # 根据地址和掩码计算覆盖的整数地址集合。
93
+ def subnet(address, mask = 32)
94
+ new(to_i(address)).mask(prefix_length(mask.nil? ? 32 : mask)).to_set
95
+ end
96
+
97
+ # 返回子网的网络地址文本。
98
+ def network(address, mask = 32) = from_i(subnet(address, mask).min)
99
+
100
+ # 解析主机、CIDR 或网络掩码表达式,也支持两个 IPv4 端点。
101
+ def parse(expression)
102
+ text = string(expression, label: "IPv4 expression")
103
+ parts = text.split("-", -1)
104
+ return between(*parts) if parts.size == 2
105
+ raise ArgumentError, "Invalid IPv4 expression: #{text}" unless parts.size == 1
106
+ new(text).to_set
107
+ end
108
+
109
+ # 用两个端点创建有序的闭区间。
110
+ def between(first, last)
111
+ low, high = [ to_i(first), to_i(last) ].minmax
112
+ RangeSet.new(low..high)
113
+ end
114
+
115
+ # 将地址范围精确拆成 CIDR,供持久化与命令生成共用。
116
+ def cidrs(set)
117
+ unless set.is_a?(RangeSet) && (set.empty? || set.max <= IPV4_MAX)
118
+ raise ArgumentError, "Expected IPv4 integer ranges"
119
+ end
120
+ set.flat_map do |range|
121
+ cursor, last = range.begin, range.end
122
+ blocks = []
123
+ while cursor <= last
124
+ size = 1
125
+ size *= 2 while (cursor % (size * 2)).zero? && (cursor + (size * 2) - 1) <= last
126
+ blocks << [ IPv4.from_i(cursor), 32 - (size.bit_length - 1) ]
127
+ cursor += size
128
+ end
129
+ blocks
130
+ end
131
+ end
132
+
133
+ # 把整数区间压缩成最短的单个网段或端点表达式。
134
+ def format_range(range)
135
+ unless range.is_a?(Range) && !range.exclude_end? && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
136
+ raise ArgumentError, "IPv4 notation requires a closed integer range"
137
+ end
138
+ integer(range.begin, max: IPV4_MAX, label: "IPv4 lower bound")
139
+ integer(range.end, max: IPV4_MAX, label: "IPv4 upper bound")
140
+ raise ArgumentError, "IPv4 range is reversed" if range.begin > range.end
141
+ size = range.end - range.begin + 1
142
+ first = from_i(range.begin)
143
+ if (size & (size - 1)).zero? && (range.begin % size).zero?
144
+ "#{first}/#{32 - (size.bit_length - 1)}"
145
+ else
146
+ "#{first}-#{from_i(range.end)}"
147
+ end
148
+ end
149
+
150
+ # 解析单个端口或端口范围,并限制在 16 位端口空间内。
151
+ def ports(expression)
152
+ if expression.is_a?(Range)
153
+ result = RangeSet.new(expression)
154
+ raise ArgumentError, "Port must be in 0..#{PORT_MAX}" if result.max > PORT_MAX
155
+ return result
156
+ end
157
+ text = expression.is_a?(Integer) ? expression.to_s : string(expression, label: "Port")
158
+ match = /\A\s*([0-9]+)(?:(?:\s*-\s*|\s+)([0-9]+))?\s*\z/.match(text)
159
+ raise ArgumentError, "Invalid port expression: #{text}" unless match
160
+ first, last = [ match[1], match[2] || match[1] ].map do |endpoint|
161
+ integer(endpoint, max: PORT_MAX, label: "Port")
162
+ end.minmax
163
+ RangeSet.new(first..last)
164
+ end
165
+
166
+ # 把协议名称或编号转换成服务编码使用的协议编号。
167
+ def protocol_number(protocol)
168
+ protocol = protocol.to_s if protocol.is_a?(Integer) || protocol.is_a?(Symbol)
169
+ name = string(protocol, label: "Protocol").downcase
170
+ return ANY_PROTOCOL if name == "any"
171
+ PROTOCOL_NUMBERS.fetch(name) { integer(name, max: 255, label: "Protocol number") }
172
+ end
173
+
174
+ # 服务编码的高八位保存协议,低十六位保存目标端口;省略端口时使用零。
175
+ def service(expression)
176
+ text = string(expression, label: "Service expression")
177
+ protocol, port, *unexpected_parts = text.split("/", -1)
178
+ raise ArgumentError, "Invalid service expression: #{text}" unless protocol && unexpected_parts.empty?
179
+ ranges = ports(port || 0)
180
+ return RangeSet.new(0..SERVICE_MAX) if protocol.casecmp?(ANY_PROTOCOL.to_s) || protocol.casecmp?("any")
181
+ base = protocol_number(protocol) << 16
182
+ RangeSet.new(*ranges.map { |range| (base + range.begin)..(base + range.end) })
183
+ end
184
+
185
+ private
186
+
187
+ # 校验并转换整数输入,同时限制其数值范围。
188
+ def integer(value, max:, label:)
189
+ unless value.is_a?(Integer) || (value.is_a?(String) && /\A[0-9]+\z/.match?(value))
190
+ raise ArgumentError, "#{label} must be an integer in 0..#{max}"
191
+ end
192
+ number = value.is_a?(Integer) ? value : Integer(value, 10)
193
+ raise ArgumentError, "#{label} must be in 0..#{max}" unless number.between?(0, max)
194
+ number
195
+ end
196
+
197
+ # 校验文本输入并保留原字符串,便于上层继续解析原始表达式。
198
+ def string(value, label:)
199
+ raise ArgumentError, "#{label} must be a string" unless value.is_a?(String)
200
+ value
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Netstack
6
+ # IPv6 ranges remain separate from IPv4. Serialize endpoints as address text.
7
+ module IPv6
8
+ MAX = (1 << 128) - 1
9
+ module_function
10
+
11
+ def address(value)
12
+ unless value.is_a?(String) && value == value.strip && !value.include?("%")
13
+ raise ArgumentError, "Invalid IPv6 address"
14
+ end
15
+
16
+ result = ::IPAddr.new(value)
17
+ raise ArgumentError, "Expected IPv6" unless result.ipv6?
18
+
19
+ result
20
+ end
21
+
22
+ def canonical(value, host: false)
23
+ ip = address(value)
24
+ return "#{address(value.split("/").first)}/#{ip.prefix}" if host
25
+
26
+ value.include?("/") ? "#{ip}/#{ip.prefix}" : ip.to_s
27
+ end
28
+
29
+ def parse(value)
30
+ raise ArgumentError, "IPv6 expression must be text" unless value.is_a?(String)
31
+
32
+ if value.include?("-")
33
+ parts = value.split("-", -1)
34
+ unless parts.size == 2 && parts.none? { |part| part.include?("/") }
35
+ raise ArgumentError, "IPv6 range needs two hosts"
36
+ end
37
+
38
+ first, last = parts.map { |part| address(part).to_i }
39
+ RangeSet.new(first..last)
40
+ else
41
+ ip = address(value)
42
+ RangeSet.new(ip.to_range.begin.to_i..ip.to_range.end.to_i)
43
+ end
44
+ end
45
+
46
+ def normalize(value)
47
+ raise ArgumentError, "IPv6 expression must be text" unless value.is_a?(String)
48
+ return canonical(value) unless value.include?("-")
49
+
50
+ range = parse(value).ranges.fetch(0)
51
+ "#{from_i(range.begin)}-#{from_i(range.end)}"
52
+ end
53
+
54
+ def from_i(value)
55
+ raise ArgumentError, "IPv6 integer is out of range" unless value.is_a?(Integer) && value.between?(0, MAX)
56
+
57
+ ::IPAddr.new(value, Socket::AF_INET6).to_s
58
+ end
59
+
60
+ def ranges(set)
61
+ validate_set!(set)
62
+ set.map { |range| [from_i(range.begin), from_i(range.end)] }
63
+ end
64
+
65
+ def cidrs(set)
66
+ validate_set!(set)
67
+ set.flat_map do |range|
68
+ cursor = range.begin
69
+ blocks = []
70
+ while cursor <= range.end
71
+ remaining_bits = (range.end - cursor + 1).bit_length - 1
72
+ aligned_bits = cursor.zero? ? 128 : (cursor & -cursor).bit_length - 1
73
+ bits = [remaining_bits, aligned_bits].min
74
+ blocks << [from_i(cursor), 128 - bits]
75
+ cursor += 1 << bits
76
+ end
77
+ blocks
78
+ end
79
+ end
80
+
81
+ def validate_set!(set)
82
+ return if set.is_a?(RangeSet) && (set.empty? || set.max <= MAX)
83
+
84
+ raise ArgumentError, "Expected IPv6 integer ranges"
85
+ end
86
+ private_class_method :validate_set!
87
+ end
88
+ end