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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Netstack
7
+ module Configuration
8
+ # Content is deliberately absent from inspect, summary and deployment plans.
9
+ class Artifact
10
+ PLATFORMS = Values.copy({ "h3c" => %w[h3c], "cisco" => %w[cisco_nxos], "hillstone" => %w[hillstone],
11
+ "paloalto" => %w[palo_alto], "radware" => %w[radware] })
12
+ attr_reader :filename, :scenario, :vendor, :created_at, :sha256, :template_sha256, :sections
13
+
14
+ def self.script(target:, content:, filename: "configuration.txt", created_at: Time.now.utc)
15
+ raise InputError, "manual script target must be a Target" unless target.is_a?(Target)
16
+
17
+ new(filename: filename, content: content, scenario: "manual", vendor: target.platform, created_at: created_at,
18
+ template_sha256: nil, target: target)
19
+ end
20
+
21
+ def initialize(filename:, content:, scenario:, vendor:, created_at:, template_sha256:, sections: {}, target: nil)
22
+ @filename = Values.text(filename, name: "artifact filename")
23
+ raise InputError, "artifact filename must be a plain filename" unless filename.match?(/\A[a-zA-Z0-9_.-]+\.txt\z/)
24
+ unless content.is_a?(String) && content.valid_encoding? && !content.empty? && !content.match?(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
25
+ raise InputError, "artifact must contain valid configuration text"
26
+ end
27
+ @content = content.dup.freeze
28
+ @scenario = Values.text(scenario, name: "scenario")
29
+ @vendor = Values.text(vendor, name: "vendor")
30
+ @created_at = created_at.getutc.freeze
31
+ @sha256 = Digest::SHA256.hexdigest(content).freeze
32
+ @template_sha256 = Values.text(template_sha256, name: "template digest", optional: true)
33
+ if scenario == "manual"
34
+ raise InputError, "manual scripts require an explicit target" unless target.is_a?(Target) && target.platform == vendor
35
+ @target = target
36
+ elsif target
37
+ raise InputError, "generated templates cannot override their platform contract"
38
+ end
39
+ unless sections.is_a?(Hash) && sections.values.all? { |section| section.is_a?(Artifact) && section.executable? }
40
+ raise InputError, "artifact sections must be single-target artifacts"
41
+ end
42
+ @sections = sections.to_h { |key, value| [Values.text(key, name: "section key"), value] }.freeze
43
+ freeze
44
+ end
45
+
46
+ def content = @content
47
+ def executable? = sections.empty?
48
+ def section(name) = sections.fetch(name.to_s)
49
+
50
+ def summary
51
+ { filename: filename, scenario: scenario, vendor: vendor, created_at: created_at.iso8601,
52
+ sha256: sha256, template_sha256: template_sha256, bytes: content.bytesize,
53
+ executable: executable?, sections: sections.keys }.freeze
54
+ end
55
+
56
+ def inspect = "#<#{self.class.name} filename=#{filename.inspect} sha256=#{sha256} bytes=#{content.bytesize}>"
57
+
58
+ def plan(target:, artifact_ref:, expected:, request_no: nil, target_id: nil)
59
+ raise InputError, "select a single-target artifact section before planning" unless executable?
60
+ raise InputError, "configuration verification expectations are required" unless expected.is_a?(Hash) && !expected.empty?
61
+ validate_target!(target)
62
+
63
+ parameters = { artifact_ref: Values.text(artifact_ref, name: "artifact reference"), sha256: sha256 }
64
+ parameters[:request_no] = Values.text(request_no, name: "request number") unless request_no.nil?
65
+ unless target_id.nil?
66
+ raise InputError, "target id must be text or an integer" unless target_id.is_a?(String) || target_id.is_a?(Integer)
67
+ parameters[:target_id] = Values.text(target_id.to_s, name: "target id")
68
+ end
69
+ Deployment::Plan.new(target: target, steps: [Deployment::Step.new(id: "configuration", action: "configuration",
70
+ parameters: parameters, expected: expected)], metadata: { scenario: scenario, vendor: vendor, template_sha256: template_sha256 })
71
+ end
72
+
73
+ def validate_target!(target)
74
+ if @target
75
+ return if target.is_a?(Target) && target == @target
76
+
77
+ raise InputError, "manual script belongs to a different target"
78
+ end
79
+ return if target.is_a?(Target) && PLATFORMS.fetch(vendor, []).include?(target.platform)
80
+ raise InputError, "artifact vendor and target platform do not match"
81
+
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Configuration
5
+ # Re-resolve and verify immediately before submission; do not cache mutable host records.
6
+ class ArtifactResolver
7
+ def initialize(resolver:)
8
+ raise InputError, "artifact resolver must be callable" unless resolver.respond_to?(:call)
9
+
10
+ @resolver = resolver
11
+ end
12
+
13
+ def resolve(step)
14
+ raise InputError, "unsupported configuration action" unless step.action == "configuration"
15
+
16
+ reference = step.parameters.fetch(:artifact_ref)
17
+ digest = step.parameters.fetch(:sha256)
18
+ raise InputError, "invalid artifact reference or digest" unless reference.is_a?(String) && !reference.empty? && digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
19
+
20
+ artifact = @resolver.call(reference)
21
+ unless artifact.is_a?(Artifact) && artifact.executable? && artifact.sha256 == digest
22
+ raise Error.new("configuration artifact is unavailable or changed", code: :artifact_changed, stage: :preflight)
23
+ end
24
+ artifact
25
+ rescue KeyError
26
+ raise InputError, "configuration plan requires artifact_ref and sha256"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Motor Netconf scenario definitions and safe template dialect.
4
+ require_relative "../support"
5
+
6
+ module Netstack
7
+ module Configuration
8
+ module Bootstrap
9
+ module Payload
10
+ H3C_STP_PRIORITIES = {
11
+ "core" => 4096, "aggregation" => 8192, "access" => 32_768
12
+ }.freeze
13
+
14
+ private
15
+
16
+ def bootstrap(data)
17
+ data["access_auth_enabled"] = false unless data.key?("access_auth_enabled")
18
+ stp_priority = H3C_STP_PRIORITIES[data.fetch("device_role")]
19
+ data["stp_priority"] = stp_priority if stp_priority
20
+ cidr = data.fetch("mgmt")
21
+ range = IPAddr.new(cidr).to_range
22
+ data.merge!(
23
+ "mgmt_ip" => cidr.split("/").first,
24
+ "mgmt_mask" => mask(cidr),
25
+ "mgmt_gateway" => data["mgmt_gateway"].presence || ipv4(range.last.to_i - 1),
26
+ "loopback_ip" => data["loopback"]&.split("/")&.first
27
+ )
28
+ if data.key?("underlay_enabled")
29
+ decorate_cisco_fabric!(data)
30
+ else
31
+ data["h3c_uplinks"] = data.fetch("uplink_interfaces").map.with_index do |interface, index|
32
+ { "interface" => interface, "description" => "UPLINK_#{index + 1}" }
33
+ end
34
+ end
35
+ decorate_bootstrap_system!(data.fetch("system"))
36
+ end
37
+
38
+ def decorate_cisco_fabric!(data)
39
+ overlay = data["overlay_enabled"]
40
+ role = data.fetch("device_role")
41
+ data["nve_enabled"] = overlay && %w[leaf border].include?(role)
42
+ data["route_reflector"] = overlay && role == "spine"
43
+ data["bgp_neighbors"] = Array(data["overlay_neighbors"]).map { |address| { "address" => address } }
44
+ data["external_bgp_neighbors"] ||= []
45
+ data["vtep_secondary_enabled"] = false
46
+ data["advertise_pip"] = false
47
+ return unless data["nve_enabled"]
48
+
49
+ data["vtep_secondary_enabled"] = data["vtep_secondary"].present?
50
+ data["advertise_pip"] = data["vpc_enabled"] && data["vtep_secondary_enabled"]
51
+ end
52
+
53
+ def decorate_bootstrap_system!(system)
54
+ system["tacacs_entries"] = entries(system.fetch("tacacs_servers"))
55
+ system["radius_entries"] = entries(Array(system["radius_servers"]))
56
+ system["ssh_acl_entries"] = system.fetch("ssh_acl_cidrs").map.with_index do |cidr, index|
57
+ { "sequence" => (index + 1) * 10, "wildcard" => wildcard(cidr) }
58
+ end
59
+ system["local_users"].each do |user|
60
+ user["h3c_role"] = user.fetch("role") == "admin" ? "network-admin" : "network-operator"
61
+ user["cisco_privilege"] = { "admin" => 15, "operator" => 5 }.fetch(user.fetch("role"), 1)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Motor Netconf scenario definitions and safe template dialect.
4
+ require_relative "../support"
5
+
6
+ module Netstack
7
+ module Configuration
8
+ module Bootstrap
9
+ module Validator
10
+ H3C_ROLES = %w[access aggregation core].freeze
11
+ CISCO_ROLES = %w[leaf spine border].freeze
12
+
13
+ private
14
+
15
+ def bootstrap_roles
16
+ @vendor == "cisco" ? CISCO_ROLES : H3C_ROLES
17
+ end
18
+
19
+ def validate_bootstrap_gateway!(result, value)
20
+ if value.nil? || value == ""
21
+ if result.fetch("mgmt").split("/").first == last_usable(result.fetch("mgmt"))
22
+ raise self.class::Error, "mgmt_gateway is required when management address is the last usable address"
23
+ end
24
+ return
25
+ end
26
+
27
+ gateway = ipv4!(value, "mgmt_gateway")
28
+ network = IPAddr.new(result.fetch("mgmt"))
29
+ range = network.to_range
30
+ unless network.include?(gateway) && ![ range.first.to_s, range.last.to_s ].include?(gateway)
31
+ raise self.class::Error, "mgmt_gateway must be a usable host in the management subnet"
32
+ end
33
+ if gateway == result.fetch("mgmt").split("/").first
34
+ raise self.class::Error, "mgmt_gateway must differ from the management address"
35
+ end
36
+ result["mgmt_gateway"] = gateway
37
+ end
38
+
39
+ def validate_h3c_bootstrap!(result, common, access_auth_enabled)
40
+ topology = take!(%w[access_interface_start access_interface_end uplink_interfaces])
41
+ uplinks = bootstrap_interface_list!(
42
+ default_if_omitted(topology["uplink_interfaces"], [ "Ten-GigabitEthernet1/0/49", "Ten-GigabitEthernet1/0/50" ]),
43
+ "uplink_interfaces", max: 8
44
+ )
45
+ result.merge!(
46
+ "access_interface_start" => interface!(default_if_omitted(topology["access_interface_start"], "GigabitEthernet1/0/1"), "access_interface_start"),
47
+ "access_interface_end" => interface!(default_if_omitted(topology["access_interface_end"], "GigabitEthernet1/0/48"), "access_interface_end"),
48
+ "uplink_interfaces" => uplinks
49
+ )
50
+ access_range = h3c_port_range!(result["access_interface_start"], result["access_interface_end"], "access interfaces")
51
+ reject_uplink_overlap!(access_range, uplinks)
52
+ if access_auth_enabled
53
+ result.merge!(
54
+ "access_auth_enabled" => true,
55
+ "access_auth_domain" => identifier!(common["access_auth_domain"], "access_auth_domain"),
56
+ "access_auth_vlan" => integer!(common["access_auth_vlan"], "access_auth_vlan", 1..4094),
57
+ "access_auth_interface_start" => interface!(common["access_auth_interface_start"], "access_auth_interface_start"),
58
+ "access_auth_interface_end" => interface!(common["access_auth_interface_end"], "access_auth_interface_end")
59
+ )
60
+ authentication_range = h3c_port_range!(result["access_auth_interface_start"], result["access_auth_interface_end"], "access authentication interfaces")
61
+ reject_uplink_overlap!(authentication_range, uplinks)
62
+ unless authentication_range.first == access_range.first && access_range.last.cover?(authentication_range.last)
63
+ raise self.class::Error, "access authentication range must be inside the access range"
64
+ end
65
+ end
66
+ finish!(result)
67
+ end
68
+
69
+ # H3C 的 to 范围必须属于同一类型、成员和槽位,并按端口号递增。
70
+ def h3c_port_range!(first, last, name)
71
+ endpoints = [first, last].map { |value| h3c_port!(value) }
72
+ unless endpoints.first.first == endpoints.last.first && endpoints.first.last <= endpoints.last.last
73
+ raise self.class::Error, "#{name} must be ordered ports on one interface path"
74
+ end
75
+ [endpoints.first.first, endpoints.first.last..endpoints.last.last]
76
+ end
77
+
78
+ def h3c_port!(value)
79
+ match = /\A([a-z][a-z-]*[0-9]+\/[0-9]+\/)([0-9]+)\z/i.match(value)
80
+ raise self.class::Error, "H3C bootstrap requires physical interface paths" unless match
81
+
82
+ [match[1].downcase, Integer(match[2], 10)]
83
+ end
84
+
85
+ # 比较范围内所有端口,避免只比较端点而把上联口纳入接入配置。
86
+ def reject_uplink_overlap!(range, uplinks)
87
+ return unless uplinks.any? { |value| h3c_port!(value).then { |path, port| path == range.first && range.last.cover?(port) } }
88
+
89
+ raise self.class::Error, "uplink interfaces must be outside access ranges"
90
+ end
91
+
92
+ def validate_cisco_bootstrap!(result)
93
+ fabric = take!(%w[
94
+ underlay_enabled loopback ospf_process_id ospf_area fabric_mtu fabric_links
95
+ overlay_enabled local_as remote_as overlay_neighbors vtep_loopback vtep_secondary
96
+ external_bgp_neighbors vpc_enabled vpc_domain_id vpc_role_priority
97
+ vpc_peer_keepalive_destination vpc_peer_keepalive_source
98
+ vpc_virtual_peer_link_destination vpc_virtual_peer_link_source
99
+ vpc_peer_link_port_channel
100
+ ])
101
+ underlay_enabled = fabric["underlay_enabled"].nil? || boolean!(fabric["underlay_enabled"], "underlay_enabled")
102
+ overlay_enabled = fabric["overlay_enabled"].nil? || boolean!(fabric["overlay_enabled"], "overlay_enabled")
103
+ vpc_enabled = fabric["vpc_enabled"].nil? ? false : boolean!(fabric["vpc_enabled"], "vpc_enabled")
104
+ raise self.class::Error, "overlay requires underlay" if overlay_enabled && !underlay_enabled
105
+ raise self.class::Error, "vPC requires overlay" if vpc_enabled && !overlay_enabled
106
+ raise self.class::Error, "spine role does not support vPC" if result["device_role"] == "spine" && vpc_enabled
107
+
108
+ result.merge!("underlay_enabled" => underlay_enabled, "overlay_enabled" => overlay_enabled, "vpc_enabled" => vpc_enabled)
109
+ validate_cisco_underlay!(result, fabric) if underlay_enabled
110
+ validate_cisco_overlay!(result, fabric) if overlay_enabled
111
+ validate_cisco_vpc!(result, fabric) if vpc_enabled
112
+ result["vpc_virtual_peer_link_enabled"] ||= false
113
+ finish!(result)
114
+ end
115
+
116
+ def validate_cisco_underlay!(result, fabric)
117
+ links = rows!(fabric["fabric_links"], "fabric_links")
118
+ raise self.class::Error, "fabric_links must contain 1 to 8 rows" if links.size > 8
119
+ links = links.map.with_index do |link, index|
120
+ row = exact!(link, %w[interface address description], "fabric_links[#{index}]")
121
+ {
122
+ "interface" => interface!(row["interface"], "fabric_links[#{index}].interface"),
123
+ "address" => cidr!(row["address"], "fabric_links[#{index}].address", host: true),
124
+ "description" => description!(row["description"], "fabric_links[#{index}].description")
125
+ }
126
+ end
127
+ interfaces = links.pluck("interface").map(&:downcase)
128
+ addresses = links.pluck("address").map { |value| value.split("/").first }
129
+ raise self.class::Error, "fabric link interfaces must be unique" unless interfaces.uniq.size == interfaces.size
130
+ raise self.class::Error, "fabric link addresses must be unique" unless addresses.uniq.size == addresses.size
131
+
132
+ loopback = cidr!(fabric["loopback"], "loopback", host: true)
133
+ raise self.class::Error, "underlay interface addresses must be unique" if addresses.include?(loopback.split("/").first)
134
+ result.merge!(
135
+ "loopback" => loopback,
136
+ "ospf_process_id" => identifier!(default_if_omitted(fabric["ospf_process_id"], "10"), "ospf_process_id"),
137
+ "ospf_area" => ospf_area!(default_if_omitted(fabric["ospf_area"], "0.0.0.0"), "ospf_area"),
138
+ "fabric_mtu" => integer!(default_if_omitted(fabric["fabric_mtu"], 9216), "fabric_mtu", 1500..9216),
139
+ "fabric_links" => links
140
+ )
141
+ end
142
+
143
+ def validate_cisco_overlay!(result, fabric)
144
+ neighbors = bootstrap_ipv4_list!(fabric["overlay_neighbors"], "overlay_neighbors", max: self.class::MAX_ROWS)
145
+ result.merge!(
146
+ "local_as" => asn!(fabric["local_as"], "local_as"),
147
+ "remote_as" => asn!(fabric["remote_as"], "remote_as"),
148
+ "overlay_neighbors" => neighbors
149
+ )
150
+ if result["device_role"] == "spine"
151
+ result["external_bgp_neighbors"] = []
152
+ return
153
+ end
154
+
155
+ result.merge!(
156
+ "vtep_loopback" => cidr!(fabric["vtep_loopback"], "vtep_loopback", host: true),
157
+ "vtep_secondary" => (cidr!(fabric["vtep_secondary"], "vtep_secondary", host: true) if provided?(fabric["vtep_secondary"]))
158
+ )
159
+ loopbacks = result.values_at("loopback", "vtep_loopback", "vtep_secondary").compact.map { |value| value.split("/").first }
160
+ raise self.class::Error, "loopback addresses must be unique" unless loopbacks.uniq.size == loopbacks.size
161
+ result["external_bgp_neighbors"] = result["device_role"] == "border" ? validate_external_bgp_neighbors!(fabric["external_bgp_neighbors"]) : []
162
+ end
163
+
164
+ def validate_external_bgp_neighbors!(value)
165
+ return [] if !provided?(value) || value == []
166
+ rows!(value, "external_bgp_neighbors").map.with_index do |neighbor, index|
167
+ row = exact!(neighbor, %w[address remote_as description], "external_bgp_neighbors[#{index}]")
168
+ {
169
+ "address" => ipv4!(row["address"], "external_bgp_neighbors[#{index}].address"),
170
+ "remote_as" => asn!(row["remote_as"], "external_bgp_neighbors[#{index}].remote_as"),
171
+ "description" => optional_description(row["description"], "external_bgp_neighbors[#{index}].description")
172
+ }.compact
173
+ end.tap do |neighbors|
174
+ addresses = neighbors.pluck("address")
175
+ raise self.class::Error, "external BGP neighbors must be unique" unless addresses.uniq.size == addresses.size
176
+ end
177
+ end
178
+
179
+ def validate_cisco_vpc!(result, fabric)
180
+ result.merge!(
181
+ "vpc_domain_id" => integer!(default_if_omitted(fabric["vpc_domain_id"], 1), "vpc_domain_id", 1..1000),
182
+ "vpc_role_priority" => integer!(default_if_omitted(fabric["vpc_role_priority"], 100), "vpc_role_priority", 1..65_535),
183
+ "vpc_peer_keepalive_destination" => ipv4!(fabric["vpc_peer_keepalive_destination"], "vpc_peer_keepalive_destination"),
184
+ "vpc_peer_keepalive_source" => ipv4!(fabric["vpc_peer_keepalive_source"], "vpc_peer_keepalive_source"),
185
+ "vpc_peer_link_port_channel" => integer!(default_if_omitted(fabric["vpc_peer_link_port_channel"], 998), "vpc_peer_link_port_channel", 1..4096)
186
+ )
187
+ if result["vpc_peer_keepalive_destination"] == result["vpc_peer_keepalive_source"]
188
+ raise self.class::Error, "vPC keepalive source and destination must be different"
189
+ end
190
+ virtual_destination = fabric["vpc_virtual_peer_link_destination"]
191
+ virtual_source = fabric["vpc_virtual_peer_link_source"]
192
+ if provided?(virtual_destination) != provided?(virtual_source)
193
+ raise self.class::Error, "vPC virtual peer-link source and destination must be provided together"
194
+ end
195
+ return unless provided?(virtual_destination)
196
+
197
+ result.merge!(
198
+ "vpc_virtual_peer_link_enabled" => true,
199
+ "vpc_virtual_peer_link_destination" => ipv4!(virtual_destination, "vpc_virtual_peer_link_destination"),
200
+ "vpc_virtual_peer_link_source" => ipv4!(virtual_source, "vpc_virtual_peer_link_source")
201
+ )
202
+ end
203
+
204
+ def bootstrap_interface_list!(value, path, max:)
205
+ unless value.is_a?(Array) && value.size.between?(1, max)
206
+ raise self.class::Error, "#{path} must contain 1 to #{max} values"
207
+ end
208
+ interfaces = value.map.with_index { |item, index| interface!(item, "#{path}[#{index}]") }
209
+ raise self.class::Error, "#{path} must be unique" unless interfaces.map(&:downcase).uniq.size == interfaces.size
210
+ interfaces
211
+ end
212
+
213
+ def bootstrap_ipv4_list!(value, path, max:)
214
+ unless value.is_a?(Array) && value.size.between?(1, max)
215
+ raise self.class::Error, "#{path} must contain 1 to #{max} values"
216
+ end
217
+ addresses = value.map.with_index { |item, index| ipv4!(item, "#{path}[#{index}]") }
218
+ raise self.class::Error, "#{path} must be unique" unless addresses.uniq.size == addresses.size
219
+ addresses
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Motor Netconf scenario definitions and safe template dialect.
4
+ require_relative "support"
5
+
6
+ module Netstack
7
+ module Configuration
8
+ class Catalog
9
+ SCENARIOS = Values.copy([
10
+ { key: "bootstrap", group: "device", default_vendor: "h3c", vendors: %w[h3c cisco], templates: { "h3c" => "h3c-bootstrap.erb", "cisco" => "cisco-bootstrap.erb" } },
11
+ { key: "h3c-irf", group: "device", default_vendor: "h3c", vendors: %w[h3c], templates: { "h3c" => "h3c-irf.erb" } },
12
+ { key: "distributed-gateway", group: "network", default_vendor: "cisco", vendors: %w[cisco], templates: { "cisco" => "cisco-distributed-gateway.erb" } },
13
+ { key: "cisco-n9k-vpc", group: "network", default_vendor: "cisco", vendors: %w[cisco], templates: { "cisco" => "cisco-n9k-vpc.erb" } },
14
+ { key: "server-port", group: "network", default_vendor: "h3c", vendors: %w[h3c cisco], templates: { "h3c" => "h3c-server-port.erb", "cisco" => "cisco-server-port.erb" } },
15
+ { key: "hillstone-policy", group: "network", default_vendor: "hillstone", vendors: %w[hillstone], templates: { "hillstone" => "hillstone-policy.erb" } },
16
+ { key: "load-balancer", group: "application", default_vendor: "radware", vendors: %w[radware], templates: { "radware" => "radware-load-balancer.erb" } },
17
+ { key: "ipsec-vpn", group: "security", default_vendor: "paloalto", vendors: %w[paloalto hillstone], templates: { "paloalto" => "paloalto-ipsec-vpn.erb", "hillstone" => "hillstone-ipsec-vpn.erb" } }
18
+ ])
19
+ VARIABLES = Values.copy({
20
+ "bootstrap" => %w[
21
+ hostname device_role stp_priority mgmt mgmt_ip mgmt_mask mgmt_gateway snmp_location system
22
+ access_interface_start access_interface_end uplink_interfaces h3c_uplinks
23
+ access_auth_enabled access_auth_domain access_auth_vlan access_auth_interface_start access_auth_interface_end
24
+ underlay_enabled loopback loopback_ip ospf_process_id ospf_area fabric_mtu fabric_links
25
+ overlay_enabled local_as remote_as overlay_neighbors bgp_neighbors vtep_loopback vtep_secondary
26
+ nve_enabled route_reflector external_bgp_neighbors vtep_secondary_enabled advertise_pip
27
+ vpc_enabled vpc_domain_id vpc_role_priority
28
+ vpc_peer_keepalive_destination vpc_peer_keepalive_source vpc_virtual_peer_link_enabled
29
+ vpc_virtual_peer_link_destination vpc_virtual_peer_link_source vpc_peer_link_port_channel
30
+ ],
31
+ "h3c-irf" => %w[port1 port2 port3 port4 mad_port1 mad_port2 mad_vlan mad_subnet mad_mask mad_member1_ip mad_member2_ip],
32
+ "distributed-gateway" => %w[gateways],
33
+ "cisco-n9k-vpc" => %w[vpc_domain_id primary_role_priority secondary_role_priority primary_keepalive_ip secondary_keepalive_ip primary_virtual_peer_link_ip secondary_virtual_peer_link_ip vpc_peer_link_port_channel infra_vlan fabric_interfaces],
34
+ "server-port" => %w[stack_mode allocations aggregates],
35
+ "hillstone-policy" => %w[policies address_objects service_objects service_groups],
36
+ "load-balancer" => %w[next_hop route_metric services real_servers groups virtuals],
37
+ "ipsec-vpn" => %w[site_name tunnel local_interface local_address local_ip remote_ip add_route virtual_router route_metric dpd_interval dpd_retry hillstone_sa_index tunnel_zone preshared_key local_subnets remote_subnets ike_version ikev1 ikev2 ike_encryption ike_authentication ike_dh_group ike_lifetime ipsec_encryption ipsec_authentication ipsec_lifetime hillstone_ike_encryption hillstone_ipsec_encryption hillstone_ikev2_encryption hillstone_ipsec_v2_encryption hillstone_ike_hash hillstone_ipsec_hash hillstone_ike_group proxy_ids routes]
38
+ })
39
+
40
+ class << self
41
+ def all = SCENARIOS.deep_dup
42
+
43
+ def fetch(key)
44
+ SCENARIOS.find { |scenario| scenario.fetch(:key) == key.to_s } || raise(KeyError, "unknown scenario")
45
+ end
46
+
47
+ def template_key(scenario, vendor)
48
+ fetch(scenario).fetch(:templates).fetch(vendor.to_s) { raise KeyError, "unsupported vendor" }
49
+ end
50
+
51
+ def template_keys = SCENARIOS.flat_map { |scenario| scenario.fetch(:templates).values }.uniq
52
+
53
+ def variables_for_template(key)
54
+ scenario = SCENARIOS.find { |item| item.fetch(:templates).value?(key) } || raise(KeyError, "unknown template")
55
+ VARIABLES.fetch(scenario.fetch(:key))
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ module Netstack
6
+ # 经过校验的场景输入、受限模板和敏感私有工件。
7
+ module Configuration
8
+ # 在本地生成工件;不向设备下发配置。
9
+ # 初始化批次必须全部校验完成,才渲染任何输出工件。
10
+ class Generator
11
+ def self.call(**options) = new.call(**options)
12
+
13
+ def initialize(store: TemplateStore.new, clock: -> { Time.now.utc })
14
+ @store = store
15
+ @clock = clock
16
+ end
17
+
18
+ # 批量初始化返回可独立引用的工件数组。
19
+ # 归档、邮件、加密和保留策略由宿主负责。
20
+ def call(scenario:, vendor:, input:, system_config: {}, settings: {})
21
+ values = Support.object(input)
22
+ now = @clock.call
23
+ if scenario.to_s == "bootstrap" && values.key?("devices")
24
+ return bootstrap_batch(vendor: vendor, input: values, system_config: system_config, settings: settings, now: now)
25
+ end
26
+ normalized = Validator.call(scenario: scenario, vendor: vendor, input: values, system_config: system_config, settings: settings)
27
+ render(scenario.to_s, vendor.to_s, normalized, now)
28
+ end
29
+
30
+ private
31
+
32
+ def bootstrap_batch(vendor:, input:, system_config:, settings:, now:)
33
+ raise InputError, "unexpected bootstrap batch fields" unless (input.keys - %w[devices system]).empty?
34
+ devices = input.fetch("devices")
35
+ unless devices.is_a?(Array) && devices.size.between?(1, Validator::MAX_ROWS)
36
+ raise InputError, "devices must contain 1 to #{Validator::MAX_ROWS} rows"
37
+ end
38
+ normalized = devices.map do |device|
39
+ row = Support.object(device, name: "bootstrap device")
40
+ raise InputError, "batch system overrides belong at the batch level" if row.key?("system")
41
+ row["system"] = input["system"] if input.key?("system")
42
+ Validator.call(scenario: "bootstrap", vendor: vendor, input: row, system_config: system_config, settings: settings)
43
+ end
44
+ hostnames = normalized.map { |row| row.fetch("hostname").downcase }
45
+ addresses = normalized.map { |row| row.fetch("mgmt").split("/").first }
46
+ raise InputError, "device hostnames and management addresses must be unique" unless hostnames.uniq.size == hostnames.size && addresses.uniq.size == addresses.size
47
+
48
+ normalized.map { |row| render("bootstrap", vendor.to_s, row, now) }.freeze
49
+ end
50
+
51
+ def render(scenario, vendor, normalized, now)
52
+ key = Catalog.template_key(scenario, vendor)
53
+ system = normalized.delete("system") || {}
54
+ data = Payload.call(scenario: scenario, input: normalized, system_config: system, now: now)
55
+ content = TemplateManager.new(key, data, store: @store).generate
56
+ prefix = normalized["hostname"] || normalized["site_name"] || "#{vendor}-#{scenario}"
57
+ filename = "#{prefix}-#{now.strftime("%Y%m%d%H%M%S")}.txt"
58
+ options = { scenario: scenario, vendor: vendor, created_at: now, template_sha256: @store.metadata(key).fetch(:sha256) }
59
+ sections = split_sections(scenario, content).to_h do |name, text|
60
+ [name, Artifact.new(filename: "#{prefix}-#{name}.txt", content: text, **options)]
61
+ end
62
+ Artifact.new(filename: filename, content: content, sections: sections, **options)
63
+ end
64
+
65
+ def split_sections(scenario, content)
66
+ markers = case scenario
67
+ when "cisco-n9k-vpc" then { "primary" => "! Primary switch", "secondary" => "! Secondary switch" }
68
+ when "h3c-irf" then { "master" => "# Stage 1 - master", "standby-renumber" => "# Stage 2 - standby renumber",
69
+ "standby-links" => "# Stage 3 - standby links", "mad" => "# Stage 4 - BFD MAD" }
70
+ else return {}
71
+ end
72
+ positions = markers.map do |name, marker|
73
+ matches = content.enum_for(:scan, /^#{Regexp.escape(marker)}$/).map { Regexp.last_match.begin(0) }
74
+ raise InputError, "multi-target template must preserve unique section markers" unless matches.one?
75
+
76
+ [name, matches.first]
77
+ end
78
+ raise InputError, "configuration sections are out of order" unless positions.map(&:last) == positions.map(&:last).sort
79
+
80
+ positions.each_with_index.to_h do |(name, start), index|
81
+ finish = positions[index + 1]&.last || content.length
82
+ [name, content[start...finish]]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Motor Netconf scenario definitions and safe template dialect.
4
+ require_relative "support"
5
+
6
+ module Netstack
7
+ module Configuration
8
+ # Defines the HTTP input shape separately from template output variables.
9
+ class InputContract
10
+ EMPTY_FIELDS = [].freeze
11
+
12
+ SYSTEM_FIELDS = Values.copy([
13
+ :domain_name, :timezone, :management_vlan, :snmp_community,
14
+ :tacacs_secret, :radius_secret, :base_security_enabled,
15
+ :disable_telnet, :disable_http, :enable_bpdu_protection,
16
+ :password_min_length, :vty_timeout_minutes,
17
+ { ntp_servers: [], dns_servers: [], syslog_servers: [],
18
+ tacacs_servers: [], radius_servers: [], ssh_acl_cidrs: [],
19
+ local_users: %i[username password role] }
20
+ ])
21
+
22
+ BOOTSTRAP_DEVICE_FIELDS = Values.copy([
23
+ :hostname, :device_role, :mgmt, :mgmt_gateway, :snmp_location,
24
+ :access_auth_enabled, :access_auth_domain, :access_auth_vlan,
25
+ :access_auth_interface_start, :access_auth_interface_end,
26
+ :access_interface_start, :access_interface_end,
27
+ :underlay_enabled, :loopback, :ospf_process_id, :ospf_area,
28
+ :fabric_mtu, :overlay_enabled, :local_as, :remote_as,
29
+ :vtep_loopback, :vtep_secondary, :vpc_enabled, :vpc_domain_id,
30
+ :vpc_role_priority, :vpc_peer_keepalive_destination,
31
+ :vpc_peer_keepalive_source, :vpc_virtual_peer_link_destination,
32
+ :vpc_virtual_peer_link_source, :vpc_peer_link_port_channel,
33
+ { uplink_interfaces: [], overlay_neighbors: [],
34
+ fabric_links: %i[interface address description],
35
+ external_bgp_neighbors: %i[address remote_as description] }
36
+ ])
37
+
38
+ SCHEMAS = Values.copy({
39
+ "bootstrap" => [
40
+ *BOOTSTRAP_DEVICE_FIELDS,
41
+ { system: SYSTEM_FIELDS, devices: BOOTSTRAP_DEVICE_FIELDS }
42
+ ],
43
+ "h3c-irf" => %i[port1 port2 port3 port4 mad_port1 mad_port2 mad_vlan mad_subnet],
44
+ "distributed-gateway" => [ { gateways: %i[vlan subnet gateway vni name mtu vrf acl] } ],
45
+ "cisco-n9k-vpc" => [
46
+ :vpc_domain_id, :primary_role_priority, :secondary_role_priority,
47
+ :primary_keepalive_ip, :secondary_keepalive_ip,
48
+ :primary_virtual_peer_link_ip, :secondary_virtual_peer_link_ip,
49
+ :vpc_peer_link_port_channel, :infra_vlan, { fabric_interfaces: [] }
50
+ ],
51
+ "server-port" => [
52
+ :stack_mode,
53
+ { allocations: %i[local_iface local_description remote remote_iface vlan port_channel aggregate_id flag] }
54
+ ],
55
+ "hillstone-policy" => [
56
+ { policies: [
57
+ :description, :src_zone, :dst_zone, :protocol, :action, :log,
58
+ { src_addresses: [], dst_addresses: [], services: [] }
59
+ ] }
60
+ ],
61
+ "load-balancer" => [
62
+ { services: [
63
+ :flag, :virtual_ip, :service_port, :proxy_ip, :app_port,
64
+ :timeout, :group_name, :service_type, :health_check,
65
+ :client_ip_persistence, { real_ips: [] }
66
+ ] }
67
+ ],
68
+ "ipsec-vpn" => [
69
+ :site_name, :tunnel, :local_interface, :local_address, :remote_ip,
70
+ :add_route, :preshared_key, :ike_version, :ike_encryption,
71
+ :ike_authentication, :ike_dh_group, :ike_lifetime,
72
+ :ipsec_encryption, :ipsec_authentication, :ipsec_lifetime,
73
+ :virtual_router, :route_metric, :dpd_interval, :dpd_retry, :hillstone_sa_index, :tunnel_zone,
74
+ { local_subnets: [], remote_subnets: [] }
75
+ ]
76
+ })
77
+
78
+ # Unknown scenarios receive no input fields and are rejected by the catalog.
79
+ def self.fields_for(scenario) = SCHEMAS.fetch(scenario.to_s, EMPTY_FIELDS)
80
+ end
81
+ end
82
+ end