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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
Backup = Data.define(:path, :bytes, :sha256, :collected_at, :change, :previous_sha256) do
|
|
10
|
+
# 保存配置备份的路径、摘要和变更状态。
|
|
11
|
+
def initialize(path:, bytes:, sha256:, collected_at:, change: nil, previous_sha256: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# 判断本次采集是否产生新的配置内容。
|
|
16
|
+
def changed? = [:created, :changed].include?(change)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 只携带库自身的提交回执和配置元数据,不携带正文或原始文件系统异常。
|
|
10
|
+
class BackupPersistenceError < Error
|
|
11
|
+
attr_reader :backup, :receipt, :underlying_type
|
|
12
|
+
|
|
13
|
+
def initialize(backup:, write_error:, host: nil)
|
|
14
|
+
unless backup.instance_of?(Backup) && Storage::PrivateFile.receipt_error?(write_error) &&
|
|
15
|
+
write_error.receipt.committed? && write_error.receipt.path == backup.path
|
|
16
|
+
raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@backup, @receipt = backup, write_error.receipt
|
|
20
|
+
@underlying_type = write_error.underlying_type
|
|
21
|
+
code = if write_error.instance_of?(Storage::PrivateFile::DirectorySyncUnsupported)
|
|
22
|
+
:backup_durability_unsupported
|
|
23
|
+
elsif receipt.durable?
|
|
24
|
+
:backup_finalize_failed
|
|
25
|
+
else
|
|
26
|
+
:backup_persistence_unconfirmed
|
|
27
|
+
end
|
|
28
|
+
super(write_error.message, code: code, host: host, phase: :backup)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "expect/pty"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 一条设备命令行,可附带独立截止时间和交互规则。
|
|
10
|
+
class Command
|
|
11
|
+
attr_reader :text, :timeout, :interactions, :prompt, :source, :line
|
|
12
|
+
|
|
13
|
+
# 在任何输入输出前校验命令、提示、交互和敏感标记,并冻结命令。
|
|
14
|
+
def initialize(text, timeout: nil, interactions: [], prompt: nil, sensitive: false, output_sensitive: false,
|
|
15
|
+
source: nil, line: nil)
|
|
16
|
+
unless text.is_a?(String) && !text.strip.empty? && !text.match?(/[\r\n\x00]/)
|
|
17
|
+
raise ScriptError.new("a command must contain exactly one nonempty CLI line", phase: :parse,
|
|
18
|
+
source: source, line: line)
|
|
19
|
+
end
|
|
20
|
+
unless interactions.is_a?(Array) && interactions.all?(Interaction)
|
|
21
|
+
raise ArgumentError, "interactions must be an Array of Interaction objects"
|
|
22
|
+
end
|
|
23
|
+
valid_prompt = prompt.nil? || (prompt.is_a?(Regexp) && !prompt.match?(""))
|
|
24
|
+
unless valid_prompt
|
|
25
|
+
raise ArgumentError, "prompt must be a nonempty Regexp or nil"
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "sensitive must be true or false" unless [true, false].include?(sensitive)
|
|
28
|
+
raise ArgumentError, "output_sensitive must be true or false" unless [true, false].include?(output_sensitive)
|
|
29
|
+
|
|
30
|
+
@text = text.dup.freeze
|
|
31
|
+
@timeout = Expect.duration(timeout)
|
|
32
|
+
@interactions = interactions.dup.freeze
|
|
33
|
+
@prompt = prompt
|
|
34
|
+
@sensitive = sensitive
|
|
35
|
+
@output_sensitive = output_sensitive
|
|
36
|
+
@source = source&.dup&.freeze
|
|
37
|
+
@line = line
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 判断命令文本是否需要脱敏。
|
|
42
|
+
def sensitive? = @sensitive
|
|
43
|
+
|
|
44
|
+
# 响应正文可能包含尚未登记的秘密,与命令文本是否敏感无关。
|
|
45
|
+
def output_sensitive? = @output_sensitive
|
|
46
|
+
|
|
47
|
+
# 复制命令元数据,只替换命令文本。
|
|
48
|
+
def with_text(text)
|
|
49
|
+
self.class.new(text, timeout: timeout, interactions: interactions, prompt: prompt,
|
|
50
|
+
sensitive: sensitive?, output_sensitive: output_sensitive?, source: source, line: line)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 配置采集保留调用方的全部命令元数据,只收紧输出的诊断边界。
|
|
54
|
+
def with_output_sensitive
|
|
55
|
+
self.class.new(text, timeout: timeout, interactions: interactions, prompt: prompt,
|
|
56
|
+
sensitive: sensitive?, output_sensitive: true, source: source, line: line)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 返回命令来源摘要,不展开命令内容。
|
|
60
|
+
def inspect = "#<#{self.class} source=#{source.inspect} line=#{line.inspect}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
module Netstack
|
|
6
|
+
module Connector
|
|
7
|
+
# 一条已完成命令的结果;后续命令失败时仍保留已完成步骤。
|
|
8
|
+
class CommandResult
|
|
9
|
+
attr_reader :command, :output, :prompt, :duration
|
|
10
|
+
|
|
11
|
+
# 保存命令输出、提示符和耗时,并冻结结果。
|
|
12
|
+
def initialize(command:, output:, prompt:, duration:)
|
|
13
|
+
@command = command
|
|
14
|
+
@output = output.frozen? ? output : output.dup.freeze
|
|
15
|
+
@prompt = prompt&.dup&.freeze
|
|
16
|
+
@duration = duration
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 返回输出字节数和耗时摘要。
|
|
21
|
+
def inspect = "#<#{self.class} bytes=#{output.bytesize} duration=#{duration.round(6)}>"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "expect/pty"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 显式且不可变的连接设置;环境变量读取由调用库的可执行程序负责。
|
|
10
|
+
class Configuration
|
|
11
|
+
attr_reader :host, :username, :password, :enable_password, :protocol, :port, :login_timeout,
|
|
12
|
+
:command_timeout, :write_timeout, :max_output_bytes, :max_script_output_bytes, :log_file, :log_format, :log_level,
|
|
13
|
+
:known_hosts, :host_key_policy, :challenges, :logger, :on_event
|
|
14
|
+
|
|
15
|
+
# 校验端点、凭据、超时、日志、主机密钥和挑战配置,然后冻结设置。
|
|
16
|
+
def initialize(host: nil, username: nil, password: nil, enable_password: nil, protocol: :ssh, port: nil,
|
|
17
|
+
login_timeout: 10, command_timeout: nil, write_timeout: 10,
|
|
18
|
+
max_output_bytes: 32 * 1024 * 1024, max_script_output_bytes: nil, log_file: nil, logger: nil,
|
|
19
|
+
log_format: :text, log_level: :info, on_event: nil,
|
|
20
|
+
known_hosts: nil, host_key_policy: :strict, telnet_fallback: false, legacy_ssh: false,
|
|
21
|
+
challenges: [])
|
|
22
|
+
@host = frozen_string(host)
|
|
23
|
+
@username = frozen_string(username)
|
|
24
|
+
@password = single_line_credential(password)
|
|
25
|
+
@enable_password = single_line_credential(enable_password)
|
|
26
|
+
@protocol = enum_value(protocol, %i[ssh telnet], :protocol)
|
|
27
|
+
@port = port
|
|
28
|
+
valid_port = port.nil? || (port.is_a?(Integer) && (1..65_535).cover?(port))
|
|
29
|
+
unless valid_port
|
|
30
|
+
raise ArgumentError, "port must be in 1..65535"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@login_timeout = timeout_seconds(login_timeout, :login_timeout)
|
|
34
|
+
@command_timeout = command_timeout.nil? ? nil : timeout_seconds(command_timeout, :command_timeout)
|
|
35
|
+
@write_timeout = timeout_seconds(write_timeout, :write_timeout)
|
|
36
|
+
@max_output_bytes = positive_integer(max_output_bytes, :max_output_bytes)
|
|
37
|
+
@max_script_output_bytes = positive_integer(max_script_output_bytes, :max_script_output_bytes) unless max_script_output_bytes.nil?
|
|
38
|
+
@log_file = absolute_path(log_file)
|
|
39
|
+
if logger && (!logger.respond_to?(:debug) || !logger.respond_to?(:info) ||
|
|
40
|
+
!logger.respond_to?(:warn) || !logger.respond_to?(:error) ||
|
|
41
|
+
!logger.respond_to?(:level))
|
|
42
|
+
raise ArgumentError, "logger must be Logger-compatible"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "logger and log_file cannot be combined" if logger && @log_file
|
|
45
|
+
@log_format = enum_value(log_format, %i[raw text], :log_format)
|
|
46
|
+
raise ArgumentError, "logger cannot be combined with raw logging" if logger && @log_format == :raw
|
|
47
|
+
|
|
48
|
+
@logger = logger
|
|
49
|
+
raise ArgumentError, "on_event must respond to call" if on_event && !on_event.respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
@on_event = on_event
|
|
52
|
+
@log_level = enum_value(log_level, %i[debug info warn error], :log_level)
|
|
53
|
+
@known_hosts = absolute_path(known_hosts)
|
|
54
|
+
@host_key_policy = enum_value(host_key_policy, %i[strict accept_new replace], :host_key_policy)
|
|
55
|
+
if @host_key_policy == :replace && !@known_hosts
|
|
56
|
+
raise ArgumentError, "host_key_policy :replace requires an explicit known_hosts file"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@telnet_fallback = boolean_value(telnet_fallback)
|
|
60
|
+
@legacy_ssh = boolean_value(legacy_ssh)
|
|
61
|
+
unless challenges.is_a?(Array) && challenges.all?(Interaction)
|
|
62
|
+
raise ArgumentError, "challenges must be an Array of Interaction objects"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@challenges = challenges.dup.freeze
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 判断是否允许 SSH 失败后回退到 Telnet。
|
|
70
|
+
def telnet_fallback? = @telnet_fallback
|
|
71
|
+
|
|
72
|
+
# 判断是否允许使用旧版 SSH 协商参数恢复。
|
|
73
|
+
def legacy_ssh? = @legacy_ssh
|
|
74
|
+
|
|
75
|
+
# 校验最终连接主机和用户名能安全进入外部命令参数。
|
|
76
|
+
def validate_endpoint!
|
|
77
|
+
unless host.is_a?(String) && /\A[A-Za-z0-9:][A-Za-z0-9.:%_-]*\z/.match?(host)
|
|
78
|
+
raise ArgumentError, "host must be a device address or hostname"
|
|
79
|
+
end
|
|
80
|
+
return if username.is_a?(String) && /\A[A-Za-z0-9_][A-Za-z0-9_.@$\\-]*\z/.match?(username)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "username is empty or contains unsupported characters"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 返回不包含凭据的连接配置摘要。
|
|
86
|
+
def inspect = "#<#{self.class} host=#{host.inspect} protocol=#{protocol.inspect}>"
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def positive_integer(value, name)
|
|
91
|
+
raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
92
|
+
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 将可选值校验为字符串副本。
|
|
97
|
+
def frozen_string(value)
|
|
98
|
+
return nil if value.nil?
|
|
99
|
+
raise ArgumentError, "expected a String or nil" unless value.is_a?(String)
|
|
100
|
+
|
|
101
|
+
value.dup.freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 校验凭据是单行文本。
|
|
105
|
+
def single_line_credential(value)
|
|
106
|
+
value = frozen_string(value)
|
|
107
|
+
raise ArgumentError, "credentials must be a single line" if value&.match?(/[\r\n\x00]/)
|
|
108
|
+
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 将路径转换为绝对冻结文本。
|
|
113
|
+
def absolute_path(value) = value.nil? ? nil : File.expand_path(frozen_string(value)).freeze
|
|
114
|
+
|
|
115
|
+
# 将超时转换为有限秒数。
|
|
116
|
+
def timeout_seconds(value, name)
|
|
117
|
+
result = Expect.duration(value)
|
|
118
|
+
raise ArgumentError, "#{name} must be finite" unless result
|
|
119
|
+
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 校验布尔设置只能是 true 或 false。
|
|
124
|
+
def boolean_value(value)
|
|
125
|
+
raise ArgumentError, "expected true or false" unless [true, false].include?(value)
|
|
126
|
+
|
|
127
|
+
value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 将枚举输入转换为允许的符号。
|
|
131
|
+
def enum_value(value, choices, name)
|
|
132
|
+
return value.to_sym if value.respond_to?(:to_sym) && choices.include?(value.to_sym)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "#{name} must be one of #{choices.join(", ")}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "forwardable"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 设备连接门面;厂商子类提供语法和钩子,组合对象负责实际执行。
|
|
10
|
+
class Device
|
|
11
|
+
extend Forwardable
|
|
12
|
+
include RunningConfig::Capability
|
|
13
|
+
include SaveConfig
|
|
14
|
+
include LocalBackup::Capability
|
|
15
|
+
include Tftp::Capability
|
|
16
|
+
include Topology::Capability
|
|
17
|
+
include TextFSM::Capability
|
|
18
|
+
|
|
19
|
+
attr_reader :configuration, :command_timeout
|
|
20
|
+
|
|
21
|
+
def_delegators :@session, :connected?, :privileged?, :state
|
|
22
|
+
def_delegators :@configuration, :host, :username, :log_file
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def vendor(key = nil)
|
|
26
|
+
return @vendor = key if key
|
|
27
|
+
|
|
28
|
+
@vendor || (superclass.vendor if superclass.respond_to?(:vendor))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 在子类中声明设备规则;没有块时返回当前类继承到的档案。
|
|
32
|
+
def profile(&definition)
|
|
33
|
+
return inherited_profile unless definition
|
|
34
|
+
|
|
35
|
+
@profile = Profile.define(parent: inherited_profile, &definition)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 让子类共享不可变父档案,子类声明时再生成自己的副本。
|
|
39
|
+
def inherited(subclass)
|
|
40
|
+
super
|
|
41
|
+
subclass.instance_variable_set(:@profile, profile)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# 查找当前类或最近父类的设备档案。
|
|
47
|
+
def inherited_profile
|
|
48
|
+
return @profile if instance_variable_defined?(:@profile)
|
|
49
|
+
|
|
50
|
+
parent = superclass
|
|
51
|
+
parent.respond_to?(:profile) ? parent.profile : nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 连接设备并执行代码块;无论正常返回、break、异常还是中断,都会释放会话资源。
|
|
56
|
+
def self.open(**)
|
|
57
|
+
raise ArgumentError, "open requires a block" unless block_given?
|
|
58
|
+
|
|
59
|
+
device = new(**)
|
|
60
|
+
failed = false
|
|
61
|
+
begin
|
|
62
|
+
device.connect
|
|
63
|
+
yield device
|
|
64
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Preserve failures from this block, including interrupts, through cleanup.
|
|
65
|
+
failed = true
|
|
66
|
+
raise
|
|
67
|
+
ensure
|
|
68
|
+
begin
|
|
69
|
+
device.close
|
|
70
|
+
rescue StandardError
|
|
71
|
+
raise unless failed
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 校验连接配置,创建对话语法、传输层、恢复器和会话对象。
|
|
77
|
+
def initialize(configuration: nil, transport: nil, **settings)
|
|
78
|
+
if configuration
|
|
79
|
+
raise ArgumentError, "configuration cannot be combined with settings" unless settings.empty?
|
|
80
|
+
raise ArgumentError, "configuration must be a Configuration" unless configuration.is_a?(Configuration)
|
|
81
|
+
end
|
|
82
|
+
@configuration = configuration || Configuration.new(**settings)
|
|
83
|
+
@command_timeout = @configuration.command_timeout || default_command_timeout
|
|
84
|
+
@dialogue = build_dialogue
|
|
85
|
+
transport ||= Transports.build(@configuration, terminal_size: terminal_size)
|
|
86
|
+
@session = Session.new(configuration: @configuration, dialogue: @dialogue, transport: transport,
|
|
87
|
+
recovery: Recovery.new(@configuration, legacy_arguments: legacy_ssh_arguments),
|
|
88
|
+
after_login: method(:after_login))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 返回当前连接器使用的不可变设备档案,便于检查厂商声明。
|
|
92
|
+
def profile = self.class.profile || Profile.default
|
|
93
|
+
|
|
94
|
+
def vendor = self.class.vendor
|
|
95
|
+
|
|
96
|
+
# 只查询实现能力,不连接设备;不代表现场权限或固件验证成功。
|
|
97
|
+
def supports?(capability)
|
|
98
|
+
capability = capability.to_sym if capability.is_a?(String)
|
|
99
|
+
case capability
|
|
100
|
+
when :running_config, :backup
|
|
101
|
+
!config_commands.empty?
|
|
102
|
+
when :save_config
|
|
103
|
+
!save_commands.empty?
|
|
104
|
+
when :tftp_backup
|
|
105
|
+
!profile.tftp_strategy.nil?
|
|
106
|
+
when :neighbors, :interface_descriptions, :interface_description_changes
|
|
107
|
+
!!profile.topology_strategy&.supports?(capability)
|
|
108
|
+
else
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
rescue NotImplementedError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 建立会话并返回当前设备对象。
|
|
116
|
+
def connect
|
|
117
|
+
@session.connect
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# 关闭会话及其传输资源。
|
|
122
|
+
def close = @session.close
|
|
123
|
+
|
|
124
|
+
# 将一条文本命令包装成脚本并执行。
|
|
125
|
+
def execute_command(text, **, &)
|
|
126
|
+
execute_script(Script.new([Command.new(text, **)]), &)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 接收脚本或命令数组,统一交给单次会话执行。
|
|
130
|
+
def execute_script(script, &)
|
|
131
|
+
script = Script.new(script) unless script.is_a?(Script)
|
|
132
|
+
perform_script(script, &)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
|
|
136
|
+
def with_operation(name, &) = @session.with_operation(name, &)
|
|
137
|
+
|
|
138
|
+
# 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
|
|
139
|
+
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
|
|
140
|
+
perform_script(script, operation: name, prompt: prompt, after_command: after_command,
|
|
141
|
+
privilege: privilege, finalize: finalize)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 返回已确认的提示,供业务层为只读采集绑定准确的结束条件。
|
|
145
|
+
def current_prompt = @session.prompt
|
|
146
|
+
|
|
147
|
+
# 将业务事件写入当前设备会话日志。
|
|
148
|
+
def log_event(name, **details)
|
|
149
|
+
@session.log_event(name, **details)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# 进入特权模式,并把当前提示符记录到会话。
|
|
153
|
+
def enable
|
|
154
|
+
@session.perform(:enable) { @session.enable(enable_command, enable_prompt) }
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# 将会话交给人工交互;交互结束后会话按终端语义关闭。
|
|
159
|
+
def interact(input: $stdin, output: $stdout, escape: "\x1d".b, timeout: nil)
|
|
160
|
+
@session.interact(input: input, output: output, escape: escape, timeout: timeout)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inspect = "#<#{self.class} host=#{host.inspect} state=#{state}>"
|
|
164
|
+
|
|
165
|
+
protected
|
|
166
|
+
|
|
167
|
+
def pager_pattern = profile.pager_pattern
|
|
168
|
+
|
|
169
|
+
def pager_response = profile.pager_response
|
|
170
|
+
|
|
171
|
+
def password_prompt = profile.password_prompt
|
|
172
|
+
|
|
173
|
+
def username_prompt = profile.username_prompt
|
|
174
|
+
|
|
175
|
+
def authentication_error_patterns = profile.authentication_error_patterns
|
|
176
|
+
|
|
177
|
+
def command_error_patterns = profile.command_error_patterns
|
|
178
|
+
|
|
179
|
+
# 返回命令期间需要自动应答的确认对话。
|
|
180
|
+
def confirmation_interactions = profile.confirmation_interactions
|
|
181
|
+
|
|
182
|
+
# 返回登录期间需要自动应答的附加对话。
|
|
183
|
+
def login_interactions = profile.login_interactions
|
|
184
|
+
|
|
185
|
+
def legacy_ssh_arguments = profile.legacy_ssh_arguments
|
|
186
|
+
|
|
187
|
+
def enable_command = profile.privilege_command
|
|
188
|
+
|
|
189
|
+
def enable_prompt = profile.privilege_prompt
|
|
190
|
+
|
|
191
|
+
def default_command_timeout = profile.command_timeout
|
|
192
|
+
|
|
193
|
+
def terminal_size = profile.terminal_size
|
|
194
|
+
|
|
195
|
+
# 返回登录完成提示;厂商必须实现。
|
|
196
|
+
def login_prompt
|
|
197
|
+
prompt = profile.login_prompt
|
|
198
|
+
return prompt if prompt
|
|
199
|
+
|
|
200
|
+
raise NotImplementedError, "#{self.class} must define login_prompt"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 返回普通命令提示;厂商必须实现。
|
|
204
|
+
def command_prompt
|
|
205
|
+
prompt = profile.command_prompt
|
|
206
|
+
return prompt if prompt
|
|
207
|
+
|
|
208
|
+
raise NotImplementedError, "#{self.class} must define command_prompt"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# 登录成功后执行厂商初始化钩子。
|
|
212
|
+
def after_login(_session, _response) end
|
|
213
|
+
|
|
214
|
+
# 脚本开始前执行厂商批处理准备钩子。
|
|
215
|
+
def before_batch(execution)
|
|
216
|
+
return unless enable_command && execution.context.fetch(:privilege, true)
|
|
217
|
+
|
|
218
|
+
execution.enable(enable_command, enable_prompt)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# 调整单条命令或返回 nil 跳过该命令。
|
|
222
|
+
def prepare_command(command, _execution) = command
|
|
223
|
+
|
|
224
|
+
# 单条命令完成后执行厂商后处理钩子。
|
|
225
|
+
def after_command(_command, _response, _execution) end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# 将厂商提示、失败模式和对话钩子组装成不可变对话语法。
|
|
230
|
+
def build_dialogue
|
|
231
|
+
Dialogue.new(
|
|
232
|
+
login_prompt: login_prompt, command_prompt: command_prompt,
|
|
233
|
+
password_prompt: password_prompt, username_prompt: username_prompt, enable_prompt: enable_prompt,
|
|
234
|
+
authentication_errors: authentication_error_patterns, command_errors: command_error_patterns,
|
|
235
|
+
login_interactions: login_interactions,
|
|
236
|
+
command_interactions: [Interaction.new(pager_pattern, pager_response, capture: false),
|
|
237
|
+
*confirmation_interactions]
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# 在会话锁内运行脚本及业务回调,保留已完成步骤和统一错误边界。
|
|
242
|
+
def perform_script(script, operation: nil, prompt: nil, after_command: nil, privilege: true,
|
|
243
|
+
finalize: nil, &on_step)
|
|
244
|
+
return Result.new if script.empty?
|
|
245
|
+
|
|
246
|
+
execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
|
|
247
|
+
output_sensitive = script.any?(&:output_sensitive?)
|
|
248
|
+
@session.perform(:script) do
|
|
249
|
+
@session.log_script(operation: operation, steps: execution.steps) do
|
|
250
|
+
@session.with_sensitive_output(output_sensitive) { before_batch(execution) }
|
|
251
|
+
result = execution.execute_script(script, &on_step)
|
|
252
|
+
sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
|
|
253
|
+
@session.with_sensitive_output(sensitive_result) do
|
|
254
|
+
finalize_script_result(result, finalize)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
rescue Error => error
|
|
259
|
+
Result.new(steps: execution ? execution.steps : [], error: error)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def build_execution(operation:, prompt:, after_command:, privilege:)
|
|
263
|
+
finish_step = lambda do |command, response, context|
|
|
264
|
+
self.after_command(command, response, context)
|
|
265
|
+
after_command&.call(command, response, context)
|
|
266
|
+
end
|
|
267
|
+
execution = Execution.new(session: @session, timeout: command_timeout,
|
|
268
|
+
prepare: method(:prepare_command), after_command: finish_step, prompt: prompt)
|
|
269
|
+
execution.context[:operation] = operation if operation
|
|
270
|
+
execution.context[:privilege] = privilege
|
|
271
|
+
execution
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# 回调也可直接返回失败;与抛错共用脱敏边界,保留已完成步骤及业务配置。
|
|
275
|
+
def finalize_script_result(result, finalize)
|
|
276
|
+
result = finalize ? finalize.call(result) : result
|
|
277
|
+
return result unless result.is_a?(Result) && result.failure?
|
|
278
|
+
|
|
279
|
+
Result.new(steps: result.steps, config: result.config,
|
|
280
|
+
error: @session.normalize_error(result.error, phase: :script))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "expect/pty"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 每台设备一份不可变对话语法,由厂商方法组装而不是合并默认值。
|
|
10
|
+
class Dialogue
|
|
11
|
+
attr_reader :login_prompt, :command_prompt, :password_prompt, :username_prompt, :enable_prompt,
|
|
12
|
+
:authentication_errors, :command_errors, :login_interactions, :command_interactions
|
|
13
|
+
|
|
14
|
+
# 校验提示和交互列表,并冻结整套设备对话语法。
|
|
15
|
+
def initialize(login_prompt:, command_prompt:, password_prompt:, username_prompt:, enable_prompt:,
|
|
16
|
+
authentication_errors:, command_errors:, login_interactions:, command_interactions:)
|
|
17
|
+
@login_prompt = login_prompt
|
|
18
|
+
@command_prompt = command_prompt
|
|
19
|
+
@password_prompt = password_prompt
|
|
20
|
+
@username_prompt = username_prompt
|
|
21
|
+
@enable_prompt = enable_prompt
|
|
22
|
+
[login_prompt, command_prompt, password_prompt, username_prompt, enable_prompt].each do |pattern|
|
|
23
|
+
raise ArgumentError, "prompt must be a nonempty Regexp" unless pattern.is_a?(Regexp) && !pattern.match?("")
|
|
24
|
+
end
|
|
25
|
+
@authentication_errors = authentication_errors.freeze
|
|
26
|
+
@command_errors = command_errors.freeze
|
|
27
|
+
@login_interactions = login_interactions.freeze
|
|
28
|
+
@command_interactions = command_interactions.freeze
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 从命令输出中返回包含错误模式的诊断行,而不是光标或此前全部输出。
|
|
33
|
+
def diagnostic(output)
|
|
34
|
+
return if command_errors.empty?
|
|
35
|
+
|
|
36
|
+
# 先保留被回车覆写的失败,再识别颜色或退格处理后的可见诊断。
|
|
37
|
+
raw = output.gsub(/\r(?!\n)/, "\n")
|
|
38
|
+
diagnostic_line(raw) || (diagnostic_line(TerminalRenderer.render(output)) if output.match?(/[\e\b]/))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# 只截取命中错误模式的当前行,避免把整段设备配置写进诊断信息。
|
|
44
|
+
def diagnostic_line(output)
|
|
45
|
+
match = command_errors.lazy.filter_map { |pattern| pattern.match(output) }.first
|
|
46
|
+
return unless match
|
|
47
|
+
|
|
48
|
+
start = match.begin(0).positive? ? output.rindex(/[\r\n]/, match.begin(0) - 1) : nil
|
|
49
|
+
start = start ? start + 1 : 0
|
|
50
|
+
finish = output.index(/[\r\n]/, match.end(0)) || output.bytesize
|
|
51
|
+
output.byteslice(start, finish - start).strip
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|