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,19 @@
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
+ module Vendors
8
+ class Radware
9
+ class Topology < Netstack::Connector::Topology::Strategy
10
+ # Alteon 只支持读取端口描述,不提供邻居发现或自动改写。
11
+ def self.supports?(capability) = capability == :interface_descriptions
12
+
13
+ # 从设备配置转储解析端口名称。
14
+ def description_template = "radware_port_names.textfsm"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,59 @@
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
+ module Vendors
8
+ class Radware < Netstack::Connector::Device
9
+ vendor :radware
10
+ # 声明菜单式 CLI 的命令、提示、超时、诊断和确认规则。
11
+ profile do
12
+ running_config_strategy Netstack::Connector::Vendors::Radware::RunningConfig
13
+ tftp_strategy Netstack::Connector::Vendors::Radware::TftpBackup
14
+ topology_strategy Netstack::Connector::Vendors::Radware::Topology
15
+ commands do
16
+ running_config "/cfg/dump"
17
+ save_config "/cfg/save"
18
+ end
19
+
20
+ prompts do
21
+ login(/(?:\A|(?<=[\r\n]))(?:>>[^\r\n]*#|(?!>>)[^\r\n]+[>#])\s*\z/)
22
+ command(/(?:\A|(?<=[\r\n]))[^\r\n]+#\s*\z/)
23
+ end
24
+
25
+ errors do
26
+ command(/^[ \t]*(?:Unknown|Invalid|Error)\s+command\b/i)
27
+ end
28
+
29
+ command_timeout 30
30
+
31
+ interactions do
32
+ confirm %r{Confirm\s+Sync\s+to\s+Peer\s+\[y/n\]:\s*\z}i, response: "y\n"
33
+ confirm %r{Synchronize\s+configuration\s+changes\?\s+\[y/n\]:\s*\z}i, response: "y\n"
34
+ confirm %r{Confirm\s+saving\s+to\s+FLASH\s+\[y/n\]:\s*\z}i, response: "y\n"
35
+ confirm %r{Confirm\s+operation\s+without\s+applying\s+changes\s+
36
+ \(leave\s+them\s+pending\)\s+\[y/n\]:\s*\z}ix, response: "y\n"
37
+ confirm %r{Confirm\s+operation\s+without\s+saving\s+changes\s+
38
+ \(leave\s+them\s+pending\)\s+\[y/n\]:?\s*\z}ix, response: "y\n"
39
+ confirm(/Confirm\s+seeing\s+above\s+note\s+\[y\]:\s*\z/i, response: "y\n")
40
+ confirm(/Please\s+enter\s+y\s+to\s+perform\s+the\s+action,\s+n\s+to\s+skip\s+it:\s*\z/i,
41
+ response: "y\n"
42
+ )
43
+ confirm %r{Display\s+private\s+keys\?\s+\[y/n\]:\s*\z}i, response: "n\n"
44
+ end
45
+ end
46
+
47
+ # 连接有效期间返回登录横幅中的 HA 状态。
48
+ def ha_state = connected? ? @ha_state : nil
49
+
50
+ protected
51
+
52
+ # 从登录横幅提取 HA 状态并冻结,供连接期间查询。
53
+ def after_login(_session, response)
54
+ @ha_state = response.raw[/^\s*HA\s+State:\s*(\S+)/i, 1]&.freeze
55
+ end
56
+ end
57
+ end
58
+ end
59
+ 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 WriteTimeout < Error; end
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../netstack" unless defined?(Netstack::LOADER)
4
+
5
+ module Netstack
6
+ # 单设备会话、厂商语法及配置操作;不读取清单或持久化执行任务。
7
+ module Connector
8
+ VENDORS = {
9
+ h3c: "H3c", h3c_wireless: "H3cWireless", cisco_ios: "CiscoIos",
10
+ cisco_nxos: "CiscoNxos", radware: "Radware", palo_alto: "PaloAlto",
11
+ huawei: "Huawei", hillstone: "Hillstone"
12
+ }.freeze
13
+
14
+ def self.vendors = VENDORS.keys
15
+
16
+ def self.vendor_class(vendor)
17
+ key = vendor.to_s.downcase.tr(" -", "__").to_sym
18
+ name = VENDORS.fetch(key) { raise ArgumentError, "unsupported vendor: #{vendor.inspect}" }
19
+ Vendors.const_get(name, false)
20
+ end
21
+
22
+ def self.build(vendor, **settings)
23
+ vendor_class(vendor).new(**settings)
24
+ end
25
+
26
+ def self.open(vendor, **settings, &)
27
+ raise ArgumentError, "open requires a block" unless block_given?
28
+
29
+ vendor_class(vendor).open(**settings, &)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Ddi
5
+ # 只有开始检查点已持久化,才应用范围内的有序意图。
6
+ # 恢复通过读取当前状态并绑定新建父对象 ID 推进,不重放写入。
7
+ # 变更已验证与 DNS/DHCP 服务已部署分别表达。
8
+ class Applier
9
+ def initialize(client:, settings:)
10
+ @settings = settings
11
+ @state = State.new(client: client, settings: settings)
12
+ end
13
+
14
+ def call(plan:, execution_id:, checkpoint:)
15
+ recover(plan: plan, execution_id: execution_id, events: [], checkpoint: checkpoint)
16
+ end
17
+
18
+ def recover(plan:, execution_id:, events:, checkpoint:)
19
+ raise InputError, "checkpoint must be callable" unless checkpoint.respond_to?(:call)
20
+ id = Values.text(execution_id, name: "execution_id")
21
+ raise InputError, "DDI plan belongs to a different scope" unless plan.scope == @settings.to_h
22
+ groups = validate_events!(plan, id, events)
23
+ plan.operations.each_with_index do |operation, index|
24
+ history = groups.fetch(index, [])
25
+ next if history.last&.fetch("stage") == "verified"
26
+ operation = bind_parent(plan, operation, index, groups)
27
+ current = @state.read(operation.fetch("kind"), operation.fetch("identity"))
28
+ base = { "execution_id" => id, "plan_digest" => plan.digest, "operation" => index }
29
+ if history.empty?
30
+ unless current == operation["before"]
31
+ raise Netstack::Error.new("DDI object changed after planning", code: :state_conflict, stage: :preflight)
32
+ end
33
+ persist!(checkpoint, base.merge("stage" => "started"))
34
+ receipt = @state.write(operation)
35
+ persist!(checkpoint, base.merge("stage" => "receipt", "receipt" => receipt))
36
+ current = @state.read(operation.fetch("kind"), operation.fetch("identity"))
37
+ else
38
+ receipt = history.find { |event| event["stage"] == "receipt" }&.fetch("receipt")
39
+ end
40
+ expected_id = receipt.is_a?(Integer) ? receipt : operation["desired"] && operation.dig("before", "id")
41
+ unless @state.matches?(current, operation["desired"]) && (!expected_id || current&.fetch("id", nil) == expected_id)
42
+ raise Netstack::Error.new("DDI write outcome needs reconciliation", code: :unknown_result, stage: :verification,
43
+ details: { "operation" => index })
44
+ end
45
+ verified = base.merge("stage" => "verified", "observed" => current)
46
+ persist!(checkpoint, verified)
47
+ groups[index] = [verified]
48
+ end
49
+ Values.copy({ "execution_id" => id, "plan_digest" => plan.digest, "state" => "verified" })
50
+ end
51
+
52
+ private
53
+
54
+ def bind_parent(plan, operation, index, groups)
55
+ return operation unless %w[role record].include?(operation["kind"])
56
+ identity = operation.fetch("identity")
57
+ parent_index = (0...index).to_a.reverse.find do |candidate|
58
+ previous = plan.operations[candidate]
59
+ previous["kind"] == "zone" && previous["desired"] && previous["identity"].slice("view_id", "name") == identity.slice("view_id", "name")
60
+ end
61
+ return operation unless parent_index
62
+ observed = groups.fetch(parent_index).last.fetch("observed")
63
+ operation.merge("identity" => identity.merge("zone_id" => observed.fetch("id")))
64
+ end
65
+
66
+ def validate_events!(plan, execution_id, events)
67
+ raise InputError, "DDI checkpoints must be an array" unless events.is_a?(Array)
68
+ groups = {}
69
+ events.each do |event|
70
+ unless event.is_a?(Hash) && event["execution_id"] == execution_id && event["plan_digest"] == plan.digest && event["operation"].is_a?(Integer) && event["operation"].between?(0, plan.operations.length - 1)
71
+ raise InputError, "DDI checkpoints do not match this execution"
72
+ end
73
+ index = event["operation"]
74
+ if index.positive? && groups[index - 1]&.last&.fetch("stage") != "verified"
75
+ raise InputError, "DDI operations are out of order"
76
+ end
77
+ history = (groups[index] ||= [])
78
+ stages = history.map { |item| item["stage"] } + [event["stage"]]
79
+ unless [%w[started], %w[started receipt], %w[started verified], %w[started receipt verified]].include?(stages)
80
+ raise InputError, "Invalid DDI checkpoint stage"
81
+ end
82
+ if event["stage"] == "receipt" && event["receipt"] != true && !(event["receipt"].is_a?(Integer) && event["receipt"].positive?)
83
+ raise InputError, "Invalid DDI receipt"
84
+ end
85
+ if event["stage"] == "verified"
86
+ operation = plan.operations[index]
87
+ receipt = history.find { |item| item["stage"] == "receipt" }&.fetch("receipt")
88
+ expected_id = receipt.is_a?(Integer) ? receipt : operation["desired"] && operation.dig("before", "id")
89
+ unless @state.matches?(event["observed"], operation["desired"]) && (!expected_id || event.dig("observed", "id") == expected_id)
90
+ raise InputError, "DDI verification does not match its receipt"
91
+ end
92
+ end
93
+ history << event
94
+ end
95
+ groups
96
+ end
97
+
98
+ def persist!(checkpoint, event)
99
+ raise Netstack::Error.new("DDI checkpoint was not committed", code: :checkpoint_failed, stage: :checkpoint) unless checkpoint.call(Values.copy(event)) == true
100
+ rescue Netstack::Error
101
+ raise
102
+ rescue StandardError
103
+ raise Netstack::Error.new("DDI checkpoint failed", code: :checkpoint_failed, stage: :checkpoint), cause: nil
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Ddi
5
+ # Legacy v1 exposes server-wide status, not a submission ID. Require an
6
+ # observed active phase after our receipt before accepting DONE.
7
+ class Deployment
8
+ ACTIVE = [-1, 0, 1].freeze
9
+ FAILED = [2, 3, 4, 6].freeze
10
+
11
+ def initialize(client:, settings:)
12
+ @client = client
13
+ @settings = settings
14
+ @scope = Scope.new(client: client, settings: settings)
15
+ end
16
+
17
+ def submit(plan:, mutation_result:, checkpoint:, events: [])
18
+ raise InputError, "checkpoint must be callable" unless checkpoint.respond_to?(:call)
19
+ unless plan.scope == @settings.to_h && mutation_result.is_a?(Hash) && mutation_result["state"] == "verified" && mutation_result["plan_digest"] == plan.digest
20
+ raise InputError, "Deployment requires verified DDI writes in this scope"
21
+ end
22
+ id = Values.text(mutation_result["execution_id"], name: "execution_id")
23
+ groups = validate_events!(plan, id, events)
24
+ return [] if plan.operations.empty? || plan.services.empty?
25
+ raise InputError, "No deployment servers configured" if @settings.server_ids.empty?
26
+ emitted = []
27
+ @settings.server_ids.each do |server_id|
28
+ history = groups[server_id]
29
+ if history
30
+ raise Netstack::Error.new("Deployment submission outcome is unknown; do not replay it", code: :unknown_result) if history.last["stage"] == "started"
31
+ raise Netstack::Error.new("Deployment needs review before further submissions", code: :deployment_failed) if %w[failed warning].include?(history.last["stage"])
32
+ next
33
+ end
34
+ status = status(server_id)
35
+ raise Netstack::Error.new("Another deployment is active", code: :deployment_busy) if ACTIVE.include?(status)
36
+ base = { "execution_id" => id, "plan_digest" => plan.digest, "server_id" => server_id, "services" => plan.services }
37
+ persist!(checkpoint, base.merge("stage" => "started"), emitted)
38
+ @client.write(:post, "deployServerServices", query: { serverId: server_id, services: "services=#{plan.services.join(",")}" })
39
+ persist!(checkpoint, base.merge("stage" => "receipt"), emitted)
40
+ end
41
+ Values.copy(emitted)
42
+ end
43
+
44
+ def recover(plan:, execution_id:, events:, checkpoint:)
45
+ raise InputError, "checkpoint must be callable" unless checkpoint.respond_to?(:call)
46
+ raise InputError, "DDI deployment scope changed" unless plan.scope == @settings.to_h
47
+ execution_id = Values.text(execution_id, name: "execution_id")
48
+ groups = validate_events!(plan, execution_id, events)
49
+ return {} if plan.operations.empty? || plan.services.empty?
50
+ result = {}
51
+ @settings.server_ids.each do |server_id|
52
+ history = groups[server_id]
53
+ unless history
54
+ result[server_id] = "not_submitted"
55
+ next
56
+ end
57
+ last = history.last
58
+ if %w[verified warning failed].include?(last["stage"])
59
+ result[server_id] = last["stage"]
60
+ next
61
+ end
62
+ current = status(server_id)
63
+ # A lost submission receipt cannot be correlated through this API.
64
+ if last["stage"] == "started"
65
+ result[server_id] = "unknown"
66
+ next
67
+ end
68
+ if ACTIVE.include?(current)
69
+ persist!(checkpoint, last.merge("stage" => "active", "status" => current), []) unless last["stage"] == "active"
70
+ result[server_id] = "pending"
71
+ elsif current == 7 && last["stage"] == "active"
72
+ persist!(checkpoint, last.merge("stage" => "verified", "status" => current), [])
73
+ result[server_id] = "verified"
74
+ elsif FAILED.include?(current) && last["stage"] == "active"
75
+ persist!(checkpoint, last.merge("stage" => "failed", "status" => current), [])
76
+ result[server_id] = "failed"
77
+ elsif current == 5 && last["stage"] == "active"
78
+ persist!(checkpoint, last.merge("stage" => "warning", "status" => current), [])
79
+ result[server_id] = "warning"
80
+ else
81
+ result[server_id] = "unknown"
82
+ end
83
+ end
84
+ Values.copy(result)
85
+ end
86
+
87
+ private
88
+
89
+ def validate_events!(plan, execution_id, events)
90
+ raise InputError, "Invalid deployment checkpoints" unless events.is_a?(Array)
91
+ raise InputError, "Empty plans cannot have deployment checkpoints" if !events.empty? && (plan.operations.empty? || plan.services.empty?)
92
+ groups = {}
93
+ events.each do |event|
94
+ unless event.is_a?(Hash) && event["execution_id"] == execution_id && event["plan_digest"] == plan.digest && @settings.server_ids.include?(event["server_id"]) && event["services"] == plan.services
95
+ raise InputError, "Deployment checkpoints do not match this execution"
96
+ end
97
+ server_id = event["server_id"]
98
+ group = (groups[server_id] ||= [])
99
+ stage = event["stage"]
100
+ valid_stage = case group.last&.fetch("stage")
101
+ when nil then stage == "started"
102
+ when "started" then stage == "receipt"
103
+ when "receipt" then stage == "active" && ACTIVE.include?(event["status"])
104
+ when "active" then (stage == "verified" && event["status"] == 7) || (stage == "warning" && event["status"] == 5) || (stage == "failed" && FAILED.include?(event["status"]))
105
+ else false
106
+ end
107
+ raise InputError, "Invalid deployment checkpoint sequence" unless valid_stage
108
+ if stage == "started"
109
+ preceding = @settings.server_ids.take_while { |id| id != server_id }
110
+ unless preceding.all? { |id| groups[id]&.any? { |item| item["stage"] == "receipt" } }
111
+ raise InputError, "Deployment submissions are out of order"
112
+ end
113
+ end
114
+ group << event
115
+ end
116
+ groups
117
+ end
118
+
119
+ def status(server_id)
120
+ @scope.entity!(server_id, types: ["Server"])
121
+ value = @client.json("getServerDeploymentStatus", serverId: server_id)
122
+ raise Netstack::Error.new("Invalid deployment status", code: :invalid_response) unless value.is_a?(Integer) && (-1..8).cover?(value)
123
+ value
124
+ end
125
+
126
+ def persist!(checkpoint, event, events)
127
+ event = Values.copy(event)
128
+ raise Netstack::Error.new("Deployment checkpoint was not committed", code: :checkpoint_failed, stage: :checkpoint) unless checkpoint.call(event) == true
129
+ events << event
130
+ rescue Netstack::Error
131
+ raise
132
+ rescue StandardError
133
+ raise Netstack::Error.new("Deployment checkpoint failed", code: :checkpoint_failed, stage: :checkpoint), cause: nil
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Netstack
6
+ module Ddi
7
+ module Inputs
8
+ module_function
9
+
10
+ def id(value) = Values.integer(value, name: "BlueCat object ID", min: 1)
11
+
12
+ def domain(value)
13
+ value = Values.text(value, name: "domain").downcase.delete_suffix(".")
14
+ unless value.length <= 253 && value.split(".", -1).length >= 2 && value.split(".", -1).all? { |label| label.match?(/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/) }
15
+ raise InputError, "Invalid DNS name"
16
+ end
17
+ value
18
+ end
19
+
20
+ def ipv4(value)
21
+ value = Values.text(value, name: "IPv4 address")
22
+ address = IPAddr.new(value)
23
+ raise InputError, "Invalid IPv4 address" unless address.ipv4? && !value.include?("/")
24
+ address.to_s
25
+ rescue IPAddr::InvalidAddressError
26
+ raise InputError, "Invalid IPv4 address"
27
+ end
28
+
29
+ def network(value)
30
+ value = Values.text(value, name: "IPv4 CIDR")
31
+ parts = value.split("/", -1)
32
+ raise InputError, "IPv4 CIDR must contain one address and one prefix" unless parts.size == 2
33
+ address, prefix = parts
34
+ prefix = Values.integer(prefix, name: "IPv4 prefix", min: 0, max: 30)
35
+ network = IPAddr.new("#{ipv4(address)}/#{prefix}")
36
+ raise InputError, "CIDR must be an aligned IPv4 network" unless network.to_s == ipv4(address)
37
+ network
38
+ end
39
+
40
+ def cidr(value)
41
+ network = network(value)
42
+ "#{network}/#{network.prefix}"
43
+ end
44
+
45
+ def mac(value)
46
+ value = Values.text(value, name: "MAC address").downcase.tr("-", ":")
47
+ raise InputError, "Invalid MAC address" unless value.match?(/\A(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\z/)
48
+ value
49
+ end
50
+
51
+ def properties(value)
52
+ raise Netstack::Error.new("Invalid BlueCat properties", code: :invalid_response) unless value.is_a?(String)
53
+ value.split(/(?<!\\)\|/).each_with_object({}) do |part, result|
54
+ key, content = part.split("=", 2)
55
+ raise Netstack::Error.new("Invalid BlueCat properties", code: :invalid_response) unless key && content && !result.key?(key)
56
+ result[key] = content.gsub("\\|", "|")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ # 限定范围的 DNS/DHCP/IPAM 意图;BlueCat 传输与宿主事务各自独立。
5
+ module Ddi
6
+ # Produces a reviewable intent from exact current state; planning never writes.
7
+ class Manager
8
+ def initialize(client:, settings:)
9
+ @client = client
10
+ @settings = settings
11
+ @state = State.new(client: client, settings: settings)
12
+ end
13
+
14
+ def dns_zone(name:, replace: false)
15
+ dns_plan(name: name, replace: replace)
16
+ end
17
+
18
+ def dns_record(name:, value:, type: "A", replace: false)
19
+ raise InputError, "Only A and CNAME records are supported" unless %w[A CNAME].include?(type)
20
+ value = type == "A" ? Inputs.ipv4(value) : Inputs.domain(value)
21
+ dns_plan(name: name, replace: replace, record: { "type" => type, "rdata" => value })
22
+ end
23
+
24
+ def dns_forward(name:, roles:, replace: false)
25
+ roles = Settings.roles(roles)
26
+ raise InputError, "Forward zones require a FORWARDER role" unless roles.any? { |role| role["type"] == "FORWARDER" }
27
+ dns_plan(name: name, replace: replace, roles: roles)
28
+ end
29
+
30
+ def delete_zone(name:, recursive: false)
31
+ raise InputError, "Zone deletion requires explicit recursive intent" unless recursive == true
32
+ name = Inputs.domain(name)
33
+ require_views!
34
+ operations = @settings.views.filter_map do |view|
35
+ operation("zone", { "view_id" => view["id"], "name" => name }, nil, delete: true)
36
+ end
37
+ plan(operations: operations, services: ["DNS"])
38
+ end
39
+
40
+ def blacklist(name:, present: true)
41
+ raise InputError, "Blacklist policy is not configured" unless @settings.policy_id
42
+ raise InputError, "Blacklist present must be boolean" unless [true, false].include?(present)
43
+ identity = { "name" => Inputs.domain(name), "policy_id" => @settings.policy_id }
44
+ op = operation("blacklist", identity, present ? { "present" => true } : nil, delete: !present)
45
+ plan(operations: [op].compact, services: ["DNS"])
46
+ end
47
+
48
+ def block(cidr:, parent_id: @settings.configuration_id)
49
+ cidr = Inputs.cidr(cidr)
50
+ parent_id = Inputs.id(parent_id)
51
+ validate_container!(parent_id, cidr) unless parent_id == @settings.configuration_id
52
+ op = operation("block", { "parent_id" => parent_id, "cidr" => cidr }, { "cidr" => cidr })
53
+ plan(operations: [op].compact, services: [])
54
+ end
55
+
56
+ def network(cidr:, block_id:, gateway:)
57
+ cidr, block_id, gateway = Inputs.cidr(cidr), Inputs.id(block_id), Inputs.ipv4(gateway)
58
+ validate_container!(block_id, cidr)
59
+ usable!(Inputs.network(cidr), gateway)
60
+ op = operation("network", { "parent_id" => block_id, "cidr" => cidr }, { "cidr" => cidr, "gateway" => gateway })
61
+ plan(operations: [op].compact, services: ["DHCP"])
62
+ end
63
+
64
+ def delete_network(cidr:, block_id:)
65
+ identity = { "parent_id" => Inputs.id(block_id), "cidr" => Inputs.cidr(cidr) }
66
+ op = operation("network", identity, nil, delete: true)
67
+ plan(operations: [op].compact, services: ["DHCP"])
68
+ end
69
+
70
+ def dhcp_range(cidr:, network_id:, start: nil, end_address: nil, reserve_tail: nil)
71
+ network = Inputs.network(cidr)
72
+ network_id = Inputs.id(network_id)
73
+ row = @client.entity("getEntityById", id: network_id)
74
+ unless row && row["type"] == "IP4Network" && Inputs.properties(row["properties"])["CIDR"] == "#{network}/#{network.prefix}"
75
+ raise InputError, "DHCP network ID does not match the exact CIDR"
76
+ end
77
+ if reserve_tail
78
+ raise InputError, "Specify explicit bounds or reserve_tail" if start || end_address
79
+ tail = Values.integer(reserve_tail, name: "reserved tail", min: 1)
80
+ start = network.to_range.first.succ.to_s
81
+ last = network.to_range.last.to_i - tail
82
+ raise InputError, "Network is too small for the reserved tail" unless last > network.to_i
83
+ end_address = IPAddr.new(last, Socket::AF_INET).to_s
84
+ end
85
+ start, end_address = Inputs.ipv4(start), Inputs.ipv4(end_address)
86
+ usable!(network, start)
87
+ usable!(network, end_address)
88
+ raise InputError, "DHCP range end precedes start" if IPAddr.new(start).to_i > IPAddr.new(end_address).to_i
89
+ gateway = Inputs.properties(row["properties"])["gateway"]
90
+ if gateway && (IPAddr.new(start).to_i..IPAddr.new(end_address).to_i).cover?(IPAddr.new(Inputs.ipv4(gateway)).to_i)
91
+ raise InputError, "DHCP range includes the gateway"
92
+ end
93
+ identity = { "network_id" => network_id, "start" => start, "end" => end_address }
94
+ op = operation("range", identity, identity.slice("start", "end"))
95
+ plan(operations: [op].compact, services: ["DHCP"])
96
+ end
97
+
98
+ def fixed_address(ip:, mac:)
99
+ address_plan(ip: ip, mac: Inputs.mac(mac))
100
+ end
101
+
102
+ def reserve_address(ip:)
103
+ ip = Inputs.ipv4(ip)
104
+ before = @state.read("address", { "ip" => ip })
105
+ raise InputError, "An existing MAC binding is required" unless before && before["mac"]
106
+ address_plan(ip: ip, mac: Inputs.mac(before["mac"]))
107
+ end
108
+
109
+ private
110
+
111
+ def plan(operations:, services:)
112
+ Plan.new(operations: operations, services: services, scope: @settings.to_h)
113
+ end
114
+
115
+ def dns_plan(name:, replace:, record: nil, roles: nil)
116
+ raise InputError, "replace must be boolean" unless [true, false].include?(replace)
117
+ name = Inputs.domain(name)
118
+ require_views!
119
+ operations = []
120
+ @settings.views.each do |view|
121
+ identity = { "view_id" => view["id"], "name" => name }
122
+ existing = @state.read("zone", identity)
123
+ if replace && existing
124
+ validate_delete!(existing)
125
+ operations << { "kind" => "zone", "identity" => identity, "before" => existing, "desired" => nil }
126
+ end
127
+ fresh = replace || existing.nil?
128
+ desired = { "name" => name, "deployable" => "true" }
129
+ op = fresh ? { "kind" => "zone", "identity" => identity, "before" => nil, "desired" => desired } : operation("zone", identity, desired)
130
+ operations << op if op
131
+ (roles || view["roles"]).each do |role|
132
+ role_identity = identity.merge("interface_id" => role["interface_id"])
133
+ role_identity["zone_id"] = existing["id"] unless fresh
134
+ desired = { "type" => role["type"] }
135
+ op = fresh ? { "kind" => "role", "identity" => role_identity, "before" => nil, "desired" => desired } : operation("role", role_identity, desired)
136
+ operations << op if op
137
+ end
138
+ next unless record
139
+ record_identity = identity.merge("type" => record["type"])
140
+ record_identity["zone_id"] = existing["id"] unless fresh
141
+ unless fresh
142
+ other_type = record["type"] == "A" ? "CNAME" : "A"
143
+ raise InputError, "A and CNAME cannot coexist at the same name" if @state.read("record", identity.merge("type" => other_type))
144
+ end
145
+ op = fresh ? { "kind" => "record", "identity" => record_identity, "before" => nil, "desired" => record } : operation("record", record_identity, record)
146
+ operations << op if op
147
+ end
148
+ plan(operations: operations, services: ["DNS"])
149
+ end
150
+
151
+ def address_plan(ip:, mac:)
152
+ ip = Inputs.ipv4(ip)
153
+ network = @client.entity("getIPRangedByIP", containerId: @settings.configuration_id, type: "IP4Network", address: ip)
154
+ raise InputError, "Address does not belong to a managed network" unless network && network["type"] == "IP4Network"
155
+ props = Inputs.properties(network["properties"])
156
+ usable!(Inputs.network(props["CIDR"]), ip)
157
+ raise InputError, "Gateway cannot be reserved as a client address" if props["gateway"] == ip
158
+ identity = { "ip" => ip }
159
+ before = @state.read("address", identity)
160
+ raise InputError, "Address is bound to another MAC" if before && before["mac"] && before["mac"] != mac
161
+ desired = { "mac" => mac, "state" => "DHCP_RESERVED" }
162
+ op = @state.matches?(before, desired) ? nil : { "kind" => "address", "identity" => identity, "before" => before, "desired" => desired }
163
+ plan(operations: [op].compact, services: ["DHCP"])
164
+ end
165
+
166
+ def operation(kind, identity, desired, delete: false)
167
+ before = @state.read(kind, identity)
168
+ return nil if @state.matches?(before, desired)
169
+ validate_delete!(before) if delete && before && kind != "blacklist"
170
+ raise Netstack::Error.new("Existing DDI object conflicts with requested state", code: :state_conflict) if before && !delete
171
+ { "kind" => kind, "identity" => identity, "before" => before, "desired" => desired }
172
+ end
173
+
174
+ def validate_delete!(before)
175
+ @state.validate_delete!(before.fetch("id"))
176
+ end
177
+
178
+ def validate_container!(id, cidr)
179
+ row = @client.entity("getEntityById", id: id)
180
+ raise InputError, "Parent is not an IPv4 block" unless row && row["type"] == "IP4Block"
181
+ parent = Inputs.network(Inputs.properties(row["properties"])["CIDR"])
182
+ child = Inputs.network(cidr)
183
+ raise InputError, "Child network must be inside its parent block" unless parent.include?(child) && parent.prefix < child.prefix
184
+ end
185
+
186
+ def usable!(network, ip)
187
+ address = IPAddr.new(ip)
188
+ return if network.include?(address) && address != network.to_range.first && address != network.to_range.last
189
+ raise InputError, "Address must be a usable host inside the network"
190
+ end
191
+
192
+ def require_views!
193
+ raise InputError, "At least one managed DNS view is required" if @settings.views.empty?
194
+ end
195
+ end
196
+ end
197
+ end