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,53 @@
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 Tftp
8
+ # H3C 和华为共用已保存文件上传流程,源文件发现仍由厂商策略负责。
9
+ class FileUpload < Strategy
10
+ # 未指定目标名时,设备使用配置源文件的 basename。
11
+ def default_path(source_file) = File.basename(source_file)
12
+
13
+ # H3C 探测源文件之前先拒绝不支持的参数;探测本身不能放进纯校验钩子。
14
+ def validate_options!(_target, source_file:, vrf: nil)
15
+ raise ArgumentError, "TFTP file upload does not use vrf" unless vrf.nil?
16
+
17
+ TftpTarget.validate_source_file!(source_file) unless source_file.nil?
18
+ end
19
+
20
+ # 指定文件的内容与格式未知,不能仅凭 .cfg 后缀声称是当前运行配置。
21
+ def receipt_metadata(_target, source_file:, **)
22
+ { configuration_kind: :saved_file, source_file: source_file, format: :unknown }
23
+ end
24
+
25
+ # 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
26
+ def script(target, source_file:, vrf: nil)
27
+ validate_options!(target, source_file: source_file, vrf: vrf)
28
+ TftpTarget.validate_source_file!(source_file)
29
+
30
+ destination = target.explicit_path? ? " #{target.path}" : ""
31
+ Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
32
+ end
33
+
34
+ # 只接受明确的成功消息、非零传输字节数或完整进度行。
35
+ def device_reported_complete?(result)
36
+ completion_lines(result).any? { |line| completed_upload?(line) }
37
+ end
38
+
39
+ private
40
+
41
+ # 两个厂商的上传完成消息使用相同的文本及进度格式。
42
+ def completed_upload?(line)
43
+ line.match?(/\A
44
+ (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
45
+ (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
46
+ \z/ix) ||
47
+ line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
48
+ completed_progress?(line)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,67 @@
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 Tftp
8
+ class Strategy
9
+ # 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
10
+ def self.file_extension = "cfg"
11
+
12
+ # 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
13
+ def self.filename(host, label: nil)
14
+ TftpTarget.filename(host, extension: file_extension, label: label)
15
+ end
16
+
17
+ # 保存 TFTP 厂商策略使用的设备对象。
18
+ def initialize(device)
19
+ @device = device
20
+ @responses = []
21
+ end
22
+
23
+ # 返回调用方指定的配置源文件。
24
+ def resolve_source_file(value) = value
25
+
26
+ # 根据设备地址生成默认远端文件名。
27
+ def default_path(_source_file) = self.class.filename(@device.host)
28
+
29
+ # 返回目标中指定的远端文件名。
30
+ def remote_path(target, _result) = target.path
31
+
32
+ private
33
+
34
+ # 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
35
+ def interaction(pattern, response)
36
+ @responses << response.chomp
37
+ Interaction.new(pattern, response, capture: false)
38
+ end
39
+
40
+ # 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
41
+ def completion_lines(result)
42
+ step = result.steps.last
43
+ return [] unless step
44
+
45
+ output = TerminalRenderer.render(step.output)
46
+ prompt = TerminalRenderer.render(step.prompt.to_s)
47
+ output = output.delete_suffix(prompt) unless prompt.empty?
48
+ echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
49
+ output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
50
+ end
51
+
52
+ # 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
53
+ def completed_progress?(line)
54
+ line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
55
+ end
56
+
57
+ # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
58
+ def copied_bytes?(line)
59
+ line.match?(/\A
60
+ [1-9]\d*\s+bytes?\s+copied
61
+ (?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
62
+ \z/ix)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,116 @@
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 Tftp
8
+ # 设备入口与功能实现共置,Device 只组合能力,不重复业务流程。
9
+ module Capability
10
+ # 要求设备直接向 TFTP 服务器导出原生配置。
11
+ # 完成仅表示设备报告传输成功,未读取服务器端文件。
12
+ def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
13
+ Tftp.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
14
+ end
15
+ end
16
+
17
+ # 保存执行 TFTP 导出的设备对象。
18
+ def initialize(device) = @device = device
19
+
20
+ # 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
21
+ def call(host:, path: nil, source_file: nil, vrf: nil)
22
+ receipt = nil
23
+ strategy_class = @device.profile.tftp_strategy
24
+ unless strategy_class
25
+ raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
26
+ host: @device.host, phase: :tftp_backup)
27
+ end
28
+ strategy = strategy_class.new(@device)
29
+ if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
30
+ raise ArgumentError, "vrf must be a single safe name"
31
+ end
32
+ source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
33
+ vrf = vrf&.dup&.freeze
34
+ target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
35
+ strategy.validate_options!(target, source_file: source_file, vrf: vrf)
36
+ @device.with_operation(:tftp_backup) do
37
+ source = strategy.resolve_source_file(source_file)
38
+ source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
39
+ target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
40
+ result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
41
+ name: :tftp_backup, privilege: false)
42
+ confirm_transfer!(strategy, target, result)
43
+ # 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
44
+ requested_path = target.explicit_path? ? target.path : nil
45
+ receipt = TftpReceipt.new(server: target.host, path: nil, completed_at: Time.now.utc,
46
+ requested_path: requested_path, source_file: source)
47
+ actual_path = remote_path(strategy, target, result, receipt)
48
+ receipt = receipt.with(path: actual_path)
49
+ receipt = build_receipt(strategy, target, source, !source_file.nil?, receipt)
50
+ if target.explicit_path? && target.path != receipt.path
51
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_mismatch, host: @device.host), cause: nil
52
+ end
53
+ # 已完成步骤仍可能伴随后处理/清理错误;上面的回执要先构造,再重新抛出。
54
+ result.value! if result.failure?
55
+ @device.log_event("tftp_backup", status: "reported_uploaded", phase: :tftp_backup,
56
+ server: target.host, path: receipt.path)
57
+ receipt
58
+ end
59
+ rescue StandardError => error
60
+ raise unless receipt
61
+ raise if error.instance_of?(TftpCompletionError) && error.receipt.equal?(receipt)
62
+
63
+ raise TftpCompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
64
+ end
65
+
66
+ private
67
+
68
+ def build_receipt(strategy, target, source_file, explicit_source, receipt)
69
+ metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
70
+ unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
71
+ raise ArgumentError, "invalid TFTP strategy receipt metadata"
72
+ end
73
+ receipt.with(**{ source_file: source_file, requested_path: target.path }.merge(metadata))
74
+ end
75
+
76
+ def remote_path(strategy, target, result, receipt)
77
+ TftpTarget.validate_path!(strategy.remote_path(target, result))
78
+ rescue StandardError => error
79
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_unconfirmed,
80
+ host: @device.host, underlying: error), cause: nil
81
+ end
82
+
83
+ # 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
84
+ def confirm_transfer!(strategy, target, result)
85
+ if transfer_failed?(result)
86
+ @device.log_event("tftp_backup", level: :error, status: "transfer_failed", phase: :tftp_backup,
87
+ code: :transfer_failed, server: target.host)
88
+ raise DeviceError.new("device reported TFTP backup failure",
89
+ code: :transfer_failed, host: @device.host, phase: :tftp_backup)
90
+ end
91
+ return if strategy.device_reported_complete?(result)
92
+
93
+ result.value! if result.failure?
94
+ @device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
95
+ code: :transfer_unconfirmed, server: target.host)
96
+ raise DeviceError.new("device did not confirm TFTP backup completion",
97
+ code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
98
+ end
99
+
100
+ # 识别设备回显中的传输失败信息。
101
+ def transfer_failed?(result)
102
+ result.steps.any? do |step|
103
+ # 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
104
+ pattern = %r{(?<![\w./-])
105
+ (?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
106
+ unable\ to|network\ is\ unreachable|can't\ open)
107
+ (?![\w/-]|\.[\w])}ix
108
+ # 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
109
+ history = step.output.gsub(/\r(?!\n)/, "\n")
110
+ step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
111
+ TerminalRenderer.render(step.output).match?(pattern)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+
6
+
7
+ module Netstack
8
+ module Connector
9
+ # 上传已确认后的失败不能被解释为“尚未上传”,也不能携带原始诊断正文。
10
+ class TftpCompletionError < Error
11
+ MESSAGES = {
12
+ transfer_finalize_failed: "device reported TFTP completion but finalization failed",
13
+ transfer_path_unconfirmed: "device reported TFTP completion but its remote path is unconfirmed",
14
+ transfer_path_mismatch: "device reported TFTP completion at a different remote path"
15
+ }.freeze
16
+ private_constant :MESSAGES
17
+
18
+ attr_reader :receipt, :underlying_type
19
+
20
+ def initialize(receipt:, code: :transfer_finalize_failed, host: nil, underlying: nil)
21
+ unless receipt.instance_of?(TftpReceipt) && MESSAGES.key?(code)
22
+ raise ArgumentError, "TFTP completion error requires a transfer receipt and a known failure code"
23
+ end
24
+
25
+ @receipt = receipt
26
+ type = if underlying.is_a?(Error) && underlying.underlying.instance_of?(UnderlyingError)
27
+ underlying.underlying.type
28
+ else
29
+ underlying&.class&.name
30
+ end
31
+ @underlying_type = type&.match?(/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/) && type.bytesize <= 128 ? type.freeze : nil
32
+ super(MESSAGES.fetch(code), code: code, host: host, phase: :tftp_backup)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+
6
+
7
+ module Netstack
8
+ module Connector
9
+ # 一次设备上报上传的不可变回执;path 是实际目标,未知时保持 nil。
10
+ TftpReceipt = Data.define(:server, :path, :completed_at, :configuration_kind, :source_file,
11
+ :format, :requested_path, :verification, :server_sha256, :server_bytes, :local_path,
12
+ :archive_path)
13
+
14
+ class TftpReceipt
15
+ KINDS = %i[running startup saved_file native_archive unknown].freeze
16
+ FORMATS = %i[cfg xml dat tgz unknown].freeze
17
+ private_constant :KINDS, :FORMATS
18
+
19
+ def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
20
+ format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil,
21
+ server_bytes: nil, local_path: nil, archive_path: nil)
22
+ raise ArgumentError, "completed_at must be a Time" unless completed_at.is_a?(Time)
23
+
24
+ TftpTarget.new(host: server, path: path.nil? ? "unconfirmed" : path)
25
+ TftpTarget.validate_path!(requested_path) unless requested_path.nil?
26
+ TftpTarget.validate_source_file!(source_file) unless source_file.nil?
27
+ unless KINDS.include?(configuration_kind) && FORMATS.include?(format)
28
+ raise ArgumentError, "invalid TFTP configuration kind or format"
29
+ end
30
+ valid_verification = (verification == :device_reported && server_sha256.nil?) ||
31
+ (verification == :server_verified && !path.nil? && server_sha256.is_a?(String) &&
32
+ server_sha256.match?(/\A[0-9a-f]{64}\z/))
33
+ raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
34
+
35
+ if (!server_bytes.nil? && (!server_bytes.is_a?(Integer) || !server_bytes.positive?)) ||
36
+ (!local_path.nil? && (!local_path.is_a?(String) || local_path.empty?)) ||
37
+ (!archive_path.nil? && (!archive_path.is_a?(String) || archive_path.empty?)) ||
38
+ (verification != :server_verified && (server_bytes || local_path || archive_path))
39
+ raise ArgumentError, "invalid server file metadata"
40
+ end
41
+ super(server: server.dup.freeze, path: path&.dup&.freeze, completed_at: completed_at.dup.freeze,
42
+ configuration_kind: configuration_kind, source_file: source_file&.dup&.freeze, format: format,
43
+ requested_path: requested_path&.dup&.freeze, verification: verification,
44
+ server_sha256: server_sha256&.dup&.freeze, server_bytes: server_bytes, local_path: local_path&.dup&.freeze,
45
+ archive_path: archive_path&.dup&.freeze)
46
+ end
47
+
48
+ # Ruby 3.2 的 Data#with 不调用 initialize;所有更新都重新校验并冻结字段。
49
+ def with(**attributes) = self.class.new(**to_h.merge(attributes))
50
+
51
+ def inspect = "#<#{self.class} kind=#{configuration_kind} format=#{format} verification=#{verification}>"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "digest"
6
+
7
+ module Netstack
8
+ module Connector
9
+ # 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
10
+ class TftpTarget
11
+ MAX_PATH_BYTES = 220
12
+
13
+ attr_reader :host, :path
14
+
15
+ # 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
16
+ def initialize(host:, path:, explicit_path: true)
17
+ unless host.is_a?(String) && host.bytesize <= 253 &&
18
+ host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
19
+ raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
20
+ end
21
+ self.class.validate_path!(path)
22
+ @host, @path = host.dup.freeze, path.dup.freeze
23
+ @explicit_path = explicit_path
24
+ freeze
25
+ end
26
+
27
+ # 判断调用方是否明确指定了远端文件名。
28
+ def explicit_path? = @explicit_path
29
+
30
+ # 校验设备或调用方给出的远端相对路径。
31
+ def self.validate_path!(path)
32
+ unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
33
+ path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
34
+ path.split("/").none? { |part| %w[. ..].include?(part) }
35
+ raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
36
+ end
37
+ path
38
+ end
39
+
40
+ # 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
41
+ def self.filename(host, extension:, label: nil)
42
+ if label && (!label.is_a?(String) || !label.ascii_only?)
43
+ raise ArgumentError, "TFTP label must be an ASCII String"
44
+ end
45
+
46
+ address = host.gsub(/[^A-Za-z0-9._-]/, "_")
47
+ suffix = "#{address}.#{extension}"
48
+ if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
49
+ suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
50
+ end
51
+ path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
52
+ validate_path!(path)
53
+ end
54
+
55
+ # 校验设备源文件路径是否符合命令安全约束。
56
+ def self.validate_source_file!(value)
57
+ unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
58
+ !value.split("/").include?("..")
59
+ raise ArgumentError, "source_file must be a device file path without spaces or traversal"
60
+ end
61
+ value
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Connector
5
+ class Topology
6
+ class CheckpointError < Deployment::CheckpointError
7
+ attr_reader :result
8
+
9
+ def initialize(receipt:, result:)
10
+ @result = result
11
+ super(receipt: receipt)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Connector
5
+ class Topology
6
+ # Each write resolves its private command artifact afresh. Recovery uses only reviewed readback expectations.
7
+ class DeploymentAdapter
8
+ def initialize(topology:, device:, strategy:, artifact_resolver:)
9
+ raise InputError, "topology artifact resolver must be callable" unless artifact_resolver.respond_to?(:call)
10
+
11
+ @topology, @device, @strategy, @resolver = topology, device, strategy, artifact_resolver
12
+ @steps = []
13
+ end
14
+
15
+ def result = Result.new(steps: @steps, error: @error)
16
+
17
+ def record_error(error)
18
+ return if @error
19
+
20
+ @error = error.is_a?(Connector::Error) ? error : InternalError.new("topology execution failed", phase: :apply)
21
+ end
22
+
23
+ def preflight(plan:, previous:, **) # rubocop:disable Naming/PredicateMethod -- Deployment adapter protocol.
24
+ @plan = plan
25
+ validate_plan!
26
+ return true if plan.steps.empty?
27
+
28
+ @changes = expected_changes(plan.steps.first.expected)
29
+ if !previous || previous.steps.empty?
30
+ artifact = resolve(plan.steps.first)
31
+ @topology.send(:revalidate_plan!, artifact)
32
+ else
33
+ verify_neighbors!
34
+ verify_descriptions! if previous.steps.first[:status] == :verified
35
+ end
36
+ true
37
+ end
38
+
39
+ def apply(step:, target:, idempotency_key:, **)
40
+ raise InputError, "topology target changed" unless target == @plan.target
41
+
42
+ artifact = resolve(step)
43
+ stages = @topology.send(:stage_scripts, artifact.changes)
44
+ outcome = @device.execute_script(stages.fetch(step.id.to_sym))
45
+ @steps.concat(outcome.steps)
46
+ if step.id == "change"
47
+ record_error(outcome.error) if outcome.failure?
48
+ { status: outcome.success? ? :applied : :unknown, evidence: { completed_commands: outcome.steps.size } }
49
+ elsif outcome.success? && @strategy.persistence_confirmed?(outcome)
50
+ { status: :applied, evidence: { persistence_confirmed: true, artifact_sha256: artifact.sha256,
51
+ operation_key: idempotency_key } }
52
+ else
53
+ record_error(@topology.send(:build_persistence_error, outcome.error))
54
+ { status: :unknown, error_code: :persistence_unconfirmed }
55
+ end
56
+ rescue StandardError => error
57
+ record_error(error)
58
+ raise
59
+ end
60
+
61
+ def verify(step:, target:, receipt:, **)
62
+ raise InputError, "topology target changed" unless target == @plan.target
63
+
64
+ if step.id == "change"
65
+ verify_descriptions!
66
+ { status: :verified }
67
+ elsif receipt.dig(:evidence, :persistence_confirmed) == true &&
68
+ receipt.dig(:evidence, :artifact_sha256) == step.parameters[:sha256] &&
69
+ receipt.dig(:evidence, :operation_key) == receipt[:idempotency_key]
70
+ { status: :verified }
71
+ else
72
+ record_error(@topology.send(:build_persistence_error))
73
+ { status: :unknown, error_code: :persistence_unconfirmed }
74
+ end
75
+ rescue StandardError => error
76
+ record_error(error)
77
+ { status: :unknown, error_code: :verification_unconfirmed }
78
+ end
79
+
80
+ private
81
+
82
+ def validate_plan!
83
+ unless @plan.target.host == @device.host && @plan.target.platform == @device.vendor.to_s &&
84
+ @plan.metadata[:domain] == "interface_descriptions"
85
+ raise InputError, "topology deployment belongs to a different device or domain"
86
+ end
87
+ return if @plan.steps.empty?
88
+
89
+ unless @plan.steps.map(&:id) == %w[change persist] &&
90
+ @plan.steps.all? { |step| step.action == "interface_descriptions_#{step.id}" } &&
91
+ @plan.steps.first.parameters == @plan.steps.last.parameters &&
92
+ @plan.steps.first.expected == @plan.steps.last.expected
93
+ raise InputError, "topology deployment requires ordered change and persist steps"
94
+ end
95
+ @plan.steps.each do |step|
96
+ reference, digest = step.parameters.values_at(:artifact_ref, :sha256)
97
+ unless step.parameters.keys.sort == %i[artifact_ref sha256] && reference.is_a?(String) && !reference.empty? &&
98
+ digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) && @plan.metadata[:artifact_sha256] == digest
99
+ raise InputError, "invalid topology artifact reference"
100
+ end
101
+ end
102
+ end
103
+
104
+ def expected_changes(expected)
105
+ unless expected.keys.sort == %i[changes verification_commands] && expected[:changes].is_a?(Array) && !expected[:changes].empty? &&
106
+ expected[:verification_commands].is_a?(Array) && !expected[:verification_commands].empty?
107
+ raise InputError, "invalid topology readback expectations"
108
+ end
109
+ changes = expected[:changes].map do |item|
110
+ neighbor = item.fetch(:neighbor)
111
+ neighbor = Neighbor.new(**neighbor.merge(protocol: neighbor.fetch(:protocol).to_sym))
112
+ unless item.keys.sort == %i[interface neighbor new_description] && neighbor.to_h.except(:protocol).values.all?(String) &&
113
+ %i[cdp lldp].include?(neighbor.protocol)
114
+ raise InputError, "invalid topology readback identity"
115
+ end
116
+ InterfaceDescription.validate_interface!(item[:interface])
117
+ InterfaceDescription.validate!(item[:new_description])
118
+ Change.new(interface: item[:interface], old_description: "", new_description: item[:new_description], neighbor: neighbor)
119
+ end
120
+ keys = changes.map { |change| @strategy.interface_key(change.interface) }
121
+ raise InputError, "topology readback interfaces must be unique" unless keys.uniq == keys
122
+ @verification = Script.new(expected[:verification_commands])
123
+ changes
124
+ rescue KeyError, TypeError, NoMethodError, ArgumentError
125
+ raise InputError, "invalid topology readback expectations", cause: nil
126
+ end
127
+
128
+ def resolve(step)
129
+ begin
130
+ artifact = @resolver.call(step.parameters.fetch(:artifact_ref))
131
+ rescue StandardError
132
+ raise Netstack::Error.new("topology artifact resolution failed", code: :artifact_unavailable, stage: :preflight), cause: nil
133
+ end
134
+ unless artifact.is_a?(Plan) && artifact.sha256 == step.parameters[:sha256]
135
+ raise Netstack::Error.new("topology artifact is unavailable or changed", code: :artifact_changed, stage: :preflight)
136
+ end
137
+ artifact.validate_target!(@plan.target)
138
+ unless artifact.deployment_plan(target: @plan.target, artifact_ref: step.parameters[:artifact_ref]).payload == @plan.payload
139
+ raise InputError, "topology artifact does not match the reviewed deployment"
140
+ end
141
+ stages = @topology.send(:stage_scripts, artifact.changes)
142
+ unless stages.values.flat_map { |script| script.map(&:text) } == artifact.commands &&
143
+ stages.fetch(:verify).map(&:text) == artifact.verification_commands
144
+ raise ArgumentError, "topology commands changed; regenerate the reviewed plan"
145
+ end
146
+ artifact
147
+ end
148
+
149
+ def verify_neighbors!
150
+ discovered = @topology.neighbors
151
+ grouped = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
152
+ return if @changes.all? { |change| grouped[@strategy.interface_key(change.interface)] == [change.neighbor] }
153
+
154
+ raise DeviceError.new("neighbors changed since topology approval", code: :stale_plan, host: @device.host, phase: :verify)
155
+ end
156
+
157
+ def verify_descriptions!
158
+ @topology.send(:verify_descriptions!, @changes, @verification, @steps)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Connector
5
+ class Topology
6
+ # Durable state is separate from this attempt's write/readback evidence, not a full session transcript.
7
+ class ExecutionResult
8
+ attr_reader :receipt, :result
9
+
10
+ def initialize(receipt:, result:)
11
+ unless receipt.is_a?(Deployment::Receipt) && result.is_a?(Connector::Result)
12
+ raise InputError, "topology execution requires a receipt and a connector result"
13
+ end
14
+ @receipt, @result = receipt, result
15
+ freeze
16
+ end
17
+
18
+ def success? = receipt.verified? || receipt.no_changes?
19
+ def failure? = !success?
20
+ def no_changes? = receipt.no_changes?
21
+
22
+ def value!
23
+ unless success?
24
+ code = receipt.failed? ? :execution_failed : :execution_unconfirmed
25
+ raise Netstack::Error.new("interface description execution was not verified", code: code, stage: :verification), cause: nil
26
+ end
27
+ result.config || result.output
28
+ end
29
+
30
+ def inspect = "#<#{self.class} success=#{success?} steps=#{receipt.steps.size} response_steps=#{result.steps.size}>"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
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
+ # 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
9
+ class ImmediateStrategy < Strategy
10
+ def verification_commands = @device.config_commands
11
+ def persistence_commands = @device.profile.save_commands
12
+
13
+ # TextFSM 不能静默跳过未知接口头;否则目标行正确也不足以证明读回证据完整。
14
+ def validate_descriptions!(config, rows)
15
+ expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
16
+ return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
17
+
18
+ raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
19
+ host: @device.host, phase: :verify)
20
+ end
21
+
22
+ # 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
23
+ def persistence_confirmed?(result)
24
+ !result.steps.empty? && result.steps.all? do |step|
25
+ raw = step.output.delete_suffix(step.prompt.to_s)
26
+ rendered = TerminalRenderer.render(raw)
27
+ failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
28
+ next false if raw.match?(failure) || rendered.match?(failure)
29
+
30
+ rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
31
+ end
32
+ end
33
+
34
+ # 没有明确完成证据的策略不能借用一个泛化的成功关键词。
35
+ def persistence_pattern = /\A\b\B\z/
36
+ end
37
+ end
38
+ end
39
+ end