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,117 @@
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
+ module Storage
10
+ # 路径锁在设备采集之前取得;锁文件长期保留,避免等待者落在已经删除的 inode 上。
11
+ class BackupLock
12
+ STORAGE_KEY = :net_connector_backup_delegation
13
+ private_constant :STORAGE_KEY
14
+
15
+ attr_reader :path
16
+
17
+ def self.lock_path(path)
18
+ destination = File.expand_path(path)
19
+ directory = File.realpath(File.dirname(destination))
20
+ # 锁名先折叠 Unicode 与大小写,避免大小写不敏感文件系统上的别名绕过互斥。
21
+ # 在区分大小写的文件系统上可能保守地合并两个锁,但不会改写目标文件名。
22
+ filename = File.basename(destination).b.force_encoding(Encoding::UTF_8)
23
+ filename = filename.valid_encoding? ? filename.unicode_normalize(:nfc).downcase(:fold).unicode_normalize(:nfc) : filename.b.downcase
24
+ File.join(directory, ".net-connector-#{Digest::SHA256.hexdigest(filename)}.lock")
25
+ end
26
+
27
+ def self.synchronize(path, **options, &) = new(path, **options).synchronize(&)
28
+
29
+ def initialize(path, timeout: 0, host: nil)
30
+ raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
31
+ unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0 && timeout.to_f.finite?
32
+ raise ArgumentError, "lock_timeout must be a nonnegative finite number"
33
+ end
34
+
35
+ @path = File.expand_path(path).freeze
36
+ @lock_path = self.class.lock_path(@path).freeze
37
+ @timeout, @host = timeout.to_f, host
38
+ end
39
+
40
+ def synchronize
41
+ # Fleet 只向下层备份借用一次;消费后清除,采集回调中的递归备份仍须竞争锁。
42
+ # Thread#[] 是 Fiber 局部存储;fork 后不能消费父进程的借用记录。
43
+ if Thread.current[STORAGE_KEY] == [@lock_path, Process.pid]
44
+ Thread.current[STORAGE_KEY] = nil
45
+ return yield self
46
+ end
47
+
48
+ file = nil
49
+ owned = false
50
+ begin
51
+ file = open_lock
52
+ verify_lock!(file)
53
+ acquire(file)
54
+ verify_lock!(file)
55
+ owned = true
56
+ @owner = [Process.pid, Fiber.current]
57
+ yield self
58
+ ensure
59
+ @owner = nil if owned
60
+ file&.close
61
+ end
62
+ end
63
+
64
+ # 仅持有实际 flock 的同一 Fiber 可授权一次内层采集,避免把路径锁变成任意可重入锁。
65
+ def with_delegated_lock
66
+ raise ThreadError, "backup path delegation requires lock ownership" unless @owner == [Process.pid, Fiber.current]
67
+
68
+ previous = Thread.current[STORAGE_KEY]
69
+ begin
70
+ Thread.current[STORAGE_KEY] = [@lock_path, Process.pid]
71
+ yield
72
+ ensure
73
+ Thread.current[STORAGE_KEY] = previous
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def open_lock
80
+ File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
81
+ rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
82
+ raise ArgumentError, "backup lock must be a private regular file", cause: nil
83
+ end
84
+
85
+ def verify_lock!(file)
86
+ stat = file.stat
87
+ unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
88
+ raise ArgumentError, "backup lock must be an owned regular file with mode 0600 and one link"
89
+ end
90
+ current = File.lstat(@lock_path)
91
+ unless current.file? && current.dev == stat.dev && current.ino == stat.ino
92
+ raise ArgumentError, "backup lock path changed while acquiring ownership"
93
+ end
94
+ rescue Errno::ENOENT
95
+ raise ArgumentError, "backup lock path changed while acquiring ownership", cause: nil
96
+ end
97
+
98
+ def acquire(file)
99
+ deadline = monotonic + @timeout
100
+ loop do
101
+ acquired = file.flock(File::LOCK_EX | File::LOCK_NB)
102
+ return if acquired && (@timeout.zero? || monotonic < deadline)
103
+
104
+ remaining = deadline - monotonic
105
+ if remaining <= 0
106
+ raise BackupBusy.new("backup path already belongs to another operation", host: @host, phase: :backup), cause: nil
107
+ end
108
+ wait([remaining, 0.05].min)
109
+ end
110
+ end
111
+
112
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ def wait(seconds) = sleep(seconds)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "tempfile"
6
+
7
+ module Netstack
8
+ module Connector
9
+ module Storage
10
+ # 文件同步和目录项同步分别记录;原子可见不等于断电后仍能恢复新目录项。
11
+ module PrivateFile
12
+ Receipt = Data.define(:path, :state, :phase) do
13
+ def initialize(path:, state:, phase:)
14
+ unless path.is_a?(String) && !path.empty? && %i[not_committed committed durable].include?(state) &&
15
+ %i[resolve_parent temporary_file write file_sync rename directory_open directory_sync cleanup complete].include?(phase)
16
+ raise ArgumentError, "invalid file write receipt"
17
+ end
18
+ super(path: path.dup.freeze, state: state, phase: phase)
19
+ end
20
+
21
+ def committed? = state != :not_committed
22
+ def durable? = state == :durable
23
+ end
24
+
25
+ class WriteError < IOError
26
+ attr_reader :receipt, :code, :underlying_type
27
+
28
+ def initialize(receipt:, underlying_type:)
29
+ raise ArgumentError, "receipt must be a file write receipt" unless receipt.instance_of?(Receipt)
30
+
31
+ @receipt = receipt
32
+ @underlying_type = underlying_type.dup.freeze
33
+ @code, message = if receipt.durable?
34
+ [:file_finalize_failed, "file replacement is durable; finalization failed"]
35
+ elsif receipt.committed?
36
+ [:file_persistence_unconfirmed, "file replacement is committed; durability is unconfirmed"]
37
+ else
38
+ [:file_write_failed, "file write failed before replacement was committed"]
39
+ end
40
+ super(message)
41
+ end
42
+
43
+ def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
44
+ end
45
+
46
+ class PersistenceError < WriteError; end
47
+
48
+ class DirectorySyncUnsupported < WriteError
49
+ def initialize(**options)
50
+ super
51
+ @code = :directory_sync_unsupported
52
+ end
53
+
54
+ def message = "file replacement is committed; directory synchronization is unsupported"
55
+ alias to_s message
56
+ end
57
+
58
+ UNSUPPORTED_DIRECTORY_SYNC = [Errno::EINVAL, Errno::ENOSYS, Errno::ENOTSUP, Errno::EOPNOTSUPP,
59
+ NotImplementedError].uniq.freeze
60
+ private_constant :UNSUPPORTED_DIRECTORY_SYNC
61
+
62
+ # 外部子类可重写消息或回执;只有本写入器定义的具体错误类型可提供完成事实。
63
+ def self.receipt_error?(error)
64
+ [WriteError, PersistenceError, DirectorySyncUnsupported].any? { |type| error.instance_of?(type) }
65
+ end
66
+
67
+ # 统一返回写入回执;失败时从异常 receipt 判断目录项是否已经替换。
68
+ def self.write(path, contents)
69
+ raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
70
+
71
+ receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
72
+ directory = File.realpath(File.dirname(receipt.path))
73
+ destination = File.join(directory, File.basename(receipt.path))
74
+ receipt = receipt.with(phase: :temporary_file)
75
+ Tempfile.create([".net-connector-", ".tmp"], directory) do |file|
76
+ file.binmode
77
+ file.chmod(0o600)
78
+ receipt = receipt.with(phase: :write)
79
+ file.write(contents)
80
+ receipt = receipt.with(phase: :file_sync)
81
+ file.flush
82
+ file.fsync
83
+ receipt = receipt.with(phase: :rename)
84
+ File.rename(file.path, destination)
85
+ receipt = receipt.with(state: :committed, phase: :directory_open)
86
+ File.open(directory, File::RDONLY | File::NOFOLLOW) do |parent|
87
+ raise IOError, "parent is not a directory" unless parent.stat.directory?
88
+
89
+ receipt = receipt.with(phase: :directory_sync)
90
+ parent.fsync
91
+ receipt = receipt.with(state: :durable, phase: :cleanup)
92
+ end
93
+ end
94
+ receipt.with(phase: :complete)
95
+ rescue StandardError, NotImplementedError => error
96
+ raise unless receipt
97
+
98
+ unsupported = receipt.phase == :directory_sync && UNSUPPORTED_DIRECTORY_SYNC.any? { |type| error.is_a?(type) }
99
+ klass = if unsupported
100
+ DirectorySyncUnsupported
101
+ elsif receipt.committed?
102
+ PersistenceError
103
+ else
104
+ WriteError
105
+ end
106
+ raise klass.new(receipt: receipt, underlying_type: error.class.name.to_s), cause: nil
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,64 @@
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
+ module Storage
10
+ # 对同一 FD 做类型检查和读取;NOFOLLOW 只保护最后一个路径分量,目录由调用方保护。
11
+ module SafeFile
12
+ Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
13
+
14
+ def self.open(path, missing: false, replace_symlink: false)
15
+ file = open_handle(path, missing: missing, replace_symlink: replace_symlink)
16
+ return nil unless file
17
+
18
+ begin
19
+ stat = file.stat
20
+ raise ArgumentError, "saved configuration is not a regular file" unless stat.file?
21
+
22
+ file.binmode
23
+ yield file, stat
24
+ ensure
25
+ file.close
26
+ end
27
+ end
28
+
29
+ def self.fingerprint(path, **options)
30
+ self.open(path, **options) { |file, stat| fingerprint_io(path, file, stat) }
31
+ end
32
+
33
+ def self.fingerprint_io(path, file, stat)
34
+ digest = Digest::SHA256.new
35
+ while (chunk = file.read(64 * 1024))
36
+ digest << chunk
37
+ end
38
+ Fingerprint.new(path: path.dup.freeze, sha256: digest.hexdigest.freeze,
39
+ mode: stat.mode & 0o7777, dev: stat.dev, ino: stat.ino)
40
+ end
41
+
42
+ # 未变内容可保留 mtime,但不能把已替换的目录项误认成刚读取的旧文件。
43
+ def self.same_entry?(path, fingerprint)
44
+ stat = File.lstat(path)
45
+ stat.file? && stat.dev == fingerprint.dev && stat.ino == fingerprint.ino
46
+ rescue Errno::ENOENT
47
+ false
48
+ end
49
+
50
+ def self.open_handle(path, missing:, replace_symlink:)
51
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
52
+ rescue Errno::ENOENT
53
+ raise unless missing
54
+ rescue Errno::ELOOP
55
+ raise ArgumentError, "saved configuration is not a regular file", cause: nil unless replace_symlink
56
+ rescue Errno::ENXIO
57
+ raise ArgumentError, "saved configuration is not a regular file", cause: nil
58
+ end
59
+
60
+ private_class_method :open_handle
61
+ end
62
+ end
63
+ end
64
+ 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 "fileutils"
6
+ require "ipaddr"
7
+
8
+ module Netstack
9
+ module Connector
10
+ module Storage
11
+ # 直接导出已有本地配置,无需访问清单服务。
12
+ class SavedConfig
13
+ # 只用规范化的管理地址标识配置,避免设备改名改变备份身份。
14
+ def self.filename(host)
15
+ raise ArgumentError, "host must be an IP address" unless host.is_a?(String) && !host.include?("/")
16
+
17
+ "#{IPAddr.new(host).to_s.tr(":", "_")}.txt"
18
+ rescue IPAddr::Error
19
+ raise ArgumentError, "host must be an IP address"
20
+ end
21
+
22
+ # 只读取规范设备地址对应的文件,不维护第二套历史文件命名。
23
+ def initialize(directory:)
24
+ raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
25
+
26
+ @directory = File.expand_path(directory).freeze
27
+ end
28
+
29
+ # 从同一已验证文件描述符取得路径或内容。
30
+ def find(host, required: true)
31
+ with_file(host, required: required) { |path, _file, _stat| path }
32
+ end
33
+
34
+ # 比较基线也从经过 fstat 的同一 FD 读取,不在 find 后重新按路径打开。
35
+ def fingerprint(host, required: true)
36
+ with_file(host, required: required) { |path, file, stat| SafeFile.fingerprint_io(path, file, stat) }
37
+ end
38
+
39
+ def read(host)
40
+ with_file(host) { |_path, file, _stat| file.read }
41
+ end
42
+
43
+ def with_file(host, required: true)
44
+ path = File.join(@directory, self.class.filename(host))
45
+ SafeFile.open(path, missing: !required) { |file, stat| yield path, file, stat }
46
+ end
47
+
48
+ private :with_file
49
+
50
+ # 将已保存配置写入目标文件或标准输出。
51
+ def export(host:, output: nil, io: $stdout)
52
+ contents = read(host)
53
+ if output
54
+ raise ArgumentError, "output must be a nonempty String" unless output.is_a?(String) && !output.empty?
55
+
56
+ destination = File.expand_path(output)
57
+ directory = File.dirname(destination)
58
+ FileUtils.mkdir_p(directory, mode: 0o700)
59
+ PrivateFile.write(destination, contents).path
60
+ else
61
+ io.binmode if io.respond_to?(:binmode)
62
+ io.write(contents)
63
+ nil
64
+ end
65
+ end
66
+
67
+ # 使用指定模板解析已有配置,无需访问设备或清单服务。
68
+ def parse(host:, template:, template_dir: nil)
69
+
70
+ parser = TextFSM.new(template_dir: template_dir)
71
+ parser.call(read(host), template: template, host: host)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "stringio"
6
+
7
+ module Netstack
8
+ module Connector
9
+ # 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
10
+ class TerminalRenderer
11
+ # 将字节流完整渲染为字符串,供测试和一次性转换使用。
12
+ def self.render(input, strict_utf8: false)
13
+ output = StringIO.new("".b)
14
+ renderer = new(output, strict_utf8: strict_utf8)
15
+ renderer.write(input)
16
+ renderer.finish
17
+ output.string
18
+ end
19
+
20
+ # 初始化当前行、光标和转义序列状态。
21
+ def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
22
+ raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
23
+ unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
24
+ raise ArgumentError, "max_line_bytes must be positive"
25
+ end
26
+ raise ArgumentError, "strict_utf8 must be true or false" unless [true, false].include?(strict_utf8)
27
+
28
+ @target = target
29
+ @line = "".b
30
+ @cursor = 0
31
+ @escape_state = nil
32
+ @csi_parameters = "".b
33
+ @max_line_bytes = max_line_bytes
34
+ @strict_utf8 = strict_utf8
35
+ end
36
+
37
+ # 普通文本成段写入;控制序列仍逐字节解析,保留跨分片状态。
38
+ def write(input)
39
+ bytes = input.to_s.b
40
+ offset = 0
41
+ while offset < bytes.bytesize
42
+ byte = bytes.getbyte(offset)
43
+ if @escape_state || (byte < 32 && byte != 9)
44
+ consume(byte)
45
+ offset += 1
46
+ else
47
+ boundary = bytes.index(/[\x00-\x08\x0a-\x1f]/n, offset) || bytes.bytesize
48
+ write_text(bytes.byteslice(offset, boundary - offset))
49
+ offset = boundary
50
+ end
51
+ end
52
+ bytes.bytesize
53
+ end
54
+
55
+ # 刷新底层日志目标。
56
+ def flush
57
+ @target.flush if @target.respond_to?(:flush)
58
+ end
59
+
60
+ # 写出尚未换行的尾行并刷新目标。
61
+ def finish
62
+ emit_line(newline: false) unless @line.empty?
63
+ flush
64
+ end
65
+
66
+ private
67
+
68
+ # 处理普通控制字符、换行、回车和转义序列起始符。
69
+ def consume(byte)
70
+ return consume_escape(byte) if @escape_state
71
+
72
+ case byte
73
+ when 7 then nil
74
+ when 8 then @cursor = [@cursor - 1, 0].max
75
+ when 10 then emit_line(newline: true)
76
+ when 13 then @cursor = 0
77
+ when 27 then @escape_state = :escape
78
+ else write_byte(byte) if byte == 9 || byte >= 32
79
+ end
80
+ end
81
+
82
+ # 根据转义序列阶段继续解析终端控制字节。
83
+ def consume_escape(byte)
84
+ case @escape_state
85
+ when :escape
86
+ start_escape_sequence(byte)
87
+ when :csi
88
+ finish_csi(byte)
89
+ when :osc
90
+ @escape_state = nil if byte == 7
91
+ @escape_state = :osc_escape if byte == 27
92
+ when :osc_escape
93
+ @escape_state = (byte == 92) ? nil : :osc
94
+ end
95
+ end
96
+
97
+ # 识别 CSI 或 OSC 序列,并忽略未知的单字节序列。
98
+ def start_escape_sequence(byte)
99
+ case byte
100
+ when 91
101
+ @csi_parameters.clear
102
+ @escape_state = :csi
103
+ when 93 then @escape_state = :osc
104
+ else
105
+ @escape_state = nil
106
+ end
107
+ end
108
+
109
+ # 收集 CSI 参数并在终止字节到达时执行光标操作。
110
+ def finish_csi(byte)
111
+ if byte.between?(0x40, 0x7e)
112
+ apply_csi(byte)
113
+ @escape_state = nil
114
+ elsif byte.between?(0x20, 0x3f)
115
+ raise OutputLimitExceeded, "terminal escape sequence is too long" if @csi_parameters.bytesize >= 64
116
+
117
+ @csi_parameters << byte
118
+ else
119
+ @escape_state = nil
120
+ end
121
+ end
122
+
123
+ # 应用有限范围内的光标移动和整行擦除控制。
124
+ def apply_csi(command)
125
+ count = Integer(@csi_parameters[/\d+/, 0] || "1", 10)
126
+ case command
127
+ when 67 then @cursor += count
128
+ when 68 then @cursor = [@cursor - count, 0].max
129
+ when 71 then @cursor = [count - 1, 0].max
130
+ when 75 then erase_line(Integer(@csi_parameters[/\d+/, 0] || "0", 10))
131
+ end
132
+ end
133
+
134
+ # 按终端擦除模式修改当前行缓冲区。
135
+ def erase_line(mode)
136
+ case mode
137
+ when 0 then @line = @line.byteslice(0, @cursor)
138
+ when 1
139
+ @line = (" ".b * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
140
+ when 2
141
+ @line.clear
142
+ @cursor = 0
143
+ end
144
+ end
145
+
146
+ # 在光标位置写入字节,并限制单行缓冲大小。
147
+ def write_byte(byte)
148
+ raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if @cursor >= @max_line_bytes
149
+
150
+ @line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
151
+ (@cursor < @line.bytesize) ? @line.setbyte(@cursor, byte) : @line << byte
152
+ @cursor += 1
153
+ end
154
+
155
+ # 只合并无控制符的字节;超限时沿用逐字节路径,保留失败前已接受的前缀。
156
+ def write_text(text)
157
+ return text.each_byte { |byte| write_byte(byte) } if @cursor + text.bytesize > @max_line_bytes
158
+
159
+ @line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
160
+ if @cursor == @line.bytesize
161
+ @line << text
162
+ else
163
+ @line[@cursor, text.bytesize] = text
164
+ end
165
+ @cursor += text.bytesize
166
+ end
167
+
168
+ # 删除行尾空白,写出当前行并重置光标。
169
+ def emit_line(newline:)
170
+ @target.write(utf8_safe(@line.sub(/[ \t]+\z/n, "")))
171
+ @target.write("\n") if newline
172
+ @line.clear
173
+ @cursor = 0
174
+ end
175
+
176
+ # 日志把非法字节转义;解析必须拒绝终端编辑产生的损坏字节,不能制造可写业务证据。
177
+ def utf8_safe(line)
178
+ text = line.dup.force_encoding(Encoding::UTF_8)
179
+ return text.b if text.valid_encoding?
180
+ raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
181
+
182
+ text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,32 @@
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
+ # 设备/文件提供的是字节,不受 Ruby 源文件编码控制。解析边界只接受 UTF-8,
8
+ # 不猜测编码、不改写原文,也不让终端编辑把非法字节抹掉后变成可信证据。
9
+ module TerminalText
10
+ def self.utf8(bytes, host: nil)
11
+ raise ArgumentError, "text must be a String" unless bytes.is_a?(String)
12
+
13
+ text = bytes.b.force_encoding(Encoding::UTF_8)
14
+ invalid_encoding!(host) unless text.valid_encoding?
15
+ text
16
+ end
17
+
18
+ def self.render(bytes, host: nil)
19
+ TerminalRenderer.render(utf8(bytes, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
20
+ rescue Encoding::InvalidByteSequenceError
21
+ invalid_encoding!(host)
22
+ end
23
+
24
+ def self.invalid_encoding!(host)
25
+ raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
26
+ host: host, phase: :parse), cause: nil
27
+ end
28
+
29
+ private_class_method :invalid_encoding!
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,71 @@
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
+ # 将设备命令回显或配置文本按 TextFSM 模板转换为结构化记录。
8
+ class TextFSM
9
+ # 设备入口与功能实现共置,Device 只组合能力,不重复业务流程。
10
+ module Capability
11
+ # 执行命令并按厂商、命令或显式 TextFSM 模板返回结构化记录。
12
+ def parse_command(command, template: nil, template_dir: nil)
13
+ parser = TextFSM.new(template_dir: template_dir)
14
+ parser.call(execute_command(command).value!, template: template, vendor: vendor, command: command, host: host)
15
+ end
16
+
17
+ # 采集运行配置并使用指定 TextFSM 模板提取结构化记录。
18
+ def parse_config(template:, template_dir: nil)
19
+ parser = TextFSM.new(template_dir: template_dir)
20
+ parser.call(running_config.value!, template: template, host: host)
21
+ end
22
+ end
23
+
24
+ DEFAULT_TEMPLATE_DIR = File.expand_path("../../../resources/textfsm", __dir__).freeze
25
+
26
+ # 配置模板目录和 Netmiko 风格的命令索引文件。
27
+ def initialize(template_dir: nil, index: "index")
28
+ template_dir ||= DEFAULT_TEMPLATE_DIR
29
+ raise ArgumentError, "template_dir must be a nonempty String" unless template_dir.is_a?(String) && !template_dir.empty?
30
+ raise ArgumentError, "index must be a nonempty String" unless index.is_a?(String) && !index.empty?
31
+
32
+ @template_dir = File.expand_path(template_dir)
33
+ @index = index
34
+ end
35
+
36
+ # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
37
+ def call(text, template: nil, vendor: nil, command: nil, host: nil)
38
+ require "textfsm"
39
+
40
+ text = TerminalText.render(text, host: host)
41
+ if template
42
+ raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
43
+
44
+ ::TextFSM::Parser.from_file(File.expand_path(template, @template_dir)).parse_hashes(text)
45
+ else
46
+ raise ArgumentError, "vendor and command are required for template lookup" unless vendor && command.is_a?(String) && !command.empty?
47
+
48
+ attributes = { "Vendor" => vendor.to_s, "Command" => command }
49
+ table = ::TextFSM::CliTable.new(index: @index, template_dir: @template_dir)
50
+ unless table.index.match(attributes)
51
+ raise ParsingError.new("no TextFSM template matches this vendor and command",
52
+ code: :template_missing, host: host, phase: :parse)
53
+ end
54
+ table.parse(text, attributes: attributes).to_hashes
55
+ end
56
+ rescue ::TextFSM::IndexError
57
+ raise ParsingError.new("TextFSM template index is invalid",
58
+ code: :template_invalid, host: host, phase: :parse), cause: nil
59
+ rescue ::TextFSM::TemplateError
60
+ raise ParsingError.new("TextFSM template is invalid",
61
+ code: :template_invalid, host: host, phase: :parse), cause: nil
62
+ rescue ::TextFSM::ParseError
63
+ raise ParsingError.new("TextFSM could not parse the device output",
64
+ code: :parse_failed, host: host, phase: :parse), cause: nil
65
+ rescue IOError, SystemCallError
66
+ raise ParsingError.new("unable to read TextFSM template",
67
+ code: :template_unreadable, host: host, phase: :parse), cause: nil
68
+ end
69
+ end
70
+ end
71
+ end