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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Netstack
7
+ module Ddi
8
+ class Plan
9
+ KINDS = %w[zone record role blacklist block network range address].freeze
10
+ attr_reader :operations, :services, :scope, :digest
11
+
12
+ def initialize(operations:, services:, scope:)
13
+ unless operations.is_a?(Array) && operations.all? { |op| op.is_a?(Hash) && KINDS.include?(op["kind"]) && op["identity"].is_a?(Hash) && op.key?("before") && op.key?("desired") }
14
+ raise InputError, "Invalid DDI operations"
15
+ end
16
+ raise InputError, "Invalid DDI deployment services" unless services.is_a?(Array) && (services - %w[DNS DHCP]).empty?
17
+ @operations = Values.copy(operations)
18
+ @operations.each { |operation| validate_operation!(operation) }
19
+ @services = Values.copy(services.uniq.sort)
20
+ raise InputError, "Invalid DDI scope" unless scope.is_a?(Hash)
21
+ @scope = Values.copy(scope)
22
+ validate_duplicates!
23
+ @digest = Digest::SHA256.hexdigest(JSON.generate(canonical(to_h))).freeze
24
+ freeze
25
+ end
26
+
27
+ def to_h = { "operations" => operations, "services" => services, "scope" => scope }
28
+ def self.from_h(value) = new(operations: value.fetch("operations"), services: value.fetch("services"), scope: value.fetch("scope"))
29
+
30
+ private
31
+
32
+ def validate_operation!(operation)
33
+ kind, identity, before, desired = operation.values_at("kind", "identity", "before", "desired")
34
+ required = {
35
+ "zone" => %w[view_id name], "record" => %w[view_id name type], "role" => %w[view_id name interface_id],
36
+ "blacklist" => %w[policy_id name], "block" => %w[parent_id cidr], "network" => %w[parent_id cidr],
37
+ "range" => %w[network_id start end], "address" => %w[ip]
38
+ }.fetch(kind)
39
+ optional = %w[record role].include?(kind) ? ["zone_id"] : []
40
+ unless (required - identity.keys).empty? && (identity.keys - required - optional).empty? && (operation.keys - %w[kind identity before desired]).empty?
41
+ raise InputError, "Invalid DDI operation fields"
42
+ end
43
+ identity.each do |key, value|
44
+ case key
45
+ when /_id\z/ then raise InputError, "DDI identity ID must be an integer" unless value.is_a?(Integer) && value.positive?
46
+ when "name" then raise InputError, "DNS names must be canonical" unless Inputs.domain(value) == value
47
+ when "cidr" then raise InputError, "Network must be canonical" unless Inputs.cidr(value) == value
48
+ when "ip", "start", "end" then raise InputError, "Address must be canonical" unless Inputs.ipv4(value) == value
49
+ when "type" then raise InputError, "Invalid DNS record type" unless %w[A CNAME].include?(value)
50
+ end
51
+ end
52
+ raise InputError, "Invalid DDI precondition" unless before.nil? || before.is_a?(Hash)
53
+ if before && kind != "blacklist"
54
+ raise InputError, "DDI precondition requires an object ID" unless before["id"].is_a?(Integer) && before["id"].positive?
55
+ end
56
+ if desired.nil?
57
+ raise InputError, "Invalid DDI deletion" unless before && %w[zone network blacklist].include?(kind)
58
+ return
59
+ end
60
+ raise InputError, "DDI desired state must be an object" unless desired.is_a?(Hash)
61
+ valid = case kind
62
+ when "zone" then desired == { "name" => identity["name"], "deployable" => "true" }
63
+ when "record"
64
+ desired.keys.sort == %w[rdata type] && desired["type"] == identity["type"] &&
65
+ (desired["type"] == "A" ? Inputs.ipv4(desired["rdata"]) : Inputs.domain(desired["rdata"])) == desired["rdata"]
66
+ when "role" then desired.keys == ["type"] && %w[MASTER SLAVE FORWARDER].include?(desired["type"])
67
+ when "blacklist" then desired == { "present" => true }
68
+ when "block" then desired == { "cidr" => identity["cidr"] }
69
+ when "network"
70
+ desired.keys.sort == %w[cidr gateway] && desired["cidr"] == identity["cidr"] && Inputs.ipv4(desired["gateway"]) == desired["gateway"]
71
+ when "range" then desired == identity.slice("start", "end")
72
+ when "address" then desired.keys.sort == %w[mac state] && desired["state"] == "DHCP_RESERVED" && Inputs.mac(desired["mac"]) == desired["mac"]
73
+ end
74
+ raise InputError, "Invalid DDI desired state" unless valid
75
+ raise InputError, "Existing DDI objects cannot be overwritten implicitly" if before && kind != "address"
76
+ end
77
+
78
+ def validate_duplicates!
79
+ seen = {}
80
+ operations.each do |operation|
81
+ key = [operation["kind"], canonical(operation["identity"])]
82
+ previous = seen[key]
83
+ if previous && !(operation["kind"] == "zone" && previous["desired"].nil? && previous["before"].is_a?(Hash) && operation["before"].nil? && operation["desired"].is_a?(Hash))
84
+ raise InputError, "Duplicate DDI operation identity"
85
+ end
86
+ seen[key] = operation
87
+ end
88
+ end
89
+
90
+ def canonical(value)
91
+ case value
92
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
93
+ when Array then value.map { |item| canonical(item) }
94
+ else value
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Ddi
5
+ # CIDR containment does not prove configuration ownership. Walk the actual
6
+ # entity hierarchy on every boundary; do not cache it across plan/apply.
7
+ class Scope
8
+ MAX_ANCESTORS = 64
9
+
10
+ def initialize(client:, settings:)
11
+ @client = client
12
+ @settings = settings
13
+ end
14
+
15
+ def validate_identity!(kind, identity)
16
+ case kind
17
+ when "zone", "record", "role"
18
+ unless @settings.views.any? { |view| view["id"] == identity.fetch("view_id") }
19
+ raise InputError, "DNS view is outside the managed scope"
20
+ end
21
+ entity!(identity.fetch("view_id"), types: ["View"])
22
+ entity!(identity.fetch("interface_id"), types: %w[NetworkServerInterface PublishedServerInterface NetworkInterface VirtualInterface]) if kind == "role"
23
+ when "blacklist"
24
+ raise InputError, "Response policy is outside the managed scope" unless identity.fetch("policy_id") == @settings.policy_id
25
+
26
+ entity!(identity.fetch("policy_id"), types: ["ResponsePolicy"])
27
+ when "block", "network"
28
+ parent_id = identity.fetch("parent_id")
29
+ if kind == "block" && parent_id == @settings.configuration_id
30
+ entity!(parent_id, types: ["Configuration"])
31
+ else
32
+ parent = entity!(parent_id, types: ["IP4Block"])
33
+ network = Inputs.network(Inputs.properties(parent.fetch("properties")).fetch("CIDR"))
34
+ child = Inputs.network(identity.fetch("cidr"))
35
+ raise InputError, "Network must be inside the parent block" unless network.include?(child) && network.prefix < child.prefix
36
+ end
37
+ when "range"
38
+ row = entity!(identity.fetch("network_id"), types: ["IP4Network"])
39
+ props = Inputs.properties(row.fetch("properties"))
40
+ network = Inputs.network(props.fetch("CIDR"))
41
+ first, last = identity.values_at("start", "end").map { |address| usable!(network, address).to_i }
42
+ raise InputError, "DHCP range end precedes start" if last < first
43
+ if props["gateway"] && (first..last).cover?(IPAddr.new(Inputs.ipv4(props["gateway"])).to_i)
44
+ raise InputError, "DHCP range includes the gateway"
45
+ end
46
+ when "address"
47
+ row = @client.entity("getIPRangedByIP", containerId: @settings.configuration_id, type: "IP4Network", address: identity.fetch("ip"))
48
+ invalid_scope! unless row
49
+ row = entity!(row.fetch("id"), types: ["IP4Network"])
50
+ props = Inputs.properties(row.fetch("properties"))
51
+ usable!(Inputs.network(props.fetch("CIDR")), identity.fetch("ip"))
52
+ raise InputError, "Gateway cannot be reserved as a client address" if props["gateway"] == identity["ip"]
53
+ else
54
+ raise InputError, "Unknown DDI resource"
55
+ end
56
+ rescue KeyError
57
+ invalid_scope!
58
+ end
59
+
60
+ def entity!(id, types: nil)
61
+ id = Inputs.id(id)
62
+ row = @client.entity("getEntityById", id: id)
63
+ validate_row!(row)
64
+ invalid_scope! unless row["id"] == id && (!types || types.include?(row["type"]))
65
+ ancestors!(row)
66
+ row
67
+ end
68
+
69
+ def validate_delete!(id)
70
+ id = Inputs.id(id)
71
+ raise InputError, "Protected BlueCat object cannot be deleted" if @settings.protected_ids.include?(id)
72
+
73
+ @settings.protected_ids.each do |protected_id|
74
+ row = @client.entity("getEntityById", id: protected_id)
75
+ validate_row!(row)
76
+ invalid_scope! unless row["id"] == protected_id
77
+ if ancestors!(row).include?(id)
78
+ raise InputError, "Deletion contains a protected BlueCat object"
79
+ end
80
+ end
81
+ end
82
+
83
+ def validate_entity!(row, types:)
84
+ validate_row!(row)
85
+ entity!(row.fetch("id"), types: types)
86
+ end
87
+
88
+ private
89
+
90
+ def ancestors!(row)
91
+ seen = []
92
+ MAX_ANCESTORS.times do
93
+ validate_row!(row)
94
+ invalid_scope! if seen.include?(row["id"])
95
+ seen << row["id"]
96
+ if row["type"] == "Configuration"
97
+ invalid_scope! unless row["id"] == @settings.configuration_id
98
+ return seen
99
+ end
100
+ invalid_scope! if row["id"] == @settings.configuration_id
101
+ # Address Manager REST v1: GET getParent?entityId=<child ID> -> APIEntity.
102
+ row = @client.entity("getParent", entityId: row["id"])
103
+ end
104
+ invalid_scope!
105
+ end
106
+
107
+ def validate_row!(row)
108
+ unless row.is_a?(Hash) && row["id"].is_a?(Integer) && row["id"].positive? &&
109
+ row["name"].is_a?(String) && row["type"].is_a?(String) && row["properties"].is_a?(String)
110
+ invalid_scope!
111
+ end
112
+ end
113
+
114
+ def usable!(network, address)
115
+ address = IPAddr.new(Inputs.ipv4(address))
116
+ unless network.include?(address) && ![network.to_range.first, network.to_range.last].include?(address)
117
+ raise InputError, "Address must be a usable host inside the network"
118
+ end
119
+ address
120
+ end
121
+
122
+ def invalid_scope!
123
+ raise Netstack::Error.new("BlueCat configuration ownership could not be confirmed", code: :scope_unconfirmed, stage: :preflight)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Ddi
5
+ class Settings
6
+ attr_reader :configuration_id, :views, :server_ids, :protected_ids, :policy_id
7
+
8
+ def initialize(configuration_id:, views:, server_ids:, protected_ids: [], policy_id: nil)
9
+ @configuration_id = Inputs.id(configuration_id)
10
+ raise InputError, "DDI views must be an array" unless views.is_a?(Array)
11
+ @views = Values.copy(views.map do |view|
12
+ raise InputError, "Invalid DDI view" unless view.is_a?(Hash)
13
+ view = view.transform_keys(&:to_s)
14
+ { "id" => Inputs.id(view.fetch("id")), "roles" => self.class.roles(view.fetch("roles", [])) }
15
+ end)
16
+ raise InputError, "Duplicate DDI views" unless @views.map { |view| view["id"] }.uniq.size == @views.size
17
+ raise InputError, "DDI server IDs must be an array" unless server_ids.is_a?(Array) && protected_ids.is_a?(Array)
18
+ @server_ids = server_ids.map { |id| Inputs.id(id) }.uniq.freeze
19
+ @policy_id = policy_id.nil? ? nil : Inputs.id(policy_id)
20
+ @protected_ids = [@configuration_id, *@views.map { |view| view["id"] }, *@server_ids,
21
+ *@views.flat_map { |view| view["roles"].map { |role| role["interface_id"] } }, @policy_id,
22
+ *protected_ids.map { |id| Inputs.id(id) }].compact.uniq.freeze
23
+ freeze
24
+ end
25
+
26
+ def self.roles(values)
27
+ raise InputError, "DNS roles must be an array" unless values.is_a?(Array)
28
+ result = values.map do |role|
29
+ raise InputError, "Invalid DNS role" unless role.is_a?(Hash)
30
+ role = role.transform_keys(&:to_s)
31
+ raise InputError, "Invalid DNS role type" unless %w[MASTER SLAVE FORWARDER].include?(role["type"])
32
+ { "interface_id" => Inputs.id(role.fetch("interface_id")), "type" => role["type"] }
33
+ end
34
+ raise InputError, "Duplicate DNS role interface" unless result.map { |role| role["interface_id"] }.uniq.size == result.size
35
+ Values.copy(result)
36
+ end
37
+
38
+ def to_h
39
+ { "configuration_id" => configuration_id, "views" => views, "server_ids" => server_ids,
40
+ "protected_ids" => protected_ids, "policy_id" => policy_id }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Ddi
5
+ # Exact selectors and verified representations for the DDI changes we own.
6
+ class State
7
+ def initialize(client:, settings:)
8
+ @client = client
9
+ @settings = settings
10
+ @scope = Scope.new(client: client, settings: settings)
11
+ end
12
+
13
+ def read(kind, identity)
14
+ @scope.validate_identity!(kind, identity)
15
+ case kind
16
+ when "zone"
17
+ row = zone(identity.fetch("view_id"), identity.fetch("name"))
18
+ row && { "id" => row["id"], "name" => identity["name"], "deployable" => properties(row)["deployable"] }
19
+ when "record"
20
+ parent = zone(identity.fetch("view_id"), identity.fetch("name"))
21
+ validate_parent!(parent, identity)
22
+ return nil unless parent
23
+ rows = @client.entities(parent_id: parent["id"], type: "GenericRecord").select do |row|
24
+ props = properties(row)
25
+ props["absoluteName"]&.delete_prefix(".")&.delete_suffix(".")&.downcase == identity["name"] && props["type"] == identity["type"]
26
+ end
27
+ row = unique(rows)
28
+ @scope.validate_entity!(row, types: ["GenericRecord"]) if row
29
+ return nil unless row
30
+
31
+ props = properties(row)
32
+ rdata = props["rdata"]&.delete_suffix(".")
33
+ # CNAME 目标按 DNS 名称比较;provider 的大小写和根点不改变身份。
34
+ rdata = rdata&.downcase if props["type"] == "CNAME"
35
+ { "id" => row["id"], "type" => props["type"], "rdata" => rdata }
36
+ when "role"
37
+ parent = zone(identity.fetch("view_id"), identity.fetch("name"))
38
+ validate_parent!(parent, identity)
39
+ return nil unless parent
40
+ rows = @client.json("getDeploymentRoles", entityId: parent["id"])
41
+ invalid! unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) && row["id"].is_a?(Integer) && row["id"].positive? && row["serverInterfaceId"].is_a?(Integer) && %w[MASTER SLAVE FORWARDER].include?(row["type"]) }
42
+ row = unique(rows.select { |role| role["serverInterfaceId"] == identity["interface_id"] })
43
+ row && { "id" => row["id"], "type" => row["type"] }
44
+ when "blacklist"
45
+ rows = @client.json("findResponsePoliciesWithItem", configurationId: @settings.configuration_id, itemName: identity.fetch("name"), options: "")
46
+ invalid! unless rows.is_a?(Array)
47
+ rows = rows.map { |row| @client.validate_entity(row) }
48
+ rows.any? { |row| row["id"] == identity["policy_id"] } ? { "present" => true } : nil
49
+ when "block", "network"
50
+ type = kind == "block" ? "IP4Block" : "IP4Network"
51
+ row = @client.entity("getEntityByCIDR", parentId: identity.fetch("parent_id"), cidr: identity.fetch("cidr"), type: type)
52
+ return nil unless row
53
+ @scope.validate_entity!(row, types: [type])
54
+ invalid! unless row["type"] == type && properties(row)["CIDR"] == identity["cidr"]
55
+ state = { "id" => row["id"], "cidr" => properties(row)["CIDR"] }
56
+ state["gateway"] = properties(row)["gateway"] if kind == "network"
57
+ state
58
+ when "range"
59
+ row = @client.entity("getEntityByRange", parentId: identity.fetch("network_id"), address1: identity.fetch("start"), address2: identity.fetch("end"), type: "DHCP4Range")
60
+ return nil unless row
61
+ @scope.validate_entity!(row, types: ["DHCP4Range"])
62
+ invalid! unless row["type"] == "DHCP4Range"
63
+ { "id" => row["id"], "start" => properties(row)["start"], "end" => properties(row)["end"] }
64
+ when "address"
65
+ row = @client.entity("getIP4Address", containerId: @settings.configuration_id, address: identity.fetch("ip"))
66
+ return nil unless row
67
+ @scope.validate_entity!(row, types: ["IP4Address"])
68
+ props = properties(row)
69
+ invalid! unless row["type"] == "IP4Address" && props["address"] == identity["ip"]
70
+ { "id" => row["id"], "mac" => props["macAddress"]&.downcase&.tr("-", ":"), "state" => props["state"] }
71
+ else
72
+ raise InputError, "Unknown DDI resource"
73
+ end
74
+ end
75
+
76
+ def write(operation)
77
+ kind, identity, desired, before = operation.values_at("kind", "identity", "desired", "before")
78
+ unless read(kind, identity) == before
79
+ raise Netstack::Error.new("DDI object changed before submission", code: :state_conflict, stage: :preflight)
80
+ end
81
+ if kind == "network" && desired
82
+ network = Inputs.network(identity.fetch("cidr"))
83
+ gateway = IPAddr.new(Inputs.ipv4(desired.fetch("gateway")))
84
+ unless network.include?(gateway) && ![network.to_range.first, network.to_range.last].include?(gateway)
85
+ raise InputError, "Gateway must be a usable host inside the network"
86
+ end
87
+ end
88
+ if desired.nil?
89
+ if kind == "blacklist"
90
+ return @client.write(:delete, "deleteResponsePolicyItem", query: { policyId: identity.fetch("policy_id"), itemName: identity.fetch("name"), option: "" })
91
+ end
92
+ id = Inputs.id(before.fetch("id"))
93
+ @scope.validate_delete!(id)
94
+ return @client.write(:delete, "deleteWithOptions", query: { objectId: id, options: "" })
95
+ end
96
+
97
+ case kind
98
+ when "zone"
99
+ create("addZone", absoluteName: identity.fetch("name"), parentId: identity.fetch("view_id"), properties: "deployable=true")
100
+ when "record"
101
+ create("addGenericRecord", absoluteName: ".#{identity.fetch("name")}", viewId: identity.fetch("view_id"), type: desired.fetch("type"), rdata: desired.fetch("rdata"), ttl: -1, properties: "")
102
+ when "role"
103
+ parent = zone(identity.fetch("view_id"), identity.fetch("name")) || raise(Netstack::Error.new("DNS zone disappeared", code: :state_conflict))
104
+ validate_parent!(parent, identity)
105
+ create("addDNSDeploymentRole", entityId: parent.fetch("id"), serverInterfaceId: identity.fetch("interface_id"), type: desired.fetch("type"), properties: "")
106
+ when "blacklist"
107
+ @client.write(:post, "addResponsePolicyItem", query: { policyId: identity.fetch("policy_id"), itemName: identity.fetch("name"), option: "" })
108
+ when "block"
109
+ create("addIP4BlockByCIDR", parentId: identity.fetch("parent_id"), CIDR: identity.fetch("cidr"), properties: "")
110
+ when "network"
111
+ create("addIP4Network", blockId: identity.fetch("parent_id"), CIDR: identity.fetch("cidr"), properties: "gateway=#{desired.fetch("gateway")}")
112
+ when "range"
113
+ create("addDHCP4Range", networkId: identity.fetch("network_id"), start: identity.fetch("start"), end: identity.fetch("end"), properties: "")
114
+ when "address"
115
+ if before && before["mac"] && before["mac"] != desired["mac"]
116
+ raise InputError, "Address is bound to another MAC"
117
+ end
118
+ if before
119
+ @client.write(:put, "changeStateIP4Address", query: { addressId: before.fetch("id"), macAddress: desired.fetch("mac"), targetState: "MAKE_DHCP_RESERVED" })
120
+ else
121
+ create("assignIP4Address", configurationId: @settings.configuration_id, ip4Address: identity.fetch("ip"), macAddress: desired.fetch("mac"), action: "MAKE_DHCP_RESERVED", hostInfo: "", properties: "")
122
+ end
123
+ end
124
+ end
125
+
126
+ def matches?(actual, desired)
127
+ return actual.nil? if desired.nil?
128
+ actual.is_a?(Hash) && desired.all? { |key, value| actual[key] == value }
129
+ end
130
+
131
+ def validate_delete!(id) = @scope.validate_delete!(id)
132
+
133
+ def zone(view_id, name)
134
+ parent_id = Inputs.id(view_id)
135
+ result = nil
136
+ Inputs.domain(name).split(".").reverse_each do |label|
137
+ result = @client.entity("getEntityByName", parentId: parent_id, name: label, type: "Zone")
138
+ return nil unless result
139
+ @scope.validate_entity!(result, types: ["Zone"])
140
+ invalid! unless result["type"] == "Zone" && result["name"].downcase == label
141
+ parent_id = result.fetch("id")
142
+ end
143
+ result
144
+ end
145
+
146
+ private
147
+
148
+ def validate_parent!(parent, identity)
149
+ return unless identity.key?("zone_id")
150
+ raise Netstack::Error.new("DNS parent identity changed", code: :state_conflict) unless parent && parent["id"] == identity["zone_id"]
151
+ end
152
+
153
+ def create(endpoint, **query) = @client.write(:post, endpoint, query: query, returns_id: true)
154
+ def properties(row) = Inputs.properties(row.fetch("properties"))
155
+
156
+ def unique(rows)
157
+ raise Netstack::Error.new("DDI selector matched multiple objects", code: :ambiguous_object) if rows.length > 1
158
+ rows.first
159
+ end
160
+
161
+ def invalid!
162
+ raise Netstack::Error.new("Invalid BlueCat object identity or properties", code: :invalid_response)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Deployment
5
+ class CheckpointError < Error
6
+ attr_reader :receipt
7
+
8
+ def initialize(receipt:)
9
+ @receipt = receipt
10
+ super("execution checkpoint was not committed", code: :checkpoint_unconfirmed, stage: :checkpoint)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Deployment
5
+ # Generic scripts require domain-specific identity checks and readback verification.
6
+ class CliAdapter
7
+ def initialize(artifact_resolver:, preflight:, verifier:, connector: Connector)
8
+ unless preflight.respond_to?(:call) && verifier.respond_to?(:call)
9
+ raise InputError, "CLI delivery requires explicit preflight and readback verification"
10
+ end
11
+ @artifacts = Configuration::ArtifactResolver.new(resolver: artifact_resolver)
12
+ @preflight = preflight
13
+ @verifier = verifier
14
+ @connector = connector
15
+ end
16
+
17
+ def preflight(plan:, previous:, credentials:) # rubocop:disable Naming/PredicateMethod -- Deployment adapter protocol.
18
+ raise InputError, "unsupported CLI target platform" unless Connector.vendors.include?(plan.target.platform.to_sym)
19
+
20
+ previous_ids = previous ? previous.steps.map { |step| step.fetch(:step_id) } : []
21
+ plan.steps.each do |step|
22
+ next if previous_ids.include?(step.id)
23
+
24
+ artifact = @artifacts.resolve(step)
25
+ artifact.validate_target!(plan.target)
26
+ commands(artifact)
27
+ end
28
+ unless @preflight.call(plan: plan, previous: previous, credentials: credentials) == true
29
+ raise Error.new("CLI preconditions were not confirmed", code: :precondition_failed, stage: :preflight)
30
+ end
31
+ true
32
+ end
33
+
34
+ def apply(step:, target:, credentials:, idempotency_key:)
35
+ artifact = @artifacts.resolve(step)
36
+ artifact.validate_target!(target)
37
+ script = commands(artifact)
38
+ result = @connector.open(target.platform.to_sym, **credentials, host: target.host) do |session|
39
+ session.execute_script(script)
40
+ end
41
+ { status: result.success? ? :applied : :unknown,
42
+ evidence: { sha256: artifact.sha256, completed_commands: result.steps.size, operation_key: idempotency_key } }
43
+ end
44
+
45
+ def verify(step:, target:, credentials:, receipt:)
46
+ @verifier.call(step: step, target: target, credentials: credentials, receipt: receipt)
47
+ end
48
+
49
+ private
50
+
51
+ def commands(artifact)
52
+ script = artifact.content.lines.filter_map.with_index do |line, index|
53
+ text = line.strip
54
+ next if text.empty? || text.start_with?("#", "!")
55
+
56
+ Connector::Command.new(text, sensitive: true, output_sensitive: true, line: index + 1)
57
+ end
58
+ raise InputError, "configuration script has no commands" if script.empty?
59
+
60
+ script
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Netstack
6
+ # 计划与收据分别表达持久意图、提交和已确认的远端效果。
7
+ module Deployment
8
+ # 推进业务步骤;持久化、授权和目标执行租约由宿主负责。
9
+ class Executor
10
+ def initialize(adapter:, clock: -> { Time.now.utc })
11
+ @adapter = adapter
12
+ @clock = clock
13
+ end
14
+
15
+ # 宿主必须持有目标执行权,并在每次检查点持久化后
16
+ # 严格返回 true;此前已开始或结果未知的写入只进行验证。
17
+ # 凭据只传给适配器,不进入检查点。
18
+ def apply(plan:, execution_id:, checkpoint:, credentials:, previous: nil)
19
+ execution_id = Values.text(execution_id, name: "execution id")
20
+ raise InputError, "plan must be a Deployment::Plan" unless plan.is_a?(Plan)
21
+
22
+ identity = { execution_id: execution_id, plan_digest: plan.digest, target_key: plan.target.key,
23
+ planned_step_ids: plan.steps.map(&:id).freeze }.freeze
24
+ validate_input!(identity, checkpoint, previous)
25
+ records = previous ? previous.steps.map(&:dup) : []
26
+ return receipt(identity, records) if plan.steps.empty?
27
+
28
+ unless @adapter.preflight(plan: plan, previous: previous, credentials: credentials) == true
29
+ raise Error.new("execution preconditions were not confirmed", code: :precondition_failed, stage: :preflight)
30
+ end
31
+ plan.steps.each_with_index do |step, index|
32
+ current = records[index]
33
+ next if current && current[:status] == :verified
34
+ break if current && %i[failed not_applied].include?(current[:status])
35
+
36
+ if current
37
+ result = verify(step, plan, credentials, current)
38
+ else
39
+ current = { step_id: step.id, status: :started, idempotency_key: operation_key(identity, step.id) }
40
+ records << current
41
+ checkpoint!(checkpoint, identity, records, current)
42
+ result = submit(step, plan, credentials, current)
43
+ current.replace(current.merge(result))
44
+ checkpoint!(checkpoint, identity, records, current)
45
+ result = verify(step, plan, credentials, current) if %i[applied accepted].include?(current[:status])
46
+ end
47
+
48
+ current.replace(current.merge(result))
49
+ checkpoint!(checkpoint, identity, records, current)
50
+ break unless current[:status] == :verified
51
+ end
52
+ receipt(identity, records)
53
+ end
54
+
55
+ private
56
+
57
+ def validate_input!(identity, checkpoint, previous)
58
+ raise InputError, "a synchronous checkpoint is required" unless checkpoint.respond_to?(:call)
59
+ return unless previous
60
+
61
+ unless previous.is_a?(Receipt) && previous.execution_id == identity[:execution_id] && previous.plan_digest == identity[:plan_digest] && previous.target_key == identity[:target_key]
62
+ raise InputError, "receipt does not belong to this execution and plan"
63
+ end
64
+ planned_step_ids = identity.fetch(:planned_step_ids)
65
+ raise InputError, "receipt plan steps do not match" unless previous.planned_step_ids == planned_step_ids
66
+ expected_ids = planned_step_ids.first(previous.steps.size)
67
+ raise InputError, "receipt steps are not a plan prefix" unless expected_ids == previous.steps.map { |step| step[:step_id] }
68
+ previous.steps.each_with_index do |record, index|
69
+ unless record[:idempotency_key] == operation_key(identity, planned_step_ids.fetch(index))
70
+ raise InputError, "receipt operation identity does not match the execution step"
71
+ end
72
+ end
73
+ return if previous.steps[0...-1].all? { |step| step[:status] == :verified }
74
+
75
+ raise InputError, "receipt advances past an unverified step"
76
+ end
77
+
78
+ def submit(step, plan, credentials, current)
79
+ response = @adapter.apply(step: step, target: plan.target, credentials: credentials, idempotency_key: current.fetch(:idempotency_key))
80
+ checked_response(response, allowed: %i[applied accepted failed unknown])
81
+ rescue StandardError => error
82
+ # 提交后的适配器异常不能证明远端未产生效果。
83
+ { status: :unknown, error_code: error.is_a?(Netstack::Error) ? error.code : :execution_unconfirmed }
84
+ end
85
+
86
+ def verify(step, plan, credentials, current)
87
+ response = @adapter.verify(step: step, target: plan.target, credentials: credentials, receipt: Values.copy(current))
88
+ checked_response(response, allowed: %i[verified failed unknown not_applied])
89
+ rescue StandardError => error
90
+ { status: :unknown, error_code: error.is_a?(Netstack::Error) ? error.code : :verification_unconfirmed }
91
+ end
92
+
93
+ def checked_response(response, allowed:)
94
+ raise Error.new("invalid execution response", code: :invalid_response) unless response.is_a?(Hash)
95
+
96
+ unless response.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } && response.keys.map(&:to_s).uniq.size == response.size
97
+ raise Error.new("ambiguous execution response", code: :invalid_response)
98
+ end
99
+ item = response.transform_keys(&:to_sym)
100
+ status = item.fetch(:status).to_sym
101
+ raise Error.new("invalid execution status", code: :invalid_response) unless allowed.include?(status)
102
+
103
+ # 检查点仅包含限定字段的收据,不包含任意传输响应正文。
104
+ result = { status: status }
105
+ result[:remote_id] = Values.text(item[:remote_id], name: "remote id") if item[:remote_id]
106
+ result[:error_code] = item[:error_code].to_sym if item[:error_code]
107
+ result[:evidence] = Values.copy(item[:evidence]) if item[:evidence]
108
+ result
109
+ end
110
+
111
+ def checkpoint!(callback, identity, records, current)
112
+ event = Values.copy({ **identity.slice(:execution_id, :plan_digest, :target_key),
113
+ observed_at: @clock.call.getutc.iso8601(6), receipt: receipt(identity, records).payload }.merge(current))
114
+ begin
115
+ committed = callback.call(event)
116
+ rescue StandardError
117
+ committed = false
118
+ end
119
+ raise CheckpointError.new(receipt: receipt(identity, records)), cause: nil unless committed == true
120
+ end
121
+
122
+ def receipt(identity, records)
123
+ Receipt.new(**identity, steps: records)
124
+ end
125
+
126
+ def operation_key(identity, step_id)
127
+ Digest::SHA256.hexdigest([identity[:execution_id], identity[:plan_digest], step_id].join("\0"))
128
+ end
129
+ end
130
+ end
131
+ end