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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ class Topology
8
+ # 描述文本与常见接口视图命令的纯构造方法,不连接或修改设备。
9
+ module InterfaceDescription
10
+ # 将邻居名称和接口拼成默认描述;只缩写邻居接口,保留原始大小写。
11
+ def self.format(neighbor, abbreviate: true, lowercase: false)
12
+ port = if abbreviate
13
+ InterfaceName.short(neighbor.neighbor_interface, lowercase: lowercase)
14
+ else
15
+ lowercase ? neighbor.neighbor_interface.downcase : neighbor.neighbor_interface
16
+ end
17
+ validate!("To #{neighbor.neighbor_name} #{port}")
18
+ end
19
+
20
+ # 生成进入接口、设置描述和退出视图的命令,不执行设备操作。
21
+ def self.commands(interface:, description:, leave: "exit")
22
+ validate_interface!(interface)
23
+ validate!(description)
24
+ raise ArgumentError, "interface exit must be exit or quit" unless %w[exit quit].include?(leave)
25
+
26
+ ["interface #{interface}", "description #{description}", leave]
27
+ end
28
+
29
+ # 共享的输入约束同样适用于 PAN-OS 的独立命令语法。
30
+ def self.validate!(value)
31
+ return value if value.is_a?(String) && value.bytesize.between?(1, 80) &&
32
+ value.match?(/\A[\p{Alnum}_.: \/-]+\z/u)
33
+
34
+ raise ArgumentError, "description must be 1-80 bytes of plain interface text"
35
+ end
36
+
37
+ # 拒绝可能把描述命令拆成多条命令的不安全接口名。
38
+ def self.validate_interface!(value)
39
+ return value if value.is_a?(String) && value.match?(/\A[A-Za-z][A-Za-z0-9.\/-]*\z/)
40
+
41
+ raise ArgumentError, "neighbor interface name is unsafe"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ class Topology
8
+ # 接口身份匹配与描述展示共用名称规则;展示简称不能直接用作下发命令。
9
+ module InterfaceName
10
+ RULES = [
11
+ [/\A(?:xge|ten-gigabitethernet|tengigabitethernet|te)(?=\d)/i, "tengigabitethernet", "Te"],
12
+ [/\A(?:gigabitethernet|ge|gi)(?=\d)/i, "gigabitethernet", "Gi"],
13
+ [/\A(?:fastethernet|fa)(?=\d)/i, "fastethernet", "Fa"],
14
+ [/\A(?:ethernet|eth)(?=\d)/i, "ethernet", "Eth"],
15
+ [/\A(?:port-channel|po)(?=\d)/i, "port-channel", "Po"]
16
+ ].map(&:freeze).freeze
17
+
18
+ # 用于本机邻居表与配置的关联;保留已有证据键的拼写契约。
19
+ def self.key(name)
20
+ rule = RULES.find { |pattern, _key, _short| pattern.match?(name) }
21
+ rule ? name.sub(rule.first, rule[1]).downcase : name.downcase
22
+ end
23
+
24
+ # 仅缩短已识别的前缀,保留端口、子接口编号及未知命名。
25
+ # 默认延续输入的大写、小写或首字母大写形式;统一小写由调用方选择。
26
+ def self.short(name, lowercase: false)
27
+ rule = RULES.find { |pattern, _key, _short| pattern.match?(name) }
28
+ formatted = if rule
29
+ name.sub(rule.first) do |prefix|
30
+ abbreviation = rule.last
31
+ if prefix == prefix.downcase
32
+ abbreviation.downcase
33
+ elsif prefix == prefix.upcase
34
+ abbreviation.upcase
35
+ elsif prefix.casecmp?(abbreviation)
36
+ prefix
37
+ else
38
+ abbreviation
39
+ end
40
+ end
41
+ else
42
+ name.dup
43
+ end
44
+ lowercase ? formatted.downcase : formatted
45
+ end
46
+
47
+ # 保留现有配置视图展开规则;厂商策略可覆盖不适用的名称。
48
+ def self.configuration(name)
49
+ name.sub(/\AXGE(?=\d)/i, "Ten-GigabitEthernet")
50
+ .sub(/\AGE(?=\d)/i, "GigabitEthernet")
51
+ .sub(/\AGi(?=\d)/i, "GigabitEthernet")
52
+ .sub(/\ATe(?=\d)/i, "TenGigabitEthernet")
53
+ .sub(/\AFa(?=\d)/i, "FastEthernet")
54
+ .sub(/\AEth(?=\d)/i, "Ethernet")
55
+ .sub(/\APo(?=\d)/i, "port-channel")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Netstack
7
+ module Connector
8
+ class Topology
9
+ # The host stores this private command artifact; deployments carry only its reference and digest.
10
+ class Plan < Data.define(:host, :vendor, :evidence, :changes, :commands, :verification_commands)
11
+ SCHEMA_VERSION = 1
12
+
13
+ def initialize(host:, vendor:, evidence:, changes:, commands:, verification_commands: nil)
14
+ vendor = vendor.to_sym if vendor.is_a?(String)
15
+ raise InputError, "invalid topology vendor" unless Connector.vendors.include?(vendor)
16
+ unless evidence.is_a?(Hash) && evidence.all? { |key, values| key.is_a?(String) && values.is_a?(Array) && values.size == 3 && values.all?(String) }
17
+ raise InputError, "invalid topology evidence"
18
+ end
19
+ unless changes.is_a?(Array) && changes.all? { |change| change.is_a?(Change) && change.neighbor.is_a?(Neighbor) }
20
+ raise InputError, "invalid topology changes"
21
+ end
22
+ changes.each do |change|
23
+ InterfaceDescription.validate_interface!(change.interface)
24
+ InterfaceDescription.validate!(change.new_description)
25
+ raise InputError, "invalid previous description" unless change.old_description.is_a?(String)
26
+ neighbor = change.neighbor
27
+ unless neighbor.to_h.except(:protocol).values.all?(String) && %i[cdp lldp].include?(neighbor.protocol)
28
+ raise InputError, "invalid topology neighbor"
29
+ end
30
+ end
31
+ keys = changes.map { |change| InterfaceName.key(change.interface) }
32
+ raise InputError, "topology changes must identify unique interfaces" unless keys.uniq == keys
33
+
34
+ verification_commands ||= Connector.vendor_class(vendor).profile.config_commands || []
35
+ [commands, verification_commands].each do |lines|
36
+ unless lines.is_a?(Array) && lines.all? { |line| line.is_a?(String) && !line.strip.empty? && !line.match?(/[\r\n\x00]/) }
37
+ raise InputError, "topology commands must be single lines"
38
+ end
39
+ end
40
+ raise InputError, "empty topology plan cannot contain commands" if changes.empty? && !commands.empty?
41
+ super(host: Values.text(host, name: "topology host"), vendor: vendor, evidence: Values.copy(evidence),
42
+ changes: Values.copy(changes), commands: Values.copy(commands), verification_commands: Values.copy(verification_commands))
43
+ end
44
+
45
+ def payload
46
+ { "schema_version" => SCHEMA_VERSION, "host" => host, "vendor" => vendor.to_s,
47
+ "evidence" => evidence.sort.to_h, "changes" => changes.map do |change|
48
+ { "interface" => change.interface, "old_description" => change.old_description,
49
+ "new_description" => change.new_description, "neighbor" => change.neighbor.to_h.transform_keys(&:to_s).transform_values { |value| value.is_a?(Symbol) ? value.to_s : value } }
50
+ end, "commands" => commands, "verification_commands" => verification_commands }
51
+ end
52
+
53
+ def sha256 = Digest::SHA256.hexdigest(JSON.generate(payload))
54
+
55
+ def self.from_h(value)
56
+ validate_keys!(value)
57
+ data = JSON.parse(JSON.generate(value), symbolize_names: true)
58
+ raise InputError, "unsupported topology artifact schema" unless data.delete(:schema_version) == SCHEMA_VERSION
59
+ data[:evidence] = data.fetch(:evidence).transform_keys(&:to_s)
60
+ data[:changes] = data.fetch(:changes).map do |change|
61
+ neighbor = change.fetch(:neighbor)
62
+ Change.new(**change.merge(neighbor: Neighbor.new(**neighbor.merge(protocol: neighbor.fetch(:protocol).to_sym))))
63
+ end
64
+ new(**data)
65
+ rescue KeyError, TypeError, NoMethodError, JSON::GeneratorError, JSON::ParserError, ArgumentError
66
+ raise InputError, "invalid topology artifact", cause: nil
67
+ end
68
+
69
+ def validate_target!(target)
70
+ return if target.is_a?(Target) && target.host == host && target.platform == vendor.to_s
71
+
72
+ raise InputError, "topology artifact and target do not match"
73
+ end
74
+
75
+ def deployment_plan(target:, artifact_ref:)
76
+ validate_target!(target)
77
+ reference = Values.text(artifact_ref, name: "topology artifact reference")
78
+ parameters = { artifact_ref: reference, sha256: sha256 }
79
+ expected = { changes: changes.map do |change|
80
+ { interface: change.interface, new_description: change.new_description, neighbor: change.neighbor.to_h }
81
+ end, verification_commands: verification_commands }
82
+ steps = changes.empty? ? [] : %w[change persist].map do |stage|
83
+ Deployment::Step.new(id: stage, action: "interface_descriptions_#{stage}", parameters: parameters, expected: expected)
84
+ end
85
+ Deployment::Plan.new(target: target, steps: steps, metadata: { domain: "interface_descriptions", artifact_sha256: sha256 })
86
+ end
87
+
88
+ def inspect = "#<#{self.class} changes=#{changes.size} sha256=#{sha256}>"
89
+ alias to_s inspect
90
+
91
+ def self.validate_keys!(value)
92
+ case value
93
+ when Hash
94
+ unless value.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } && value.keys.map(&:to_s).uniq.size == value.size
95
+ raise InputError, "ambiguous topology artifact keys"
96
+ end
97
+ value.each_value { |item| validate_keys!(item) }
98
+ when Array then value.each { |item| validate_keys!(item) }
99
+ end
100
+ end
101
+ private_class_method :validate_keys!
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "shellwords"
6
+
7
+ module Netstack
8
+ module Connector
9
+ class Topology
10
+ # 厂商拓扑规则;默认不提供任何设备能力。
11
+ class Strategy
12
+ # 保存设备供厂商规则查询档案和会话状态。
13
+ def initialize(device)
14
+ @device = device
15
+ end
16
+
17
+ # 声明策略类支持的拓扑能力,默认全部关闭。
18
+ def self.supports?(_capability) = false
19
+
20
+ # 将实例级能力查询交给策略类声明。
21
+ def supports?(capability) = self.class.supports?(capability)
22
+
23
+ # 返回读取邻居的命令,未定义时不支持发现。
24
+ def neighbor_command = nil
25
+
26
+ # 根据原始输出选择邻居解析模板。
27
+ def neighbor_template(_output) = nil
28
+
29
+ # 返回当前接口描述所用的解析模板。
30
+ def description_template = nil
31
+
32
+ # 返回输出中应解析出的邻居数量,供完整性校验使用。
33
+ def expected_neighbor_count(_output, _template) = 0
34
+
35
+ # 只有明确的空表证据才允许返回空邻居列表。
36
+ def empty_neighbor_output?(_output) = false
37
+
38
+ # 返回进入设备配置视图的命令。
39
+ def enter_configuration_command = nil
40
+
41
+ # 只读策略不提供变更阶段;可写策略必须分别声明退出、验证和保存命令。
42
+ def leave_configuration_commands = nil
43
+ def verification_commands = nil
44
+ def persistence_commands = nil
45
+ def persistence_confirmed?(_result) = false
46
+ def change_error_code = :description_unsupported
47
+
48
+ # 只读策略可以没有完整性扩展;立即生效的接口块模型会进一步校验解析数量。
49
+ def validate_descriptions!(_config, _rows) end
50
+
51
+ # 为一条接口描述变更生成厂商命令。
52
+ def change_commands(_change) = []
53
+
54
+ # 标明邻居发现使用的协议。
55
+ def protocol = :lldp
56
+
57
+ # 在下发前允许厂商调整命令对象及超时。
58
+ def script_command(command) = command
59
+
60
+ # 将发现接口名归一为查找当前配置所用的键。
61
+ def interface_key(name) = InterfaceName.key(name)
62
+
63
+ # 将发现协议常用的接口简称展开为配置视图可识别的名称。
64
+ def configuration_interface(name) = InterfaceName.configuration(name)
65
+
66
+ # 还原 PAN-OS set 配置或 Alteon dump 中的引号描述。
67
+ def decode_description(value)
68
+ value = value.strip
69
+ value.start_with?('"') ? Shellwords.split(value).join(" ") : value
70
+ rescue ArgumentError
71
+ value
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ # 从 CDP/LLDP 回显生成链路邻居,并规划接口描述变更。
8
+ class Topology
9
+ # 设备入口与功能实现共置,Device 只组合能力,不重复业务流程。
10
+ module Capability
11
+ # 执行 CDP 或 LLDP 查询并返回统一的链路邻居记录。
12
+ def neighbors = Topology.new(self).neighbors
13
+
14
+ # 读取运行配置中的接口描述或端口名称。
15
+ def interface_descriptions = Topology.new(self).interface_descriptions
16
+
17
+ # 以邻居和现有配置为证据,生成待确认的接口描述变更计划。
18
+ def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
19
+ Topology.new(self).plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
20
+ end
21
+
22
+ # 明确确认且现场证据未变化时执行接口描述计划。
23
+ def apply_interface_descriptions(plan, **options)
24
+ Topology.new(self).apply_interface_descriptions(plan, **options)
25
+ end
26
+ end
27
+
28
+ Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol)
29
+ Change = Data.define(:interface, :old_description, :new_description, :neighbor)
30
+
31
+ # 保存设备及其独立的 TextFSM 解析入口。
32
+ def initialize(device, template_dir: nil)
33
+
34
+ @device = device
35
+ @parser = TextFSM.new(template_dir: template_dir)
36
+ @strategy = (device.profile.topology_strategy || Strategy).new(device)
37
+ end
38
+
39
+ # 执行厂商 CDP/LLDP 命令,返回统一的邻居记录。
40
+ def neighbors
41
+ command = @strategy.neighbor_command
42
+ unless @strategy.supports?(:neighbors) && command
43
+ raise UnsupportedOperation.new("this device cannot discover LLDP neighbors",
44
+ code: :neighbor_discovery_unsupported, host: @device.host, phase: :discover)
45
+ end
46
+
47
+ output = TerminalText.utf8(@device.execute_command(command).value!, host: @device.host)
48
+ template = @strategy.neighbor_template(output)
49
+ rows = @parser.call(output, template: template, vendor: @device.vendor, command: command, host: @device.host)
50
+ if rows.size != @strategy.expected_neighbor_count(output, template) || (rows.empty? && !@strategy.empty_neighbor_output?(output))
51
+ raise ParsingError.new("neighbor output was not recognized", code: :unrecognized_output,
52
+ host: @device.host, phase: :discover)
53
+ end
54
+
55
+ rows.map do |row|
56
+ Neighbor.new(local_interface: row.fetch("LOCAL_INTERFACE").strip.freeze,
57
+ neighbor_name: row.fetch("NEIGHBOR_NAME", "").strip.freeze,
58
+ neighbor_interface: row.fetch("NEIGHBOR_INTERFACE").strip.freeze,
59
+ chassis_id: row.fetch("CHASSIS_ID", "").strip.freeze,
60
+ protocol: @strategy.protocol).freeze
61
+ end
62
+ end
63
+
64
+ # 从运行配置读取已设置的接口描述或端口名称。
65
+ def interface_descriptions = read_interface_descriptions
66
+
67
+ # 内部读取先交付执行步骤,再处理解析;解析失败也不能抹去已经完成的采集命令。
68
+ def read_interface_descriptions
69
+ template = @strategy.description_template
70
+ unless @strategy.supports?(:interface_descriptions) && template
71
+ raise UnsupportedOperation.new("interface descriptions are unsupported for this vendor",
72
+ code: :description_unsupported, host: @device.host, phase: :discover)
73
+ end
74
+ result = @device.running_config
75
+ yield result if block_given?
76
+ config = result.value!
77
+ rows = @parser.call(config, template: template, host: @device.host)
78
+ @strategy.validate_descriptions!(config, rows)
79
+ rows.each_with_object({}) do |row, found|
80
+ interface = @strategy.interface_key(row.fetch("INTERFACE"))
81
+ description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
82
+ previous = found[interface]
83
+ if previous && !previous.empty? && !description.empty? && previous != description
84
+ raise ParsingError.new("interface has conflicting descriptions", code: :ambiguous_description,
85
+ host: @device.host, phase: :parse)
86
+ end
87
+ found[interface] = description unless description.empty? && previous
88
+ end
89
+ end
90
+
91
+ private :read_interface_descriptions
92
+
93
+ # 比对邻居和现有描述,生成包含确切命令的只读变更计划。
94
+ def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
95
+ validate_change_support!(:plan) if @strategy.supports?(:neighbors)
96
+ discovered = neighbors
97
+ current = interface_descriptions
98
+ current.each_value(&:freeze)
99
+ evidence = evidence_for(discovered, current)
100
+ changes = discovered.filter_map do |neighbor|
101
+ old = current.fetch(@strategy.interface_key(neighbor.local_interface))
102
+ proposed = if formatter
103
+ formatter.call(neighbor)
104
+ else
105
+ InterfaceDescription.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
106
+ end
107
+ InterfaceDescription.validate!(proposed)
108
+ next if old == proposed
109
+
110
+ Change.new(interface: @strategy.configuration_interface(neighbor.local_interface).freeze, old_description: old.freeze,
111
+ new_description: proposed.freeze, neighbor: neighbor).freeze
112
+ end.freeze
113
+ commands = if changes.empty?
114
+ []
115
+ else
116
+ stage_scripts(changes).values.flat_map { |script| script.map(&:text) }
117
+ end
118
+ commands.each(&:freeze)
119
+ Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
120
+ changes: changes, commands: commands.freeze,
121
+ verification_commands: Script.new(@device.config_commands).map(&:text))
122
+ end
123
+
124
+ # 只有明确确认且邻居和旧描述未变化时,才下发计划中的命令。
125
+ def apply_interface_descriptions(plan, execution_id:, checkpoint:, artifact_resolver:, previous: nil, confirmed: false)
126
+ unless confirmed == true
127
+ raise UnsupportedOperation.new("description plan requires confirmation", code: :confirmation_required,
128
+ host: @device.host, phase: :apply)
129
+ end
130
+ raise InputError, "apply requires a Deployment::Plan" unless plan.is_a?(Deployment::Plan)
131
+ unless plan.target.host == @device.host && plan.target.platform == @device.vendor.to_s &&
132
+ plan.metadata[:domain] == "interface_descriptions"
133
+ raise InputError, "topology deployment belongs to a different device or domain"
134
+ end
135
+ @strategy.supports?(:neighbors) ? validate_change_support!(:apply) : neighbors
136
+ adapter = DeploymentAdapter.new(topology: self, device: @device, strategy: @strategy, artifact_resolver: artifact_resolver)
137
+ receipt = nil
138
+ @device.with_operation(:apply) do
139
+ receipt = Deployment::Executor.new(adapter: adapter).apply(plan: plan, execution_id: execution_id,
140
+ checkpoint: checkpoint, credentials: {}, previous: previous)
141
+ end
142
+ ExecutionResult.new(receipt: receipt, result: adapter.result)
143
+ rescue Deployment::CheckpointError => error
144
+ raise CheckpointError.new(receipt: error.receipt, result: adapter.result), cause: nil
145
+ rescue Error => error
146
+ raise unless receipt
147
+
148
+ adapter.record_error(error)
149
+ ExecutionResult.new(receipt: receipt, result: adapter.result)
150
+ end
151
+
152
+ private
153
+
154
+ # 读回命令与审批一致,并且每个目标描述均得到确认,才允许进入保存阶段。
155
+ def verify_descriptions!(changes, script, steps)
156
+ unless Script.new(@device.config_commands).map(&:text) == script.map(&:text)
157
+ raise DeviceError.new("configuration readback commands changed after approval",
158
+ code: :verification_plan_changed, host: @device.host, phase: :verify)
159
+ end
160
+ updated = read_interface_descriptions do |readback|
161
+ steps.concat(readback.steps)
162
+ if readback.success? && readback.steps.map { |step| step.command.text } != script.map(&:text)
163
+ raise DeviceError.new("configuration readback did not execute the approved commands",
164
+ code: :verification_plan_changed, host: @device.host, phase: :verify)
165
+ end
166
+ end
167
+ return if changes.all? { |change| updated[@strategy.interface_key(change.interface)] == change.new_description }
168
+
169
+ raise DeviceError.new("interface descriptions were not confirmed by readback",
170
+ code: :description_unconfirmed, host: @device.host, phase: :verify)
171
+ end
172
+
173
+ # 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
174
+ def revalidate_plan!(plan)
175
+ discovered = neighbors
176
+ evidence = evidence_for(discovered, interface_descriptions)
177
+ current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
178
+ same_neighbors = plan.changes.all? do |change|
179
+ current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
180
+ end
181
+ unless evidence == plan.evidence && same_neighbors
182
+ raise DeviceError.new("neighbors or interface descriptions changed after planning",
183
+ code: :stale_plan, host: @device.host, phase: :apply)
184
+ end
185
+
186
+ plan.changes.each do |change|
187
+ InterfaceDescription.validate!(change.new_description)
188
+ unless evidence.fetch(@strategy.interface_key(change.interface)) ==
189
+ [change.neighbor.neighbor_name, change.neighbor.neighbor_interface, change.old_description]
190
+ raise ArgumentError, "plan changes do not match its discovery evidence"
191
+ end
192
+ end
193
+ end
194
+
195
+ # 审批列表保留真正执行的读回命令,使旧版“先保存后读回”计划无法静默复用。
196
+ def stage_scripts(changes)
197
+ phases = %i[leave_configuration_commands verification_commands persistence_commands].map do |method|
198
+ @strategy.public_send(method)
199
+ end
200
+ unless phases.all? { |commands| commands.is_a?(Array) && !commands.empty? }
201
+ raise UnsupportedOperation.new("topology strategy must declare separate readback and persistence stages",
202
+ code: :description_stages_unsupported, host: @device.host, phase: :plan)
203
+ end
204
+ leave, verify, persist = phases
205
+ sequences = {
206
+ change: [@strategy.enter_configuration_command] + changes.flat_map { |change| change_commands(change) } + leave,
207
+ verify: verify, persist: persist
208
+ }
209
+ sequences.transform_values do |commands|
210
+ Script.new(commands.map { |command| @strategy.script_command(command) }, name: "interface descriptions")
211
+ end
212
+ end
213
+
214
+ # 保存可能已执行,超时、失败或缺少完成行均不自动重放;诊断不附带回显正文。
215
+ def build_persistence_error(error = nil)
216
+ underlying = UnderlyingError.new(error, nil, sensitive: true) if error
217
+ DeviceError.new("interface descriptions were verified; persistence was not confirmed",
218
+ code: :persistence_unconfirmed, host: @device.host, phase: :persist, underlying: underlying)
219
+ end
220
+
221
+ # 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
222
+ def validate_change_support!(phase)
223
+ return if @strategy.supports?(:interface_description_changes)
224
+
225
+ raise UnsupportedOperation.new("automatic interface description changes are unavailable; use a verified manual workflow",
226
+ code: @strategy.change_error_code, host: @device.host, phase: phase)
227
+ end
228
+
229
+ # 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
230
+ def evidence_for(discovered, current)
231
+ interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
232
+ if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
233
+ peers.first.neighbor_interface.empty? }
234
+ raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
235
+ host: @device.host, phase: :plan)
236
+ end
237
+ unless interfaces.keys.all? { |interface| current.key?(interface) }
238
+ raise ParsingError.new("neighbor interface was not found in running configuration",
239
+ code: :interface_missing, host: @device.host, phase: :plan)
240
+ end
241
+
242
+ interfaces.transform_values do |peers|
243
+ peer = peers.fetch(0)
244
+ [peer.neighbor_name, peer.neighbor_interface, current.fetch(@strategy.interface_key(peer.local_interface))].freeze
245
+ end.freeze
246
+ end
247
+
248
+ # 设备标识来自解析证据,仍需在生成命令前验证其安全性。
249
+ def change_commands(change)
250
+ InterfaceDescription.validate_interface!(change.interface)
251
+ @strategy.change_commands(change)
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "expect/redactor"
6
+
7
+ module Netstack
8
+ module Connector
9
+ class TransportError < ConnectionError; end
10
+ end
11
+ end