net-connector 0.4.2 → 0.6.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 +23 -1
- data/README.md +92 -20
- data/docs/VERIFICATION.md +17 -7
- data/docs/architecture.md +102 -64
- data/examples/netdisco_database.yml +16 -0
- data/lib/net/connector/device/base.rb +27 -107
- data/lib/net/connector/device/local_backup.rb +96 -0
- data/lib/net/connector/device/profile.rb +8 -3
- data/lib/net/connector/device/running_config/strategy.rb +1 -1
- data/lib/net/connector/device/running_config.rb +44 -5
- data/lib/net/connector/device/save_config.rb +21 -0
- data/lib/net/connector/device/tftp/file_upload.rb +53 -0
- data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
- data/lib/net/connector/device/tftp/strategy.rb +67 -0
- data/lib/net/connector/device/tftp/target.rb +63 -0
- data/lib/net/connector/device/tftp.rb +117 -0
- data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
- data/lib/net/connector/device/topology/interface_description.rb +46 -0
- data/lib/net/connector/device/topology/interface_name.rb +58 -0
- data/lib/net/connector/device/topology/strategy.rb +75 -0
- data/lib/net/connector/device/topology.rb +277 -0
- data/lib/net/connector/engine/authentication.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +21 -21
- data/lib/net/connector/engine/error_metadata.rb +50 -0
- data/lib/net/connector/engine/errors.rb +15 -1
- data/lib/net/connector/engine/execution.rb +17 -10
- data/lib/net/connector/engine/log/event.rb +66 -0
- data/lib/net/connector/engine/log/formatter.rb +16 -0
- data/lib/net/connector/engine/log/messages.rb +52 -0
- data/lib/net/connector/engine/log/stream.rb +56 -0
- data/lib/net/connector/engine/log.rb +105 -84
- data/lib/net/connector/engine/session.rb +83 -42
- data/lib/net/connector/engine/terminal_text.rb +33 -0
- data/lib/net/connector/engine/transport.rb +2 -2
- data/lib/net/connector/netdisco/batch.rb +9 -45
- data/lib/net/connector/netdisco/cli.rb +17 -10
- data/lib/net/connector/netdisco/client.rb +4 -3
- data/lib/net/connector/netdisco/config_file.rb +6 -3
- data/lib/net/connector/netdisco/database_client.rb +197 -0
- data/lib/net/connector/netdisco/device.rb +4 -5
- data/lib/net/connector/netdisco/diagnostic.rb +9 -39
- data/lib/net/connector/netdisco/fleet.rb +26 -39
- data/lib/net/connector/netdisco/report.rb +37 -16
- data/lib/net/connector/netdisco/result_store.rb +2 -2
- data/lib/net/connector/netdisco/settings.rb +26 -9
- data/lib/net/connector/netdisco.rb +1 -0
- data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
- data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
- data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
- data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
- data/lib/net/connector/storage.rb +15 -0
- data/lib/net/connector/textfsm.rb +72 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
- data/lib/net/connector/vendor/h3c/topology.rb +5 -5
- data/lib/net/connector/vendor/h3c.rb +3 -3
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
- data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/topology.rb +2 -2
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +1 -6
- metadata +47 -39
- data/lib/net/connector/device/interface_description.rb +0 -44
- data/lib/net/connector/device/interface_name.rb +0 -56
- data/lib/net/connector/engine/base.rb +0 -4
- data/lib/net/connector/engine/log_messages.rb +0 -47
- data/lib/net/connector/engine/profile.rb +0 -4
- data/lib/net/connector/engine.rb +0 -4
- data/lib/net/connector/operations/local_backup.rb +0 -89
- data/lib/net/connector/operations/parse_output.rb +0 -77
- data/lib/net/connector/operations/running_config/cisco.rb +0 -14
- data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
- data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
- data/lib/net/connector/operations/running_config/strategy.rb +0 -3
- data/lib/net/connector/operations/running_config.rb +0 -16
- data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
- data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
- data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
- data/lib/net/connector/operations/tftp/h3c.rb +0 -11
- data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
- data/lib/net/connector/operations/tftp/huawei.rb +0 -11
- data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
- data/lib/net/connector/operations/tftp/radware.rb +0 -11
- data/lib/net/connector/operations/tftp/strategy.rb +0 -75
- data/lib/net/connector/operations/tftp_backup.rb +0 -183
- data/lib/net/connector/operations/topology/cisco.rb +0 -11
- data/lib/net/connector/operations/topology/h3c.rb +0 -11
- data/lib/net/connector/operations/topology/hillstone.rb +0 -11
- data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
- data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
- data/lib/net/connector/operations/topology/radware.rb +0 -11
- data/lib/net/connector/operations/topology/strategy.rb +0 -87
- data/lib/net/connector/operations/topology.rb +0 -261
- data/lib/net/connector/operations.rb +0 -26
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "interface_name"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Topology
|
|
8
|
+
# 描述文本与常见接口视图命令的纯构造方法,不连接或修改设备。
|
|
9
|
+
module InterfaceDescription
|
|
10
|
+
# 将邻居名称和接口拼成默认描述;只缩写邻居接口,保留原始大小写。
|
|
11
|
+
def self.format(neighbor, abbreviate: true, lowercase: false)
|
|
12
|
+
port = if abbreviate
|
|
13
|
+
InterfaceName.short(neighbor.neighbor_interface, lowercase: lowercase)
|
|
14
|
+
else
|
|
15
|
+
lowercase ? neighbor.neighbor_interface.downcase : neighbor.neighbor_interface
|
|
16
|
+
end
|
|
17
|
+
validate!("To #{neighbor.neighbor_name} #{port}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 生成进入接口、设置描述和退出视图的命令,不执行设备操作。
|
|
21
|
+
def self.commands(interface:, description:, leave: "exit")
|
|
22
|
+
validate_interface!(interface)
|
|
23
|
+
validate!(description)
|
|
24
|
+
raise ArgumentError, "interface exit must be exit or quit" unless %w[exit quit].include?(leave)
|
|
25
|
+
|
|
26
|
+
["interface #{interface}", "description #{description}", leave]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 共享的输入约束同样适用于 PAN-OS 的独立命令语法。
|
|
30
|
+
def self.validate!(value)
|
|
31
|
+
return value if value.is_a?(String) && value.bytesize.between?(1, 80) &&
|
|
32
|
+
value.match?(/\A[\p{Alnum}_.: \/-]+\z/u)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "description must be 1-80 bytes of plain interface text"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 拒绝可能把描述命令拆成多条命令的不安全接口名。
|
|
38
|
+
def self.validate_interface!(value)
|
|
39
|
+
return value if value.is_a?(String) && value.match?(/\A[A-Za-z][A-Za-z0-9.\/-]*\z/)
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "neighbor interface name is unsafe"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
class Topology
|
|
6
|
+
# 接口身份匹配与描述展示共用名称规则;展示简称不能直接用作下发命令。
|
|
7
|
+
module InterfaceName
|
|
8
|
+
RULES = [
|
|
9
|
+
[/\A(?:xge|ten-gigabitethernet|tengigabitethernet|te)(?=\d)/i, "tengigabitethernet", "Te"],
|
|
10
|
+
[/\A(?:gigabitethernet|ge|gi)(?=\d)/i, "gigabitethernet", "Gi"],
|
|
11
|
+
[/\A(?:fastethernet|fa)(?=\d)/i, "fastethernet", "Fa"],
|
|
12
|
+
[/\A(?:ethernet|eth)(?=\d)/i, "ethernet", "Eth"],
|
|
13
|
+
[/\A(?:port-channel|po)(?=\d)/i, "port-channel", "Po"]
|
|
14
|
+
].map(&:freeze).freeze
|
|
15
|
+
|
|
16
|
+
# 用于本机邻居表与配置的关联;保留已有证据键的拼写契约。
|
|
17
|
+
def self.key(name)
|
|
18
|
+
rule = RULES.find { |pattern, _key, _short| pattern.match?(name) }
|
|
19
|
+
rule ? name.sub(rule.first, rule[1]).downcase : name.downcase
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 仅缩短已识别的前缀,保留端口、子接口编号及未知命名。
|
|
23
|
+
# 默认延续输入的大写、小写或首字母大写形式;统一小写由调用方选择。
|
|
24
|
+
def self.short(name, lowercase: false)
|
|
25
|
+
rule = RULES.find { |pattern, _key, _short| pattern.match?(name) }
|
|
26
|
+
formatted = if rule
|
|
27
|
+
name.sub(rule.first) do |prefix|
|
|
28
|
+
abbreviation = rule.last
|
|
29
|
+
if prefix == prefix.downcase
|
|
30
|
+
abbreviation.downcase
|
|
31
|
+
elsif prefix == prefix.upcase
|
|
32
|
+
abbreviation.upcase
|
|
33
|
+
elsif prefix.casecmp?(abbreviation)
|
|
34
|
+
prefix
|
|
35
|
+
else
|
|
36
|
+
abbreviation
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
name.dup
|
|
41
|
+
end
|
|
42
|
+
lowercase ? formatted.downcase : formatted
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 保留现有配置视图展开规则;厂商策略可覆盖不适用的名称。
|
|
46
|
+
def self.configuration(name)
|
|
47
|
+
name.sub(/\AXGE(?=\d)/i, "Ten-GigabitEthernet")
|
|
48
|
+
.sub(/\AGE(?=\d)/i, "GigabitEthernet")
|
|
49
|
+
.sub(/\AGi(?=\d)/i, "GigabitEthernet")
|
|
50
|
+
.sub(/\ATe(?=\d)/i, "TenGigabitEthernet")
|
|
51
|
+
.sub(/\AFa(?=\d)/i, "FastEthernet")
|
|
52
|
+
.sub(/\AEth(?=\d)/i, "Ethernet")
|
|
53
|
+
.sub(/\APo(?=\d)/i, "port-channel")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "interface_description"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class Topology
|
|
9
|
+
# 厂商拓扑规则;默认不提供任何设备能力。
|
|
10
|
+
class Strategy
|
|
11
|
+
# 保存设备供厂商规则查询档案和会话状态。
|
|
12
|
+
def initialize(device)
|
|
13
|
+
@device = device
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 声明策略类支持的拓扑能力,默认全部关闭。
|
|
17
|
+
def self.supports?(_capability) = false
|
|
18
|
+
|
|
19
|
+
# 将实例级能力查询交给策略类声明。
|
|
20
|
+
def supports?(capability) = self.class.supports?(capability)
|
|
21
|
+
|
|
22
|
+
# 返回读取邻居的命令,未定义时不支持发现。
|
|
23
|
+
def neighbor_command = nil
|
|
24
|
+
|
|
25
|
+
# 根据原始输出选择邻居解析模板。
|
|
26
|
+
def neighbor_template(_output) = nil
|
|
27
|
+
|
|
28
|
+
# 返回当前接口描述所用的解析模板。
|
|
29
|
+
def description_template = nil
|
|
30
|
+
|
|
31
|
+
# 返回输出中应解析出的邻居数量,供完整性校验使用。
|
|
32
|
+
def expected_neighbor_count(_output, _template) = 0
|
|
33
|
+
|
|
34
|
+
# 只有明确的空表证据才允许返回空邻居列表。
|
|
35
|
+
def empty_neighbor_output?(_output) = false
|
|
36
|
+
|
|
37
|
+
# 返回进入设备配置视图的命令。
|
|
38
|
+
def enter_configuration_command = nil
|
|
39
|
+
|
|
40
|
+
# 只读策略不提供变更阶段;可写策略必须分别声明退出、验证和保存命令。
|
|
41
|
+
def leave_configuration_commands = nil
|
|
42
|
+
def verification_commands = nil
|
|
43
|
+
def persistence_commands = nil
|
|
44
|
+
def persistence_confirmed?(_result) = false
|
|
45
|
+
def change_error_code = :description_unsupported
|
|
46
|
+
|
|
47
|
+
# 只读策略可以没有完整性扩展;立即生效的接口块模型会进一步校验解析数量。
|
|
48
|
+
def validate_descriptions!(_config, _rows) end
|
|
49
|
+
|
|
50
|
+
# 为一条接口描述变更生成厂商命令。
|
|
51
|
+
def change_commands(_change) = []
|
|
52
|
+
|
|
53
|
+
# 标明邻居发现使用的协议。
|
|
54
|
+
def protocol = :lldp
|
|
55
|
+
|
|
56
|
+
# 在下发前允许厂商调整命令对象及超时。
|
|
57
|
+
def script_command(command) = command
|
|
58
|
+
|
|
59
|
+
# 将发现接口名归一为查找当前配置所用的键。
|
|
60
|
+
def interface_key(name) = InterfaceName.key(name)
|
|
61
|
+
|
|
62
|
+
# 将发现协议常用的接口简称展开为配置视图可识别的名称。
|
|
63
|
+
def configuration_interface(name) = InterfaceName.configuration(name)
|
|
64
|
+
|
|
65
|
+
# 还原 PAN-OS set 配置或 Alteon dump 中的引号描述。
|
|
66
|
+
def decode_description(value)
|
|
67
|
+
value = value.strip
|
|
68
|
+
value.start_with?('"') ? Shellwords.split(value).join(" ") : value
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "topology/strategy"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
# 从 CDP/LLDP 回显生成链路邻居,并规划接口描述变更。
|
|
8
|
+
class Topology
|
|
9
|
+
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
10
|
+
module Capability
|
|
11
|
+
# 执行 CDP 或 LLDP 查询并返回统一的链路邻居记录。
|
|
12
|
+
def neighbors = Topology.new(self).neighbors
|
|
13
|
+
|
|
14
|
+
# 读取运行配置中的接口描述或端口名称。
|
|
15
|
+
def interface_descriptions = Topology.new(self).interface_descriptions
|
|
16
|
+
|
|
17
|
+
# 以邻居和现有配置为证据,生成待确认的接口描述变更计划。
|
|
18
|
+
def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
|
|
19
|
+
Topology.new(self).plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 明确确认且现场证据未变化时执行接口描述计划。
|
|
23
|
+
def apply_interface_descriptions(plan, confirmed: false)
|
|
24
|
+
Topology.new(self).apply_interface_descriptions(plan, confirmed: confirmed)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol)
|
|
29
|
+
Change = Data.define(:interface, :old_description, :new_description, :neighbor)
|
|
30
|
+
Plan = Data.define(:host, :vendor, :evidence, :changes, :commands)
|
|
31
|
+
|
|
32
|
+
# 保存设备及其独立的 TextFSM 解析入口。
|
|
33
|
+
def initialize(device, template_dir: nil)
|
|
34
|
+
require_relative "../textfsm"
|
|
35
|
+
|
|
36
|
+
@device = device
|
|
37
|
+
@parser = TextFSM.new(template_dir: template_dir)
|
|
38
|
+
@strategy = (device.profile.topology_strategy || Strategy).new(device)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 执行厂商 CDP/LLDP 命令,返回统一的邻居记录。
|
|
42
|
+
def neighbors
|
|
43
|
+
command = @strategy.neighbor_command
|
|
44
|
+
unless @strategy.supports?(:neighbors) && command
|
|
45
|
+
raise UnsupportedOperation.new("this device cannot discover LLDP neighbors",
|
|
46
|
+
code: :neighbor_discovery_unsupported, host: @device.host, phase: :discover)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
output = TerminalText.utf8(@device.execute_command(command).value!, host: @device.host)
|
|
50
|
+
template = @strategy.neighbor_template(output)
|
|
51
|
+
rows = @parser.call(output, template: template, vendor: @device.vendor, command: command, host: @device.host)
|
|
52
|
+
if rows.size != @strategy.expected_neighbor_count(output, template) || (rows.empty? && !@strategy.empty_neighbor_output?(output))
|
|
53
|
+
raise ParsingError.new("neighbor output was not recognized", code: :unrecognized_output,
|
|
54
|
+
host: @device.host, phase: :discover)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
rows.map do |row|
|
|
58
|
+
Neighbor.new(local_interface: row.fetch("LOCAL_INTERFACE").strip.freeze,
|
|
59
|
+
neighbor_name: row.fetch("NEIGHBOR_NAME", "").strip.freeze,
|
|
60
|
+
neighbor_interface: row.fetch("NEIGHBOR_INTERFACE").strip.freeze,
|
|
61
|
+
chassis_id: row.fetch("CHASSIS_ID", "").strip.freeze,
|
|
62
|
+
protocol: @strategy.protocol).freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 从运行配置读取已设置的接口描述或端口名称。
|
|
67
|
+
def interface_descriptions = read_interface_descriptions
|
|
68
|
+
|
|
69
|
+
# 内部读取先交付执行步骤,再处理解析;解析失败也不能抹去已经完成的采集命令。
|
|
70
|
+
def read_interface_descriptions
|
|
71
|
+
template = @strategy.description_template
|
|
72
|
+
unless @strategy.supports?(:interface_descriptions) && template
|
|
73
|
+
raise UnsupportedOperation.new("interface descriptions are unsupported for this vendor",
|
|
74
|
+
code: :description_unsupported, host: @device.host, phase: :discover)
|
|
75
|
+
end
|
|
76
|
+
result = @device.running_config
|
|
77
|
+
yield result if block_given?
|
|
78
|
+
config = result.value!
|
|
79
|
+
rows = @parser.call(config, template: template, host: @device.host)
|
|
80
|
+
@strategy.validate_descriptions!(config, rows)
|
|
81
|
+
rows.each_with_object({}) do |row, found|
|
|
82
|
+
interface = @strategy.interface_key(row.fetch("INTERFACE"))
|
|
83
|
+
description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
|
|
84
|
+
previous = found[interface]
|
|
85
|
+
if previous && !previous.empty? && !description.empty? && previous != description
|
|
86
|
+
raise ParsingError.new("interface has conflicting descriptions", code: :ambiguous_description,
|
|
87
|
+
host: @device.host, phase: :parse)
|
|
88
|
+
end
|
|
89
|
+
found[interface] = description unless description.empty? && previous
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private :read_interface_descriptions
|
|
94
|
+
|
|
95
|
+
# 比对邻居和现有描述,生成包含确切命令的只读变更计划。
|
|
96
|
+
def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
|
|
97
|
+
validate_change_support!(:plan) if @strategy.supports?(:neighbors)
|
|
98
|
+
discovered = neighbors
|
|
99
|
+
current = interface_descriptions
|
|
100
|
+
current.each_value(&:freeze)
|
|
101
|
+
evidence = evidence_for(discovered, current)
|
|
102
|
+
changes = discovered.filter_map do |neighbor|
|
|
103
|
+
old = current.fetch(@strategy.interface_key(neighbor.local_interface))
|
|
104
|
+
proposed = if formatter
|
|
105
|
+
formatter.call(neighbor)
|
|
106
|
+
else
|
|
107
|
+
InterfaceDescription.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
|
|
108
|
+
end
|
|
109
|
+
InterfaceDescription.validate!(proposed)
|
|
110
|
+
next if old == proposed
|
|
111
|
+
|
|
112
|
+
Change.new(interface: @strategy.configuration_interface(neighbor.local_interface).freeze, old_description: old.freeze,
|
|
113
|
+
new_description: proposed.freeze, neighbor: neighbor).freeze
|
|
114
|
+
end.freeze
|
|
115
|
+
commands = if changes.empty?
|
|
116
|
+
[]
|
|
117
|
+
else
|
|
118
|
+
stage_scripts(changes).values.flat_map { |script| script.map(&:text) }
|
|
119
|
+
end
|
|
120
|
+
commands.each(&:freeze)
|
|
121
|
+
Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
|
|
122
|
+
changes: changes, commands: commands.freeze).freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 只有明确确认且邻居和旧描述未变化时,才下发计划中的命令。
|
|
126
|
+
def apply_interface_descriptions(plan, confirmed: false)
|
|
127
|
+
unless confirmed == true
|
|
128
|
+
raise UnsupportedOperation.new("description plan requires confirmation", code: :confirmation_required,
|
|
129
|
+
host: @device.host, phase: :apply)
|
|
130
|
+
end
|
|
131
|
+
raise ArgumentError, "plan must be a topology plan for this device" unless plan.is_a?(Plan) &&
|
|
132
|
+
plan.host == @device.host &&
|
|
133
|
+
plan.vendor == @device.vendor
|
|
134
|
+
if plan.changes.empty?
|
|
135
|
+
raise ArgumentError, "empty topology plan cannot contain commands" unless plan.commands.empty?
|
|
136
|
+
|
|
137
|
+
return Result.new
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@strategy.supports?(:neighbors) ? validate_change_support!(:apply) : neighbors
|
|
141
|
+
@device.with_operation(:apply) { apply_plan(plan) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# 同一租约覆盖重验、修改、读回与保存;有完成步骤后发生的错误必须带回这些步骤。
|
|
147
|
+
def apply_plan(plan)
|
|
148
|
+
stages = stage_scripts(plan.changes)
|
|
149
|
+
unless stages.values.flat_map { |script| script.map(&:text) } == plan.commands
|
|
150
|
+
raise ArgumentError, "plan commands were modified or use an older execution sequence; regenerate the plan"
|
|
151
|
+
end
|
|
152
|
+
revalidate_plan!(plan)
|
|
153
|
+
steps = []
|
|
154
|
+
result = execute_stage(stages.fetch(:change), steps)
|
|
155
|
+
return Result.new(steps: steps, error: result.error) if result.failure?
|
|
156
|
+
|
|
157
|
+
verify_descriptions!(plan.changes, stages.fetch(:verify), steps)
|
|
158
|
+
persisting = true
|
|
159
|
+
result = execute_stage(stages.fetch(:persist), steps)
|
|
160
|
+
if result.failure? || !@strategy.persistence_confirmed?(result)
|
|
161
|
+
return Result.new(steps: steps, error: build_persistence_error(result.error))
|
|
162
|
+
end
|
|
163
|
+
Result.new(steps: steps)
|
|
164
|
+
rescue Error => error
|
|
165
|
+
raise unless steps
|
|
166
|
+
|
|
167
|
+
Result.new(steps: steps, error: persisting ? build_persistence_error(error) : error)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 读回命令与审批一致,并且每个目标描述均得到确认,才允许进入保存阶段。
|
|
171
|
+
def verify_descriptions!(changes, script, steps)
|
|
172
|
+
unless Script.new(@device.config_commands).map(&:text) == script.map(&:text)
|
|
173
|
+
raise DeviceError.new("configuration readback commands changed after approval",
|
|
174
|
+
code: :verification_plan_changed, host: @device.host, phase: :verify)
|
|
175
|
+
end
|
|
176
|
+
updated = read_interface_descriptions do |readback|
|
|
177
|
+
steps.concat(readback.steps)
|
|
178
|
+
if readback.success? && readback.steps.map { |step| step.command.text } != script.map(&:text)
|
|
179
|
+
raise DeviceError.new("configuration readback did not execute the approved commands",
|
|
180
|
+
code: :verification_plan_changed, host: @device.host, phase: :verify)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
unless changes.all? { |change| updated[@strategy.interface_key(change.interface)] == change.new_description }
|
|
184
|
+
raise DeviceError.new("interface descriptions were not confirmed by readback",
|
|
185
|
+
code: :description_unconfirmed, host: @device.host, phase: :verify)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
|
|
190
|
+
def revalidate_plan!(plan)
|
|
191
|
+
discovered = neighbors
|
|
192
|
+
evidence = evidence_for(discovered, interface_descriptions)
|
|
193
|
+
current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
|
|
194
|
+
same_neighbors = plan.changes.all? do |change|
|
|
195
|
+
current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
|
|
196
|
+
end
|
|
197
|
+
unless evidence == plan.evidence && same_neighbors
|
|
198
|
+
raise DeviceError.new("neighbors or interface descriptions changed after planning",
|
|
199
|
+
code: :stale_plan, host: @device.host, phase: :apply)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
plan.changes.each do |change|
|
|
203
|
+
InterfaceDescription.validate!(change.new_description)
|
|
204
|
+
unless evidence.fetch(@strategy.interface_key(change.interface)) ==
|
|
205
|
+
[change.neighbor.neighbor_name, change.neighbor.neighbor_interface, change.old_description]
|
|
206
|
+
raise ArgumentError, "plan changes do not match its discovery evidence"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# 审批列表保留真正执行的读回命令,使旧版“先保存后读回”计划无法静默复用。
|
|
212
|
+
def stage_scripts(changes)
|
|
213
|
+
phases = %i[leave_configuration_commands verification_commands persistence_commands].map do |method|
|
|
214
|
+
@strategy.public_send(method)
|
|
215
|
+
end
|
|
216
|
+
unless phases.all? { |commands| commands.is_a?(Array) && !commands.empty? }
|
|
217
|
+
raise UnsupportedOperation.new("topology strategy must declare separate readback and persistence stages",
|
|
218
|
+
code: :description_stages_unsupported, host: @device.host, phase: :plan)
|
|
219
|
+
end
|
|
220
|
+
leave, verify, persist = phases
|
|
221
|
+
sequences = {
|
|
222
|
+
change: [@strategy.enter_configuration_command] + changes.flat_map { |change| change_commands(change) } + leave,
|
|
223
|
+
verify: verify, persist: persist
|
|
224
|
+
}
|
|
225
|
+
sequences.transform_values do |commands|
|
|
226
|
+
Script.new(commands.map { |command| @strategy.script_command(command) }, name: "interface descriptions")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def execute_stage(script, steps)
|
|
231
|
+
result = @device.execute_script(script)
|
|
232
|
+
steps.concat(result.steps)
|
|
233
|
+
result
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# 保存可能已执行,超时、失败或缺少完成行均不自动重放;诊断不附带回显正文。
|
|
237
|
+
def build_persistence_error(error = nil)
|
|
238
|
+
underlying = UnderlyingError.new(error, nil, sensitive: true) if error
|
|
239
|
+
DeviceError.new("interface descriptions were verified; persistence was not confirmed",
|
|
240
|
+
code: :persistence_unconfirmed, host: @device.host, phase: :persist, underlying: underlying)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
|
|
244
|
+
def validate_change_support!(phase)
|
|
245
|
+
return if @strategy.supports?(:interface_description_changes)
|
|
246
|
+
|
|
247
|
+
raise UnsupportedOperation.new("automatic interface description changes are unavailable; use a verified manual workflow",
|
|
248
|
+
code: @strategy.change_error_code, host: @device.host, phase: phase)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
|
|
252
|
+
def evidence_for(discovered, current)
|
|
253
|
+
interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
|
|
254
|
+
if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
|
|
255
|
+
peers.first.neighbor_interface.empty? }
|
|
256
|
+
raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
|
|
257
|
+
host: @device.host, phase: :plan)
|
|
258
|
+
end
|
|
259
|
+
unless interfaces.keys.all? { |interface| current.key?(interface) }
|
|
260
|
+
raise ParsingError.new("neighbor interface was not found in running configuration",
|
|
261
|
+
code: :interface_missing, host: @device.host, phase: :plan)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
interfaces.transform_values do |peers|
|
|
265
|
+
peer = peers.fetch(0)
|
|
266
|
+
[peer.neighbor_name, peer.neighbor_interface, current.fetch(@strategy.interface_key(peer.local_interface))].freeze
|
|
267
|
+
end.freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# 设备标识来自解析证据,仍需在生成命令前验证其安全性。
|
|
271
|
+
def change_commands(change)
|
|
272
|
+
InterfaceDescription.validate_interface!(change.interface)
|
|
273
|
+
@strategy.change_commands(change)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -61,8 +61,8 @@ module Net
|
|
|
61
61
|
def login_interactions
|
|
62
62
|
host_key = Interaction.new(SSH_CONFIRMATION, lambda { |_prompt|
|
|
63
63
|
if @config.host_key_policy == :strict
|
|
64
|
-
raise @session.
|
|
65
|
-
|
|
64
|
+
raise @session.build_error(ConnectionError, "host key confirmation rejected",
|
|
65
|
+
phase: :login, code: :host_key_untrusted), cause: nil
|
|
66
66
|
end
|
|
67
67
|
"yes\n"
|
|
68
68
|
}, limit: 1)
|
|
@@ -37,7 +37,7 @@ module Net
|
|
|
37
37
|
@log_file = absolute_path(log_file)
|
|
38
38
|
if logger && (!logger.respond_to?(:debug) || !logger.respond_to?(:info) ||
|
|
39
39
|
!logger.respond_to?(:warn) || !logger.respond_to?(:error) ||
|
|
40
|
-
!logger.respond_to?(:
|
|
40
|
+
!logger.respond_to?(:level))
|
|
41
41
|
raise ArgumentError, "logger must be Logger-compatible"
|
|
42
42
|
end
|
|
43
43
|
raise ArgumentError, "logger and log_file cannot be combined" if logger && @log_file
|
|
@@ -134,7 +134,7 @@ module Net
|
|
|
134
134
|
raw << event.before unless event.before.empty?
|
|
135
135
|
output << event.before unless event.before.empty?
|
|
136
136
|
bytes += event.before.bytesize + event.match.bytesize
|
|
137
|
-
validate_event(event, bytes, raw, phase, command)
|
|
137
|
+
validate_event!(event, bytes, raw, phase, command)
|
|
138
138
|
|
|
139
139
|
if event.index == patterns.size
|
|
140
140
|
raw << event.match
|
|
@@ -142,7 +142,7 @@ module Net
|
|
|
142
142
|
elsif event.index == patterns.size - 1
|
|
143
143
|
return finish_response(event.match, raw, output, phase, command)
|
|
144
144
|
elsif event.index < failures.size
|
|
145
|
-
raise
|
|
145
|
+
raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
|
|
146
146
|
else
|
|
147
147
|
reply = interactions.fetch(event.index - failures.size)
|
|
148
148
|
raw << event.match
|
|
@@ -151,7 +151,7 @@ module Net
|
|
|
151
151
|
end
|
|
152
152
|
next if Expect.monotonic < deadline
|
|
153
153
|
|
|
154
|
-
raise
|
|
154
|
+
raise build_read_error(nil, phase, command, raw.join), cause: nil
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -163,25 +163,25 @@ module Net
|
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
# 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
|
|
166
|
-
def
|
|
166
|
+
def build_connection_error(failure, phase, command)
|
|
167
167
|
klass, code = failure
|
|
168
|
-
@session.
|
|
168
|
+
@session.build_error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
# 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
|
|
172
|
-
def validate_event(event, bytes, raw, phase, command)
|
|
172
|
+
def validate_event!(event, bytes, raw, phase, command)
|
|
173
173
|
if bytes > @session.configuration.max_output_bytes
|
|
174
|
-
raise @session.
|
|
175
|
-
|
|
174
|
+
raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
|
|
175
|
+
phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
|
|
176
176
|
end
|
|
177
|
-
raise
|
|
177
|
+
raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
# 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
|
|
181
181
|
def finish_response(prompt, raw, output, phase, command)
|
|
182
182
|
if prompt.empty?
|
|
183
|
-
raise @session.
|
|
184
|
-
|
|
183
|
+
raise @session.build_error(PromptError, "prompt pattern did not consume any output",
|
|
184
|
+
phase: phase, command: command, output: raw.join), cause: nil
|
|
185
185
|
end
|
|
186
186
|
|
|
187
187
|
raw << prompt
|
|
@@ -194,11 +194,11 @@ module Net
|
|
|
194
194
|
def respond(reply, prompt, counts, deadline, phase, command)
|
|
195
195
|
counts[reply] += 1
|
|
196
196
|
if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
|
|
197
|
-
raise
|
|
197
|
+
raise build_interaction_error("response rejected or repeated", phase, command), cause: nil
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
value = interaction_response(reply, prompt, phase, command)
|
|
201
|
-
raise
|
|
201
|
+
raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
|
|
202
202
|
|
|
203
203
|
@session.redactor.remember(value.chomp) if reply.sensitive?
|
|
204
204
|
@session.write(value, deadline: deadline, phase: phase, command: command)
|
|
@@ -209,7 +209,7 @@ module Net
|
|
|
209
209
|
def interaction_response(reply, prompt, phase, command)
|
|
210
210
|
return reply.response(prompt) unless reply.sensitive?
|
|
211
211
|
|
|
212
|
-
@session.redactor.
|
|
212
|
+
@session.redactor.with_scope(reuse: true) do
|
|
213
213
|
@session.redactor.sensitive!
|
|
214
214
|
reply.response(prompt)
|
|
215
215
|
rescue => error
|
|
@@ -218,18 +218,18 @@ module Net
|
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
# 将交互拒绝或响应缺失转换为认证或脚本错误。
|
|
221
|
-
def
|
|
221
|
+
def build_interaction_error(message, phase, command)
|
|
222
222
|
authenticating = %i[login enable].include?(phase)
|
|
223
223
|
klass = authenticating ? AuthenticationError : ScriptError
|
|
224
224
|
label = authenticating ? "authentication" : "command interaction"
|
|
225
|
-
@session.
|
|
225
|
+
@session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
|
|
226
226
|
end
|
|
227
227
|
|
|
228
228
|
# 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
|
|
229
|
-
def
|
|
229
|
+
def build_read_error(event, phase, command, output)
|
|
230
230
|
if event&.error.is_a?(Exception)
|
|
231
|
-
return @session.
|
|
232
|
-
|
|
231
|
+
return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
|
|
232
|
+
underlying: event.error, output: output)
|
|
233
233
|
end
|
|
234
234
|
klass = if event&.error == :eof
|
|
235
235
|
ConnectionClosed
|
|
@@ -240,8 +240,8 @@ module Net
|
|
|
240
240
|
else
|
|
241
241
|
CommandTimeout
|
|
242
242
|
end
|
|
243
|
-
@session.
|
|
244
|
-
|
|
243
|
+
@session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
|
|
244
|
+
command: command, output: output)
|
|
245
245
|
end
|
|
246
246
|
end
|
|
247
247
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
# 日志与报告共用固定诊断词表;未知元数据可能来自配置正文,不能作为自由文本输出。
|
|
6
|
+
module ErrorMetadata
|
|
7
|
+
CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
|
|
8
|
+
PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
|
|
9
|
+
OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
|
|
10
|
+
BackupPersistenceError TftpCompletionError].freeze
|
|
11
|
+
TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
|
|
12
|
+
%w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
|
|
13
|
+
RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
|
|
14
|
+
Timeout::Error IO::TimeoutError Errno::EIO Errno::EACCES Errno::EPERM Errno::ENOSPC Errno::EROFS
|
|
15
|
+
Errno::ENOENT Errno::ENOTDIR Errno::EISDIR Errno::ELOOP Errno::EINVAL Errno::ENOSYS
|
|
16
|
+
Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
|
|
17
|
+
Net::Connector::Storage::PrivateFile::WriteError
|
|
18
|
+
Net::Connector::Storage::PrivateFile::PersistenceError
|
|
19
|
+
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported]).freeze
|
|
20
|
+
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
21
|
+
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
22
|
+
connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
|
|
23
|
+
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
24
|
+
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
25
|
+
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
26
|
+
template_invalid template_missing template_unreadable transfer_failed transfer_finalize_failed
|
|
27
|
+
transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
|
|
28
|
+
unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
|
|
29
|
+
backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
|
|
30
|
+
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported]).freeze
|
|
31
|
+
PHASES = %i[connect login enable command script read write close logging backup collect discover parse
|
|
32
|
+
plan apply verify persist save interact tftp_backup report callback].freeze
|
|
33
|
+
private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def code(value) = CODES.include?(value) ? value : nil
|
|
38
|
+
|
|
39
|
+
def phase(value) = PHASES.include?(value) ? value : nil
|
|
40
|
+
|
|
41
|
+
def known_type?(name) = name.instance_of?(String) && TYPES.include?(name)
|
|
42
|
+
|
|
43
|
+
def type(name)
|
|
44
|
+
return if name.nil?
|
|
45
|
+
|
|
46
|
+
known_type?(name) ? name.dup.freeze : "StandardError"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|