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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Netstack
7
+ module Integrations
8
+ module HttpDelivery
9
+ # Motor-compatible POST fields, plus an explicit key-based recovery endpoint.
10
+ class Client
11
+ def initialize(http:, lookup_path: "", lookup_parameter: "idempotency_key")
12
+ @http = http
13
+ @lookup_path = lookup_path == "" ? "" : Values.text(lookup_path, name: "lookup path", optional: true) || ""
14
+ @lookup_parameter = Values.text(lookup_parameter, name: "lookup parameter")
15
+ end
16
+
17
+ def inspect = "#<#{self.class.name}>"
18
+
19
+ def submit(target:, artifact:, credentials:, idempotency_key:, request_no: nil, target_id: nil)
20
+ key = validate_key!(idempotency_key)
21
+ request_no = Values.text(request_no, name: "request number") unless request_no.nil?
22
+ validate_identifier!(target_id, name: "target id") unless target_id.nil?
23
+ unless artifact.is_a?(Configuration::Artifact) && artifact.executable?
24
+ raise InputError, "delivery requires a single-target artifact"
25
+ end
26
+ artifact.validate_target!(target)
27
+ auth = validate_credentials!(credentials)
28
+ device = device_snapshot(target)
29
+ response = @http.request(:post, "", headers: { "Accept" => "application/json", "Idempotency-Key" => key }, json: {
30
+ request_no: request_no, target_id: target_id || target.key, idempotency_key: key,
31
+ device: device, credentials: auth, script: artifact.content
32
+ })
33
+ data = response.json
34
+ if data.is_a?(Hash) && data.key?("idempotency_key") && data["idempotency_key"] != key
35
+ raise Error.new("delivery submission returned a different operation", code: :delivery_identity_mismatch, stage: :submit)
36
+ end
37
+ normalize(data)
38
+ end
39
+
40
+ def status(remote_id: nil, idempotency_key:)
41
+ key = validate_key!(idempotency_key)
42
+ remote_id = validate_identifier!(remote_id, name: "remote id") unless remote_id.nil?
43
+ response = if remote_id
44
+ id = Values.text(remote_id.to_s, name: "remote id")
45
+ raise InputError, "delivery task identifier cannot be a path dot segment" if %w[. ..].include?(id)
46
+ @http.request(:get, URI.encode_uri_component(id), headers: { "Accept" => "application/json" })
47
+ else
48
+ @http.request(:get, @lookup_path, query: { @lookup_parameter => key }, headers: { "Accept" => "application/json" })
49
+ end
50
+ data = response.json
51
+ if data.is_a?(Hash) && ((!remote_id && data["idempotency_key"] != key) || (data.key?("idempotency_key") && data["idempotency_key"] != key))
52
+ raise Error.new("delivery lookup returned a different operation", code: :delivery_identity_mismatch, stage: :verify)
53
+ end
54
+ result = normalize(data, fallback_id: remote_id)
55
+ if remote_id && result.remote_id != remote_id.to_s
56
+ raise Error.new("delivery lookup returned a different remote task", code: :delivery_identity_mismatch, stage: :verify)
57
+ end
58
+ result
59
+ rescue Http::Error => error
60
+ # A missing index record cannot establish non-execution after an uncertain POST.
61
+ return Result.new(status: :not_found) if error.code.to_s == "http_status" && error.details["status"] == 404
62
+
63
+ raise
64
+ end
65
+
66
+ def validate_credentials!(credentials)
67
+ auth = Configuration::Support.object(credentials, name: "delivery credentials")
68
+ unless auth.keys.sort == %w[password username] && auth.values.all? { |value| value.is_a?(String) && !value.empty? }
69
+ raise InputError, "delivery credentials require username and password"
70
+ end
71
+ auth
72
+ end
73
+
74
+ private
75
+
76
+ def normalize(data, fallback_id: nil)
77
+ raise Error.new("invalid delivery response", code: :invalid_delivery_response, stage: :response) unless data.is_a?(Hash)
78
+
79
+ status = case data["status"]&.to_s&.downcase
80
+ when "success", "succeeded", "completed" then :succeeded
81
+ when "failed", "error", "rejected" then :failed
82
+ when "accepted", "pending", "queued", "running", "processing" then :running
83
+ when nil, ""
84
+ if data["success"] == true then :succeeded
85
+ elsif data["success"] == false then :failed
86
+ else :unknown
87
+ end
88
+ else :unknown
89
+ end
90
+ task_id = validate_identifier!(data["task_id"], name: "task id") unless data["task_id"].nil?
91
+ identifier = validate_identifier!(data["id"], name: "task id") unless data["id"].nil?
92
+ if task_id && identifier && task_id != identifier
93
+ raise Error.new("conflicting delivery task identifiers", code: :delivery_identity_mismatch, stage: :response)
94
+ end
95
+ remote_id = task_id || identifier || fallback_id
96
+ if %w[. ..].include?(remote_id)
97
+ raise Error.new("invalid delivery task identifier", code: :invalid_delivery_response, stage: :response)
98
+ end
99
+ output = data["output"] || data["result"]
100
+ output = JSON.generate(output) unless output.nil? || output.is_a?(String)
101
+ Result.new(status: status, remote_id: remote_id, output: output)
102
+ end
103
+
104
+ def validate_identifier!(value, name:)
105
+ unless value.is_a?(String) || value.is_a?(Integer)
106
+ raise InputError, "#{name} must be text or an integer"
107
+ end
108
+ Values.text(value.to_s, name: name)
109
+ end
110
+
111
+ def validate_key!(key)
112
+ unless key.is_a?(String) && key.match?(/\A[a-zA-Z0-9_.:-]{1,256}\z/)
113
+ raise InputError, "invalid delivery idempotency key"
114
+ end
115
+ key
116
+ end
117
+
118
+ def device_snapshot(target)
119
+ allowed = %w[id serial name hostname vendor model os device_role source]
120
+ supplied = Configuration::Support.object(target.attributes, name: "target attributes")
121
+ supplied.slice(*allowed).merge("id" => supplied.fetch("id", target.key), "name" => supplied.fetch("name", target.key),
122
+ "ip" => target.host, "vendor" => supplied.fetch("vendor", target.platform))
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Integrations
5
+ module HttpDelivery
6
+ class Result
7
+ STATUSES = %i[succeeded failed running unknown not_found].freeze
8
+ attr_reader :status, :remote_id
9
+
10
+ def initialize(status:, remote_id: nil, output: nil)
11
+ raise InputError, "invalid delivery result status" unless STATUSES.include?(status)
12
+ raise InputError, "delivery output must be text" unless output.nil? || output.is_a?(String)
13
+
14
+ @status = status
15
+ @remote_id = remote_id.nil? ? nil : Values.text(remote_id.to_s, name: "remote id")
16
+ @output = output&.dup&.freeze
17
+ freeze
18
+ end
19
+
20
+ def output = @output
21
+ def succeeded? = status == :succeeded
22
+ def failed? = status == :failed
23
+ def inspect = "#<#{self.class.name} status=#{status}>"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "time"
5
+
6
+ module Netstack
7
+ module Integrations
8
+ module Oxidized
9
+ # Oxidized Web's queue endpoint is a side-effectful GET. HTTP never retries it.
10
+ class Client
11
+ def initialize(base_url: nil, http: nil, **http_options)
12
+ @http = http || Http::Client.new(base_url: base_url, **http_options)
13
+ end
14
+
15
+ def inspect = "#<#{self.class.name}>"
16
+
17
+ def nodes
18
+ rows = @http.request(:get, "/nodes.json").json
19
+ invalid! unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) && row["name"].is_a?(String) && !row["name"].empty? }
20
+ rows = rows.map do |row|
21
+ next row unless row["last"]
22
+ invalid! unless row["last"].is_a?(Hash)
23
+ last = row["last"].dup
24
+ %w[start end].each { |key| last[key] = timestamp(last[key]) if last[key] }
25
+ row.merge("last" => last)
26
+ end
27
+ Values.copy(rows)
28
+ end
29
+
30
+ def configuration(node_name:, group: nil)
31
+ @http.request(:get, node_path("/node/fetch", node_name, group), headers: { "Accept" => "text/plain" }).body
32
+ end
33
+
34
+ def request_backup(node_name:, group: nil) # rubocop:disable Naming/PredicateMethod -- command, not a predicate
35
+ result = @http.request(:get, "#{node_path("/node/next", node_name, group)}.json").json
36
+ invalid! unless result == ["ok"] || result == "ok"
37
+ true
38
+ end
39
+
40
+ def versions(node_name:, group: nil)
41
+ node_name = segment(node_name)
42
+ group = segment(group) unless group.nil?
43
+ rows = @http.request(:get, "/node/version.json", query: { node_full: [group, node_name].compact.join("/") }).json
44
+ invalid! unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) && row["oid"].is_a?(String) && !row["oid"].empty? }
45
+ invalid! unless rows.map { |row| row["oid"] }.uniq.length == rows.length
46
+ Values.copy(rows)
47
+ end
48
+
49
+ def diff(node_name:, group: nil, from_oid:, to_oid:)
50
+ rows = @http.request(:get, "/node/version/diffs", query: {
51
+ format: "json", node: segment(node_name), group: group.nil? ? "" : segment(group),
52
+ oid: Values.text(from_oid, name: "from_oid"), oid2: Values.text(to_oid, name: "to_oid"), epoch: 0, num: 0
53
+ }).json
54
+ invalid! unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(String) }
55
+ Values.copy(rows)
56
+ end
57
+
58
+ private
59
+
60
+ # Upstream JSON.generate serializes Time objects via Time#to_s (UTC).
61
+ def timestamp(value)
62
+ invalid! unless value.is_a?(String)
63
+ time = if value.match?(/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? UTC\z/)
64
+ Time.iso8601(value.sub(" ", "T").sub(" UTC", "Z"))
65
+ else
66
+ Time.iso8601(value)
67
+ end
68
+ time.iso8601(6)
69
+ rescue ArgumentError
70
+ invalid!
71
+ end
72
+
73
+ def node_path(prefix, name, group)
74
+ [prefix, *[group, name].compact.map { |value| URI.encode_www_form_component(segment(value)).gsub("+", "%20") }].join("/")
75
+ end
76
+
77
+ def segment(value)
78
+ value = Values.text(value, name: "Oxidized node or group")
79
+ raise InputError, "Invalid Oxidized node or group" if value.match?(/[\x00-\x1f\x7f]/) || %w[. ..].include?(value)
80
+ value
81
+ end
82
+
83
+ def invalid!
84
+ raise Netstack::Error.new("Invalid Oxidized response", code: :invalid_response, stage: :response)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Integrations
5
+ module Zabbix
6
+ # JSON-RPC 传输与资源映射;监控资源归属策略由上层决定。
7
+ # 响应 ID 必须匹配请求;凭据和 RPC 错误正文保持私有。
8
+ class Client
9
+ RESOURCES = {
10
+ "host" => %w[hostid hostids], "hostgroup" => %w[groupid groupids],
11
+ "template" => %w[templateid templateids], "proxygroup" => %w[proxy_groupid proxy_groupids],
12
+ "hostinterface" => %w[interfaceid interfaceids], "usermacro" => %w[hostmacroid hostmacroids],
13
+ "item" => %w[itemid itemids], "trigger" => %w[triggerid triggerids]
14
+ }.freeze
15
+
16
+ def initialize(token:, api_url: nil, http: nil)
17
+ unless token.is_a?(String) && !token.strip.empty? && !token.match?(/[\r\n\x00]/)
18
+ raise Netstack::InputError.new("Zabbix token is required", code: "invalid_zabbix_configuration")
19
+ end
20
+
21
+ @token = token.dup.freeze
22
+ @http = http || Http::Client.new(base_url: api_url)
23
+ @sequence = 0
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def inspect = "#<#{self.class.name}>"
28
+
29
+ def call(method, params = {}, authenticate: true)
30
+ unless method.is_a?(String) && method.match?(/\A[a-z]+\.[a-z]+\z/i) && (params.is_a?(Hash) || params.is_a?(Array))
31
+ raise Netstack::InputError.new("Invalid JSON-RPC request", code: "invalid_rpc_input")
32
+ end
33
+
34
+ id = @mutex.synchronize { @sequence += 1 }
35
+ headers = { "Accept" => "application/json", "Content-Type" => "application/json-rpc" }
36
+ headers["Authorization"] = "Bearer #{@token}" if authenticate
37
+ payload = @http.request(:post, "", headers: headers,
38
+ json: { jsonrpc: "2.0", method: method, params: params, id: id }).json
39
+ unless payload.is_a?(Hash) && payload["jsonrpc"] == "2.0" && payload["id"].is_a?(Integer) &&
40
+ payload["id"] == id && (payload.key?("result") ^ payload.key?("error"))
41
+ invalid_response!(method)
42
+ end
43
+ if payload.key?("error")
44
+ error = payload["error"]
45
+ invalid_response!(method) unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
46
+ raise Error.new("Zabbix rejected #{method}", code: "zabbix_rejected", stage: "response",
47
+ details: { "method" => method, "provider_code" => error["code"] }), cause: nil
48
+ end
49
+ payload.fetch("result")
50
+ end
51
+
52
+ def api_version
53
+ version = call("apiinfo.version", {}, authenticate: false)
54
+ invalid_response!("apiinfo.version") unless version.is_a?(String) && version.match?(/\A\d+\.\d+(?:\.\d+)?\z/)
55
+ version
56
+ end
57
+
58
+ def query(resource, params = {})
59
+ singular, = resource_keys(resource)
60
+ method = "#{resource}.get"
61
+ rows = call(method, default_query(resource).merge(params))
62
+ rows = validate_rows!(rows, method: method, id_key: singular)
63
+ rows = rows.map { |row| normalize_host!(row, method) } if resource.to_s == "host"
64
+ rows.each { |row| validate_interface!(row, method) } if resource.to_s == "hostinterface"
65
+ if resource.to_s == "trigger"
66
+ rows.each do |row|
67
+ validate_rows!(row["dependencies"], method: method, id_key: "triggerid")
68
+ invalid_response!(method) unless valid_tags?(row["tags"])
69
+ end
70
+ end
71
+ rows
72
+ end
73
+
74
+ def get(resource, id)
75
+ _, plural = resource_keys(resource)
76
+ rows = query(resource, plural => [identifier!(id)])
77
+ invalid_response!("#{resource}.get") unless rows.size <= 1
78
+ row = rows.first
79
+ invalid_response!("#{resource}.get") if row && row.fetch(resource_keys(resource).first).to_s != id.to_s
80
+ row
81
+ end
82
+
83
+ def find(resource, params)
84
+ rows = query(resource, params)
85
+ invalid_response!("#{resource}.get") unless rows.size <= 1
86
+ rows.first
87
+ end
88
+
89
+ def create(resource, attributes)
90
+ mutate(resource, "create", attributes)
91
+ end
92
+
93
+ def update(resource, id, attributes)
94
+ singular, = resource_keys(resource)
95
+ identifier!(id)
96
+ mutate(resource, "update", attributes.merge(singular => id.to_s), expected_id: id.to_s)
97
+ end
98
+
99
+ def history(item_id:, value_type:, from:, to:, limit: 10_000)
100
+ range!(from, to, limit)
101
+ raise Netstack::InputError.new("Only numeric Zabbix history is supported", code: "unsupported_history_type") unless [0, 3].include?(value_type)
102
+
103
+ rows = call("history.get", { history: value_type, itemids: [identifier!(item_id)],
104
+ time_from: from, time_till: to, limit: limit, output: "extend", sortfield: %w[clock ns], sortorder: "ASC" })
105
+ validate_rows!(rows, method: "history.get", id_key: "itemid", unique: false)
106
+ end
107
+
108
+ def trends(item_id:, from:, to:, limit: 10_000)
109
+ range!(from, to, limit)
110
+ rows = call("trend.get", { itemids: [identifier!(item_id)], time_from: from, time_till: to,
111
+ limit: limit, output: %w[itemid clock num value_min value_avg value_max], sortfield: "clock", sortorder: "ASC" })
112
+ validate_rows!(rows, method: "trend.get", id_key: "itemid", unique: false)
113
+ end
114
+
115
+ def problems(host_id:, from:, limit: 100)
116
+ range!(from, from, limit)
117
+ rows = call("problem.get", { hostids: [identifier!(host_id)], time_from: from, recent: true,
118
+ limit: limit, output: %w[eventid objectid name severity acknowledged clock r_clock], sortfield: "eventid", sortorder: "DESC" })
119
+ validate_rows!(rows, method: "problem.get", id_key: "eventid")
120
+ end
121
+
122
+ def resource_keys(resource)
123
+ RESOURCES.fetch(resource.to_s) do
124
+ raise Netstack::InputError.new("Unsupported Zabbix resource", code: "unsupported_zabbix_resource")
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def validate_interface!(row, method)
131
+ unless valid_identifier?(row["hostid"]) && %w[1 2 3 4].include?(row["type"].to_s) &&
132
+ %w[0 1].include?(row["main"].to_s) && %w[0 1].include?(row["useip"].to_s) &&
133
+ %w[ip dns port].all? { |key| row[key].is_a?(String) }
134
+ invalid_response!(method)
135
+ end
136
+ invalid_response!(method) if row["type"].to_s == "2" && !row["details"].is_a?(Hash)
137
+ end
138
+
139
+ def valid_tags?(tags)
140
+ tags.is_a?(Array) && tags.all? { |tag| tag.is_a?(Hash) && tag["tag"].is_a?(String) && tag["value"].is_a?(String) }
141
+ end
142
+
143
+ def normalize_host!(row, method)
144
+ groups = row["hostgroups"]
145
+ templates = row["parentTemplates"]
146
+ tags = row["tags"]
147
+ inventory = row["inventory"]
148
+ validate_rows!(groups, method: method, id_key: "groupid")
149
+ validate_rows!(templates, method: method, id_key: "templateid")
150
+ unless valid_tags?(tags) &&
151
+ (inventory.is_a?(Hash) || inventory == [])
152
+ invalid_response!(method)
153
+ end
154
+ row.merge("groups" => groups, "inventory" => inventory == [] ? {} : inventory)
155
+ end
156
+
157
+ def default_query(resource)
158
+ case resource.to_s
159
+ when "host"
160
+ { "output" => "extend", "selectHostGroups" => %w[groupid], "selectParentTemplates" => %w[templateid],
161
+ "selectTags" => "extend", "selectInventory" => "extend" }
162
+ when "trigger"
163
+ { "output" => "extend", "selectTags" => "extend", "selectDependencies" => %w[triggerid], "expandExpression" => true }
164
+ when "usermacro"
165
+ # Secret macro values cannot be read back and are never needed for discovery.
166
+ { "output" => %w[hostmacroid hostid macro type] }
167
+ else
168
+ { "output" => "extend" }
169
+ end
170
+ end
171
+
172
+ def mutate(resource, action, attributes, expected_id: nil)
173
+ _, plural = resource_keys(resource)
174
+ method = "#{resource}.#{action}"
175
+ result = call(method, attributes)
176
+ ids = result[plural] if result.is_a?(Hash)
177
+ invalid_response!(method) unless ids.is_a?(Array) && ids.one? && valid_identifier?(ids.first) && (!expected_id || ids.first.to_s == expected_id)
178
+ ids.first.to_s
179
+ end
180
+
181
+ def validate_rows!(rows, method:, id_key:, unique: true)
182
+ invalid_response!(method) unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) && valid_identifier?(row[id_key]) }
183
+ ids = rows.map { |row| row[id_key].to_s }
184
+ invalid_response!(method) if unique && ids.uniq.size != ids.size
185
+ rows
186
+ end
187
+
188
+ def valid_identifier?(value)
189
+ (value.is_a?(Integer) && value.positive?) || (value.is_a?(String) && value.match?(/\A[1-9][0-9]*\z/))
190
+ end
191
+
192
+ def identifier!(value)
193
+ return value.to_s if valid_identifier?(value)
194
+
195
+ raise Netstack::InputError.new("Invalid Zabbix identifier", code: "invalid_zabbix_id")
196
+ end
197
+
198
+ def range!(from, to, limit)
199
+ return if from.is_a?(Integer) && from >= 0 && to.is_a?(Integer) && to >= from && limit.is_a?(Integer) && (1..100_000).cover?(limit)
200
+
201
+ raise Netstack::InputError.new("Invalid measurement interval or limit", code: "invalid_measurement_range")
202
+ end
203
+
204
+ def invalid_response!(method)
205
+ raise Error.new("Invalid Zabbix response for #{method}", code: "invalid_zabbix_response", stage: "response",
206
+ details: { "method" => method }), cause: nil
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Integrations
5
+ module Zabbix
6
+ class Error < Netstack::Error
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Netstack
6
+ module Inventory
7
+ class Address < Data.define(:host, :prefix, :family)
8
+ def initialize(host:, prefix: nil, family: nil)
9
+ text = Values.text(host, name: "interface address")
10
+ raise InputError, "scoped interface addresses need an explicit interface scope" if text.include?("%")
11
+
12
+ address, suffix = text.split("/", 2)
13
+ raise InputError, "prefix specified twice" if suffix && !prefix.nil?
14
+
15
+ ip = ::IPAddr.new(address)
16
+ limit = ip.ipv4? ? 32 : 128
17
+ prefix = Values.integer(prefix || suffix || limit, name: "prefix", min: 0, max: limit)
18
+ actual_family = ip.ipv4? ? :ipv4 : :ipv6
19
+ raise InputError, "address family mismatch" if family && family.to_sym != actual_family
20
+
21
+ super(host: ip.to_s.freeze, prefix: prefix, family: actual_family)
22
+ rescue ::IPAddr::Error
23
+ raise InputError, "invalid interface address"
24
+ end
25
+
26
+ def cidr = "#{host}/#{prefix}"
27
+ def network = "#{::IPAddr.new(cidr)}/#{prefix}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class Changes < Data.define(:target, :source, :dimensions, :skipped)
6
+ def initialize(target:, source:, dimensions:, skipped:)
7
+ super(target: target, source: source, dimensions: Values.copy(dimensions), skipped: Values.copy(skipped))
8
+ end
9
+
10
+ def empty? = dimensions.values.all? { |change| change.values.all?(&:empty?) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Inventory
5
+ class CliParser
6
+ PLATFORMS = %w[h3c h3c_wireless huawei cisco_ios cisco_nxos].freeze
7
+
8
+ def supports?(platform) = PLATFORMS.include?(platform.to_s)
9
+
10
+ def device(configuration:, version:, target:)
11
+ raise UnsupportedCapability, "inventory platform is not implemented" unless supports?(target.platform)
12
+
13
+ config = Configuration::Reader.new(configuration).content
14
+ version = Configuration::Reader.new(version).content
15
+ name = config[/^\s*(?:hostname|sysname)\s+(\S+)\s*$/, 1]
16
+ raise InputError, "configuration does not contain a recognizable device identity" unless name
17
+
18
+ serial = version[/(?:Processor board ID|Device serial number\s*:|Serial Number\s*:|System serial number\s*:)\s*(\S+)/i, 1]
19
+ os = version[/(?:NXOS: version|system:\s*version|Software, Version|VRP.*Version|IOS.*Version)\s*([^\r\n,]+)/i, 1]&.strip
20
+ model = version[/^\s*(?:cisco|H3C|HUAWEI)\s+([A-Za-z0-9][A-Za-z0-9_-]+)(?:\s+(?:processor|Chassis|Switch|Router)|\s*\()/i, 1]
21
+ Device.new(key: target.key, name: name, serial: serial, model: model, vendor: target.platform.split("_").first,
22
+ os_version: os, attributes: {})
23
+ end
24
+
25
+ def interfaces(configuration:, platform:)
26
+ raise UnsupportedCapability, "inventory platform is not implemented" unless supports?(platform)
27
+
28
+ reader = Configuration::Reader.new(configuration)
29
+ rows = []
30
+ current = nil
31
+ while (line = reader.read)
32
+ if (match = /^interface\s+(\S+)\s*$/.match(line))
33
+ rows << build_interface(current) if current
34
+ current = { name: match[1], addresses: [], admin_status: :unknown, attributes: {} }
35
+ elsif current && line.match?(/\A\S/) && !line.match?(/\A(?:#|!)\s*\z/)
36
+ rows << build_interface(current)
37
+ current = nil
38
+ elsif current
39
+ parse_interface_line(current, line.strip)
40
+ end
41
+ end
42
+ rows << build_interface(current) if current
43
+ unless reader.content.match?(/^\s*(?:hostname|sysname)\s+\S+/)
44
+ raise InputError, "interface configuration identity is missing"
45
+ end
46
+ raise InputError, "duplicate interface identity" unless rows.map(&:key).uniq.size == rows.size
47
+
48
+ rows.freeze
49
+ end
50
+
51
+ private
52
+
53
+ def parse_interface_line(current, text)
54
+ case text
55
+ when /\Adescription\s+(.+)\z/ then current[:description] = Regexp.last_match(1)
56
+ when /\A(?:shutdown|disable)\z/ then current[:admin_status] = :down
57
+ when /\A(?:no shutdown|undo shutdown)\z/ then current[:admin_status] = :up
58
+ when /\Aip address\s+(\S+\/\d+)(?:\s+.*)?\z/
59
+ current[:addresses] << Address.new(host: IPv4.host_cidr(Regexp.last_match(1)))
60
+ when /\Aip address\s+(\S+)\s+(\S+)(?:\s+.*)?\z/
61
+ current[:addresses] << Address.new(host: IPv4.host_cidr(Regexp.last_match(1), Regexp.last_match(2)))
62
+ when /\Aipv6 address\s+(\S+\/\d+)(?:\s+.*)?\z/
63
+ current[:addresses] << Address.new(host: IPv6.canonical(Regexp.last_match(1), host: true))
64
+ when /\Amtu\s+(\d+)\z/ then current[:attributes][:mtu] = Integer(Regexp.last_match(1), 10)
65
+ when /\A(?:ip|ipv6) address\b/
66
+ raise InputError, "unsupported interface address syntax"
67
+ end
68
+ end
69
+
70
+ def build_interface(attributes)
71
+ Interface.new(**attributes)
72
+ end
73
+ end
74
+ end
75
+ end