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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -3
- data/{LICENSE.txt → LICENSE} +7 -6
- data/NOTICE +25 -0
- data/README.md +58 -20
- data/docs/acceptance.md +92 -0
- data/docs/backups.md +100 -0
- data/docs/configuration.md +198 -0
- data/docs/connector.md +187 -0
- data/docs/ddi.md +142 -0
- data/docs/deployment.md +47 -0
- data/docs/foundations.md +45 -0
- data/docs/implementation.md +68 -0
- data/docs/inventory.md +42 -0
- data/docs/monitoring.md +227 -0
- data/docs/motor_integration.md +96 -0
- data/docs/performance.md +39 -0
- data/docs/release.md +20 -0
- data/docs/vxlan.md +91 -0
- data/docs/wireless.md +81 -0
- data/lib/netstack/backups/artifact.rb +23 -0
- data/lib/netstack/backups/diff.rb +15 -0
- data/lib/netstack/backups/repository.rb +77 -0
- data/lib/netstack/backups/request.rb +78 -0
- data/lib/netstack/circuits/business_calendar.rb +51 -0
- data/lib/netstack/circuits/capacity_review.rb +191 -0
- data/lib/netstack/circuits/endpoint.rb +25 -0
- data/lib/netstack/circuits/monitoring_plan.rb +212 -0
- data/lib/netstack/configuration/artifact.rb +85 -0
- data/lib/netstack/configuration/artifact_resolver.rb +30 -0
- data/lib/netstack/configuration/bootstrap/payload.rb +67 -0
- data/lib/netstack/configuration/bootstrap/validator.rb +224 -0
- data/lib/netstack/configuration/catalog.rb +60 -0
- data/lib/netstack/configuration/generator.rb +87 -0
- data/lib/netstack/configuration/input_contract.rb +82 -0
- data/lib/netstack/configuration/payload.rb +168 -0
- data/lib/netstack/configuration/reader.rb +124 -0
- data/lib/netstack/configuration/support.rb +30 -0
- data/lib/netstack/configuration/system_configuration.rb +56 -0
- data/lib/netstack/configuration/template_manager.rb +174 -0
- data/lib/netstack/configuration/template_store.rb +40 -0
- data/lib/netstack/configuration/validator.rb +566 -0
- data/lib/netstack/connector/authentication.rb +77 -0
- data/lib/netstack/connector/authentication_error.rb +11 -0
- data/lib/netstack/connector/backup.rb +19 -0
- data/lib/netstack/connector/backup_busy.rb +11 -0
- data/lib/netstack/connector/backup_persistence_error.rb +32 -0
- data/lib/netstack/connector/command.rb +63 -0
- data/lib/netstack/connector/command_result.rb +24 -0
- data/lib/netstack/connector/command_timeout.rb +11 -0
- data/lib/netstack/connector/configuration.rb +138 -0
- data/lib/netstack/connector/connection_closed.rb +11 -0
- data/lib/netstack/connector/connection_error.rb +11 -0
- data/lib/netstack/connector/device.rb +284 -0
- data/lib/netstack/connector/device_error.rb +11 -0
- data/lib/netstack/connector/dialogue.rb +55 -0
- data/lib/netstack/connector/error.rb +44 -0
- data/lib/netstack/connector/error_metadata.rb +58 -0
- data/lib/netstack/connector/event.rb +24 -0
- data/lib/netstack/connector/execution.rb +110 -0
- data/lib/netstack/connector/interaction.rb +55 -0
- data/lib/netstack/connector/internal_error.rb +11 -0
- data/lib/netstack/connector/known_hosts.rb +128 -0
- data/lib/netstack/connector/local_backup.rb +63 -0
- data/lib/netstack/connector/log/event.rb +65 -0
- data/lib/netstack/connector/log/formatter.rb +18 -0
- data/lib/netstack/connector/log/messages.rb +54 -0
- data/lib/netstack/connector/log/redacting_writer.rb +33 -0
- data/lib/netstack/connector/log/transcript.rb +35 -0
- data/lib/netstack/connector/log.rb +262 -0
- data/lib/netstack/connector/log_error.rb +11 -0
- data/lib/netstack/connector/login_timeout.rb +11 -0
- data/lib/netstack/connector/output_limit_exceeded.rb +11 -0
- data/lib/netstack/connector/parsing_error.rb +11 -0
- data/lib/netstack/connector/profile/builder.rb +238 -0
- data/lib/netstack/connector/profile.rb +225 -0
- data/lib/netstack/connector/prompt_error.rb +11 -0
- data/lib/netstack/connector/recovery.rb +59 -0
- data/lib/netstack/connector/redactor.rb +100 -0
- data/lib/netstack/connector/response.rb +25 -0
- data/lib/netstack/connector/response_reader.rb +140 -0
- data/lib/netstack/connector/result.rb +50 -0
- data/lib/netstack/connector/running_config/rendered.rb +16 -0
- data/lib/netstack/connector/running_config/strategy.rb +28 -0
- data/lib/netstack/connector/running_config.rb +113 -0
- data/lib/netstack/connector/save_config.rb +23 -0
- data/lib/netstack/connector/script.rb +59 -0
- data/lib/netstack/connector/script_error.rb +11 -0
- data/lib/netstack/connector/script_output_limit_exceeded.rb +11 -0
- data/lib/netstack/connector/session.rb +414 -0
- data/lib/netstack/connector/session_busy.rb +11 -0
- data/lib/netstack/connector/storage/backup_lock.rb +117 -0
- data/lib/netstack/connector/storage/private_file.rb +111 -0
- data/lib/netstack/connector/storage/safe_file.rb +64 -0
- data/lib/netstack/connector/storage/saved_config.rb +76 -0
- data/lib/netstack/connector/terminal_renderer.rb +186 -0
- data/lib/netstack/connector/terminal_text.rb +32 -0
- data/lib/netstack/connector/textfsm.rb +71 -0
- data/lib/netstack/connector/tftp/file_upload.rb +53 -0
- data/lib/netstack/connector/tftp/strategy.rb +67 -0
- data/lib/netstack/connector/tftp.rb +116 -0
- data/lib/netstack/connector/tftp_completion_error.rb +36 -0
- data/lib/netstack/connector/tftp_receipt.rb +54 -0
- data/lib/netstack/connector/tftp_target.rb +65 -0
- data/lib/netstack/connector/topology/checkpoint_error.rb +16 -0
- data/lib/netstack/connector/topology/deployment_adapter.rb +163 -0
- data/lib/netstack/connector/topology/execution_result.rb +34 -0
- data/lib/netstack/connector/topology/immediate_strategy.rb +39 -0
- data/lib/netstack/connector/topology/interface_description.rb +46 -0
- data/lib/netstack/connector/topology/interface_name.rb +60 -0
- data/lib/netstack/connector/topology/plan.rb +105 -0
- data/lib/netstack/connector/topology/strategy.rb +76 -0
- data/lib/netstack/connector/topology.rb +255 -0
- data/lib/netstack/connector/transport_error.rb +11 -0
- data/lib/netstack/connector/transports.rb +201 -0
- data/lib/netstack/connector/underlying_error.rb +25 -0
- data/lib/netstack/connector/unsupported_operation.rb +11 -0
- data/lib/netstack/connector/vendors/cisco_ios/running_config.rb +22 -0
- data/lib/netstack/connector/vendors/cisco_ios/tftp_backup.rb +41 -0
- data/lib/netstack/connector/vendors/cisco_ios/topology.rb +54 -0
- data/lib/netstack/connector/vendors/cisco_ios.rb +44 -0
- data/lib/netstack/connector/vendors/cisco_nxos/running_config.rb +25 -0
- data/lib/netstack/connector/vendors/cisco_nxos/tftp_backup.rb +43 -0
- data/lib/netstack/connector/vendors/cisco_nxos.rb +51 -0
- data/lib/netstack/connector/vendors/h3c/tftp_backup.rb +34 -0
- data/lib/netstack/connector/vendors/h3c/topology.rb +72 -0
- data/lib/netstack/connector/vendors/h3c.rb +75 -0
- data/lib/netstack/connector/vendors/h3c_wireless.rb +13 -0
- data/lib/netstack/connector/vendors/hillstone/running_config.rb +16 -0
- data/lib/netstack/connector/vendors/hillstone/tftp_backup.rb +55 -0
- data/lib/netstack/connector/vendors/hillstone/topology.rb +64 -0
- data/lib/netstack/connector/vendors/hillstone.rb +39 -0
- data/lib/netstack/connector/vendors/huawei/tftp_backup.rb +26 -0
- data/lib/netstack/connector/vendors/huawei.rb +62 -0
- data/lib/netstack/connector/vendors/palo_alto/running_config.rb +77 -0
- data/lib/netstack/connector/vendors/palo_alto/tftp_backup.rb +49 -0
- data/lib/netstack/connector/vendors/palo_alto/topology.rb +77 -0
- data/lib/netstack/connector/vendors/palo_alto.rb +39 -0
- data/lib/netstack/connector/vendors/radware/running_config.rb +21 -0
- data/lib/netstack/connector/vendors/radware/tftp_backup.rb +49 -0
- data/lib/netstack/connector/vendors/radware/topology.rb +19 -0
- data/lib/netstack/connector/vendors/radware.rb +59 -0
- data/lib/netstack/connector/write_timeout.rb +11 -0
- data/lib/netstack/connector.rb +32 -0
- data/lib/netstack/ddi/applier.rb +107 -0
- data/lib/netstack/ddi/deployment.rb +137 -0
- data/lib/netstack/ddi/inputs.rb +61 -0
- data/lib/netstack/ddi/manager.rb +197 -0
- data/lib/netstack/ddi/plan.rb +99 -0
- data/lib/netstack/ddi/scope.rb +127 -0
- data/lib/netstack/ddi/settings.rb +44 -0
- data/lib/netstack/ddi/state.rb +166 -0
- data/lib/netstack/deployment/checkpoint_error.rb +14 -0
- data/lib/netstack/deployment/cli_adapter.rb +64 -0
- data/lib/netstack/deployment/executor.rb +131 -0
- data/lib/netstack/deployment/plan.rb +82 -0
- data/lib/netstack/deployment/receipt.rb +78 -0
- data/lib/netstack/deployment/recovery.rb +19 -0
- data/lib/netstack/deployment/step.rb +16 -0
- data/lib/netstack/dimension.rb +30 -0
- data/lib/netstack/error.rb +14 -0
- data/lib/netstack/input_error.rb +9 -0
- data/lib/netstack/integrations/bluecat/client.rb +84 -0
- data/lib/netstack/integrations/http/client.rb +146 -0
- data/lib/netstack/integrations/http/error.rb +10 -0
- data/lib/netstack/integrations/http/response.rb +28 -0
- data/lib/netstack/integrations/http_delivery/adapter.rb +51 -0
- data/lib/netstack/integrations/http_delivery/client.rb +127 -0
- data/lib/netstack/integrations/http_delivery/result.rb +27 -0
- data/lib/netstack/integrations/oxidized/client.rb +89 -0
- data/lib/netstack/integrations/zabbix/client.rb +211 -0
- data/lib/netstack/integrations/zabbix/error.rb +10 -0
- data/lib/netstack/inventory/address.rb +30 -0
- data/lib/netstack/inventory/changes.rb +13 -0
- data/lib/netstack/inventory/cli_parser.rb +75 -0
- data/lib/netstack/inventory/collector.rb +99 -0
- data/lib/netstack/inventory/device.rb +14 -0
- data/lib/netstack/inventory/interface.rb +25 -0
- data/lib/netstack/inventory/neighbor.rb +15 -0
- data/lib/netstack/inventory/reconciler.rb +74 -0
- data/lib/netstack/inventory/snapshot.rb +69 -0
- data/lib/netstack/inventory/transceiver.rb +23 -0
- data/lib/netstack/ipv4.rb +204 -0
- data/lib/netstack/ipv6.rb +88 -0
- data/lib/netstack/monitoring/applier.rb +179 -0
- data/lib/netstack/monitoring/device_plan.rb +23 -0
- data/lib/netstack/monitoring/host_plan.rb +152 -0
- data/lib/netstack/monitoring/inputs.rb +73 -0
- data/lib/netstack/monitoring/measurements.rb +89 -0
- data/lib/netstack/monitoring/mutation.rb +37 -0
- data/lib/netstack/monitoring/plan.rb +69 -0
- data/lib/netstack/monitoring/probe_plan.rb +19 -0
- data/lib/netstack/monitoring/state_comparison.rb +37 -0
- data/lib/netstack/range_set.rb +181 -0
- data/lib/netstack/routing/lookup.rb +46 -0
- data/lib/netstack/routing/lookup_result.rb +14 -0
- data/lib/netstack/routing/route.rb +32 -0
- data/lib/netstack/routing/snapshot.rb +23 -0
- data/lib/netstack/target.rb +15 -0
- data/lib/netstack/unsupported_capability.rb +9 -0
- data/lib/netstack/values.rb +70 -0
- data/lib/netstack/version.rb +1 -1
- data/lib/netstack/vxlan/auditor.rb +187 -0
- data/lib/netstack/vxlan/cisco_nxos/config_parser.rb +327 -0
- data/lib/netstack/vxlan/cisco_nxos/deployment_adapter.rb +63 -0
- data/lib/netstack/vxlan/cisco_nxos/runtime_parser.rb +182 -0
- data/lib/netstack/vxlan/cisco_nxos/segment_renderer.rb +42 -0
- data/lib/netstack/vxlan/collector.rb +77 -0
- data/lib/netstack/vxlan/fabric.rb +29 -0
- data/lib/netstack/vxlan/finding.rb +20 -0
- data/lib/netstack/vxlan/node.rb +51 -0
- data/lib/netstack/vxlan/route_policy.rb +57 -0
- data/lib/netstack/vxlan/runtime_result.rb +24 -0
- data/lib/netstack/vxlan/segment.rb +35 -0
- data/lib/netstack/vxlan/segment_plan.rb +25 -0
- data/lib/netstack/vxlan/segment_planner.rb +156 -0
- data/lib/netstack/vxlan/snapshot.rb +34 -0
- data/lib/netstack/vxlan/validation.rb +58 -0
- data/lib/netstack/vxlan/verifier.rb +52 -0
- data/lib/netstack/vxlan/vpc_domain.rb +20 -0
- data/lib/netstack/wireless/access_point.rb +12 -0
- data/lib/netstack/wireless/ap_group.rb +12 -0
- data/lib/netstack/wireless/assessment.rb +13 -0
- data/lib/netstack/wireless/assessor.rb +83 -0
- data/lib/netstack/wireless/authentication_connection.rb +12 -0
- data/lib/netstack/wireless/bss.rb +12 -0
- data/lib/netstack/wireless/client.rb +12 -0
- data/lib/netstack/wireless/collector.rb +191 -0
- data/lib/netstack/wireless/configuration.rb +13 -0
- data/lib/netstack/wireless/finding.rb +12 -0
- data/lib/netstack/wireless/h3c/config_parser.rb +229 -0
- data/lib/netstack/wireless/h3c/lldp_parser.rb +69 -0
- data/lib/netstack/wireless/h3c/runtime_parser.rb +240 -0
- data/lib/netstack/wireless/huawei/runtime_parser.rb +83 -0
- data/lib/netstack/wireless/invalid_snapshot.rb +11 -0
- data/lib/netstack/wireless/neighbor.rb +12 -0
- data/lib/netstack/wireless/normalizer.rb +90 -0
- data/lib/netstack/wireless/parse_result.rb +13 -0
- data/lib/netstack/wireless/radio.rb +12 -0
- data/lib/netstack/wireless/reconciler.rb +115 -0
- data/lib/netstack/wireless/reconciliation.rb +11 -0
- data/lib/netstack/wireless/security_policy.rb +12 -0
- data/lib/netstack/wireless/service_template.rb +12 -0
- data/lib/netstack/wireless/service_template_bindings.rb +56 -0
- data/lib/netstack/wireless/snapshot.rb +150 -0
- data/lib/netstack/wireless/state.rb +23 -0
- data/lib/netstack/wireless/view.rb +154 -0
- data/lib/netstack.rb +10 -3
- data/resources/configuration/cisco-bootstrap.erb +118 -0
- data/resources/configuration/cisco-distributed-gateway.erb +40 -0
- data/resources/configuration/cisco-n9k-vpc.erb +62 -0
- data/resources/configuration/cisco-server-port.erb +43 -0
- data/resources/configuration/h3c-bootstrap.erb +108 -0
- data/resources/configuration/h3c-irf.erb +57 -0
- data/resources/configuration/h3c-server-port.erb +36 -0
- data/resources/configuration/hillstone-ipsec-vpn.erb +68 -0
- data/resources/configuration/hillstone-policy.erb +31 -0
- data/resources/configuration/paloalto-ipsec-vpn.erb +39 -0
- data/resources/configuration/radware-load-balancer.erb +26 -0
- data/resources/textfsm/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/resources/textfsm/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/resources/textfsm/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/resources/textfsm/h3c_interface_descriptions.textfsm +11 -0
- data/resources/textfsm/h3c_lldp_local_first.textfsm +8 -0
- data/resources/textfsm/h3c_lldp_name_first.textfsm +8 -0
- data/resources/textfsm/hillstone_interface_descriptions.textfsm +11 -0
- data/resources/textfsm/hillstone_lldp_neighbors.textfsm +7 -0
- data/resources/textfsm/index +8 -0
- data/resources/textfsm/palo_alto_interface_descriptions.textfsm +6 -0
- data/resources/textfsm/palo_alto_lldp_neighbors.textfsm +11 -0
- data/resources/textfsm/radware_port_names.textfsm +13 -0
- metadata +588 -27
- data/.rubocop.yml +0 -13
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -10
- data/Gemfile.lock +0 -40
- data/Rakefile +0 -8
- data/bin/console +0 -15
- 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
|