net-connector 0.4.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 +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- metadata +517 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
module Operations
|
|
6
|
+
module Tftp
|
|
7
|
+
autoload :H3c, File.expand_path("h3c", __dir__)
|
|
8
|
+
autoload :CiscoIos, File.expand_path("cisco_ios", __dir__)
|
|
9
|
+
autoload :CiscoNxos, File.expand_path("cisco_nxos", __dir__)
|
|
10
|
+
autoload :Huawei, File.expand_path("huawei", __dir__)
|
|
11
|
+
autoload :Hillstone, File.expand_path("hillstone", __dir__)
|
|
12
|
+
autoload :Radware, File.expand_path("radware", __dir__)
|
|
13
|
+
autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
|
|
14
|
+
|
|
15
|
+
class Strategy
|
|
16
|
+
# 保存 TFTP 厂商策略使用的设备对象。
|
|
17
|
+
def initialize(device)
|
|
18
|
+
@device = device
|
|
19
|
+
@responses = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 返回调用方指定的配置源文件。
|
|
23
|
+
def source_file(value) = value
|
|
24
|
+
|
|
25
|
+
# 根据设备地址生成默认远端文件名。
|
|
26
|
+
def default_path(_source_file) = TftpTarget.filename(@device.host, extension: "cfg")
|
|
27
|
+
|
|
28
|
+
# 返回目标中指定的远端文件名。
|
|
29
|
+
def remote_path(target, _result) = target.path
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
|
|
34
|
+
def interaction(pattern, response)
|
|
35
|
+
@responses << response.chomp
|
|
36
|
+
Interaction.new(pattern, response, capture: false)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
|
|
40
|
+
def completion_lines(result)
|
|
41
|
+
step = result.steps.last
|
|
42
|
+
return [] unless step
|
|
43
|
+
|
|
44
|
+
output = TerminalRenderer.render(step.output)
|
|
45
|
+
prompt = TerminalRenderer.render(step.prompt.to_s)
|
|
46
|
+
output = output.delete_suffix(prompt) unless prompt.empty?
|
|
47
|
+
echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
|
|
48
|
+
output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
|
|
52
|
+
def completed_progress?(line)
|
|
53
|
+
line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# H3C 和华为的上传完成消息使用相同的文本及进度格式。
|
|
57
|
+
def completed_upload?(line)
|
|
58
|
+
line.match?(/\A
|
|
59
|
+
(?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
|
|
60
|
+
(?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
|
|
61
|
+
\z/ix) ||
|
|
62
|
+
line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
|
|
63
|
+
completed_progress?(line)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cisco 明确报告复制完成的字节数,可附带耗时和速率。
|
|
67
|
+
def copied_bytes?(line)
|
|
68
|
+
line.match?(/\A
|
|
69
|
+
[1-9]\d*\s+bytes?\s+copied
|
|
70
|
+
(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
|
|
71
|
+
\z/ix)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "tftp/strategy"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
# 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
|
|
9
|
+
class TftpTarget
|
|
10
|
+
MAX_PATH_BYTES = 220
|
|
11
|
+
|
|
12
|
+
attr_reader :host, :path
|
|
13
|
+
|
|
14
|
+
# 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
|
|
15
|
+
def initialize(host:, path:, explicit_path: true)
|
|
16
|
+
unless host.is_a?(String) && host.bytesize <= 253 &&
|
|
17
|
+
host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
|
|
18
|
+
raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
|
|
19
|
+
end
|
|
20
|
+
self.class.validate_path!(path)
|
|
21
|
+
@host, @path = host.dup.freeze, path.dup.freeze
|
|
22
|
+
@explicit_path = explicit_path
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 判断调用方是否明确指定了远端文件名。
|
|
27
|
+
def explicit_path? = @explicit_path
|
|
28
|
+
|
|
29
|
+
# 校验设备或调用方给出的远端相对路径。
|
|
30
|
+
def self.validate_path!(path)
|
|
31
|
+
unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
|
|
32
|
+
path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
|
|
33
|
+
path.split("/").none? { |part| %w[. ..].include?(part) }
|
|
34
|
+
raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
|
|
35
|
+
end
|
|
36
|
+
path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
|
|
40
|
+
def self.filename(host, extension:, label: nil)
|
|
41
|
+
address = host.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
42
|
+
suffix = "#{address}.#{extension}"
|
|
43
|
+
if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
|
|
44
|
+
suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
|
|
45
|
+
end
|
|
46
|
+
path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
|
|
47
|
+
validate_path!(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 校验设备源文件路径是否符合命令安全约束。
|
|
51
|
+
def self.validate_source_file!(value)
|
|
52
|
+
unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
|
|
53
|
+
!value.split("/").include?("..")
|
|
54
|
+
raise ArgumentError, "source_file must be a device file path without spaces or traversal"
|
|
55
|
+
end
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
TftpBackup = Data.define(:server, :path, :completed_at)
|
|
61
|
+
|
|
62
|
+
module Operations
|
|
63
|
+
class TftpBackup
|
|
64
|
+
# 保存执行 TFTP 导出的设备对象。
|
|
65
|
+
def initialize(device) = @device = device
|
|
66
|
+
|
|
67
|
+
# 执行设备原生 TFTP 导出并核对完成回显。
|
|
68
|
+
def call(host:, path: nil, source_file: nil, vrf: nil)
|
|
69
|
+
strategy_class = @device.profile.tftp_strategy
|
|
70
|
+
unless strategy_class
|
|
71
|
+
raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
|
|
72
|
+
host: @device.host, phase: :tftp_backup)
|
|
73
|
+
end
|
|
74
|
+
strategy = strategy_class.new(@device)
|
|
75
|
+
if vrf && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
|
|
76
|
+
raise ArgumentError, "vrf must be a single safe name"
|
|
77
|
+
end
|
|
78
|
+
# H3C 在解析源文件时可能连接设备,因此先验证调用方提供的目标。
|
|
79
|
+
target = TftpTarget.new(host: host, path: path || "preflight.cfg", explicit_path: !path.nil?)
|
|
80
|
+
source_file = strategy.source_file(source_file)
|
|
81
|
+
target = TftpTarget.new(host: host, path: strategy.default_path(source_file), explicit_path: false) unless path
|
|
82
|
+
result = @device.execute_operation(strategy.script(target, source_file: source_file, vrf: vrf),
|
|
83
|
+
name: :tftp_backup, privilege: false)
|
|
84
|
+
result.value!
|
|
85
|
+
if transfer_failed?(result)
|
|
86
|
+
@device.record_event("tftp_backup", level: :error, status: "transfer_failed", server: target.host)
|
|
87
|
+
raise DeviceError.new("device reported TFTP backup failure",
|
|
88
|
+
code: :transfer_failed, host: @device.host, phase: :tftp_backup)
|
|
89
|
+
end
|
|
90
|
+
unless strategy.complete?(result)
|
|
91
|
+
@device.record_event("tftp_backup", level: :error, status: "transfer_unconfirmed", server: target.host)
|
|
92
|
+
raise DeviceError.new("device did not confirm TFTP backup completion",
|
|
93
|
+
code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
remote_path = strategy.remote_path(target, result)
|
|
97
|
+
@device.record_event("tftp_backup", status: "reported_uploaded", server: target.host, path: remote_path)
|
|
98
|
+
Net::Connector::TftpBackup.new(server: target.host, path: remote_path, completed_at: Time.now.utc)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# 识别设备回显中的传输失败信息。
|
|
104
|
+
def transfer_failed?(result)
|
|
105
|
+
result.steps.any? do |step|
|
|
106
|
+
# 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
|
|
107
|
+
pattern = %r{(?<![\w./-])
|
|
108
|
+
(?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
|
|
109
|
+
unable\ to|network\ is\ unreachable|can't\ open)
|
|
110
|
+
(?![\w/-]|\.[\w])}ix
|
|
111
|
+
# 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
|
|
112
|
+
history = step.output.gsub(/\r(?!\n)/, "\n")
|
|
113
|
+
step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
|
|
114
|
+
TerminalRenderer.render(step.output).match?(pattern)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "../../device/interface_description"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Operations
|
|
9
|
+
class Topology
|
|
10
|
+
autoload :Cisco, File.expand_path("cisco", __dir__)
|
|
11
|
+
autoload :H3c, File.expand_path("h3c", __dir__)
|
|
12
|
+
autoload :Hillstone, File.expand_path("hillstone", __dir__)
|
|
13
|
+
autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
|
|
14
|
+
autoload :Radware, File.expand_path("radware", __dir__)
|
|
15
|
+
|
|
16
|
+
# 厂商拓扑规则;默认不提供任何设备能力。
|
|
17
|
+
class Strategy
|
|
18
|
+
def initialize(device)
|
|
19
|
+
@device = device
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.supports?(_capability) = false
|
|
23
|
+
def supports?(capability) = self.class.supports?(capability)
|
|
24
|
+
|
|
25
|
+
def neighbor_command = nil
|
|
26
|
+
def neighbor_template(_output) = nil
|
|
27
|
+
def description_template = nil
|
|
28
|
+
def expected_neighbor_count(_output, _template) = 0
|
|
29
|
+
def empty_neighbor_output?(_output) = false
|
|
30
|
+
def enter_configuration = nil
|
|
31
|
+
def finish_commands = []
|
|
32
|
+
def change_commands(_change) = []
|
|
33
|
+
def protocol = :lldp
|
|
34
|
+
def script_command(command) = command
|
|
35
|
+
|
|
36
|
+
def interface_key(name) = InterfaceName.key(name)
|
|
37
|
+
|
|
38
|
+
# 将发现协议常用的接口简称展开为配置视图可识别的名称。
|
|
39
|
+
def configuration_interface(name) = InterfaceName.configuration(name)
|
|
40
|
+
|
|
41
|
+
# 还原 PAN-OS set 配置或 Alteon dump 中的引号描述。
|
|
42
|
+
def decode_description(value)
|
|
43
|
+
value = value.strip
|
|
44
|
+
value.start_with?('"') ? Shellwords.split(value).join(" ") : value
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "topology/strategy"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Operations
|
|
8
|
+
# 从 CDP/LLDP 回显生成链路邻居,并规划接口描述变更。
|
|
9
|
+
class Topology
|
|
10
|
+
Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol)
|
|
11
|
+
Change = Data.define(:interface, :old_description, :new_description, :neighbor)
|
|
12
|
+
Plan = Data.define(:host, :vendor, :evidence, :changes, :commands)
|
|
13
|
+
|
|
14
|
+
# 保存设备及其独立的 TextFSM 解析入口。
|
|
15
|
+
def initialize(device, template_dir: nil)
|
|
16
|
+
require_relative "parse_output"
|
|
17
|
+
|
|
18
|
+
@device = device
|
|
19
|
+
@parser = ParseOutput.new(template_dir: template_dir)
|
|
20
|
+
@strategy = (device.profile.topology_strategy || Strategy).new(device)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 执行厂商 CDP/LLDP 命令,返回统一的邻居记录。
|
|
24
|
+
def neighbors
|
|
25
|
+
command = @strategy.neighbor_command
|
|
26
|
+
unless @strategy.supports?(:neighbors) && command
|
|
27
|
+
raise UnsupportedOperation.new("this device cannot discover LLDP neighbors",
|
|
28
|
+
code: :neighbor_discovery_unsupported, host: @device.host, phase: :discover)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
output = @device.execute(command).value!
|
|
32
|
+
template = @strategy.neighbor_template(output)
|
|
33
|
+
rows = @parser.call(output, template: template, vendor: @device.vendor, command: command, host: @device.host)
|
|
34
|
+
if rows.size != @strategy.expected_neighbor_count(output, template) || (rows.empty? && !@strategy.empty_neighbor_output?(output))
|
|
35
|
+
raise ParsingError.new("neighbor output was not recognized", code: :unrecognized_output,
|
|
36
|
+
host: @device.host, phase: :discover)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
rows.map do |row|
|
|
40
|
+
Neighbor.new(local_interface: row.fetch("LOCAL_INTERFACE").strip.freeze,
|
|
41
|
+
neighbor_name: row.fetch("NEIGHBOR_NAME", "").strip.freeze,
|
|
42
|
+
neighbor_interface: row.fetch("NEIGHBOR_INTERFACE").strip.freeze,
|
|
43
|
+
chassis_id: row.fetch("CHASSIS_ID", "").strip.freeze,
|
|
44
|
+
protocol: @strategy.protocol).freeze
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 从运行配置读取已设置的接口描述或端口名称。
|
|
49
|
+
def descriptions
|
|
50
|
+
template = @strategy.description_template
|
|
51
|
+
unless @strategy.supports?(:interface_descriptions) && template
|
|
52
|
+
raise UnsupportedOperation.new("interface descriptions are unsupported for this vendor",
|
|
53
|
+
code: :description_unsupported, host: @device.host, phase: :discover)
|
|
54
|
+
end
|
|
55
|
+
config = @device.running_config.value!
|
|
56
|
+
@parser.call(config, template: template, host: @device.host).each_with_object({}) do |row, found|
|
|
57
|
+
interface = @strategy.interface_key(row.fetch("INTERFACE"))
|
|
58
|
+
description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
|
|
59
|
+
previous = found[interface]
|
|
60
|
+
if previous && !previous.empty? && !description.empty? && previous != description
|
|
61
|
+
raise ParsingError.new("interface has conflicting descriptions", code: :ambiguous_description,
|
|
62
|
+
host: @device.host, phase: :parse)
|
|
63
|
+
end
|
|
64
|
+
found[interface] = description unless description.empty? && previous
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 比对邻居和现有描述,生成包含确切命令的只读变更计划。
|
|
69
|
+
def plan_descriptions(abbreviate: true, lowercase: false, &formatter)
|
|
70
|
+
check_change_support!(:plan) if @strategy.supports?(:neighbors)
|
|
71
|
+
discovered = neighbors
|
|
72
|
+
current = descriptions
|
|
73
|
+
current.each_value(&:freeze)
|
|
74
|
+
evidence = evidence_for(discovered, current)
|
|
75
|
+
changes = discovered.filter_map do |neighbor|
|
|
76
|
+
old = current.fetch(@strategy.interface_key(neighbor.local_interface))
|
|
77
|
+
proposed = if formatter
|
|
78
|
+
formatter.call(neighbor)
|
|
79
|
+
else
|
|
80
|
+
InterfaceDescription.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
|
|
81
|
+
end
|
|
82
|
+
InterfaceDescription.validate!(proposed)
|
|
83
|
+
next if old == proposed
|
|
84
|
+
|
|
85
|
+
Change.new(interface: @strategy.configuration_interface(neighbor.local_interface).freeze, old_description: old.freeze,
|
|
86
|
+
new_description: proposed.freeze, neighbor: neighbor).freeze
|
|
87
|
+
end.freeze
|
|
88
|
+
commands = if changes.empty?
|
|
89
|
+
[]
|
|
90
|
+
else
|
|
91
|
+
[@strategy.enter_configuration] + changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
|
|
92
|
+
end
|
|
93
|
+
commands.each(&:freeze)
|
|
94
|
+
Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
|
|
95
|
+
changes: changes, commands: commands.freeze).freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 只有明确确认且邻居和旧描述未变化时,才下发计划中的命令。
|
|
99
|
+
def apply(plan, confirmed: false)
|
|
100
|
+
unless confirmed == true
|
|
101
|
+
raise UnsupportedOperation.new("description plan requires confirmation", code: :confirmation_required,
|
|
102
|
+
host: @device.host, phase: :apply)
|
|
103
|
+
end
|
|
104
|
+
raise ArgumentError, "plan must be a topology plan for this device" unless plan.is_a?(Plan) &&
|
|
105
|
+
plan.host == @device.host &&
|
|
106
|
+
plan.vendor == @device.vendor
|
|
107
|
+
return Result.new if plan.changes.empty?
|
|
108
|
+
|
|
109
|
+
check_change_support!(:apply) if @strategy.supports?(:neighbors)
|
|
110
|
+
@device.with_operation(:apply) { apply_plan(plan) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# 现场复核、修改和回读属于同一次独占操作。
|
|
116
|
+
def apply_plan(plan)
|
|
117
|
+
discovered = neighbors
|
|
118
|
+
evidence = evidence_for(discovered, descriptions)
|
|
119
|
+
current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
|
|
120
|
+
same_neighbors = plan.changes.all? do |change|
|
|
121
|
+
current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
|
|
122
|
+
end
|
|
123
|
+
unless evidence == plan.evidence && same_neighbors
|
|
124
|
+
raise DeviceError.new("neighbors or interface descriptions changed after planning",
|
|
125
|
+
code: :stale_plan, host: @device.host, phase: :apply)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
plan.changes.each do |change|
|
|
129
|
+
InterfaceDescription.validate!(change.new_description)
|
|
130
|
+
unless evidence.fetch(@strategy.interface_key(change.interface)) ==
|
|
131
|
+
[change.neighbor.neighbor_name, change.neighbor.neighbor_interface, change.old_description]
|
|
132
|
+
raise ArgumentError, "plan changes do not match its discovery evidence"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
commands = [@strategy.enter_configuration] + plan.changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
|
|
136
|
+
raise ArgumentError, "plan commands were modified" unless commands == plan.commands
|
|
137
|
+
|
|
138
|
+
script = commands.map do |command|
|
|
139
|
+
@strategy.script_command(command)
|
|
140
|
+
end
|
|
141
|
+
result = @device.execute_script(Script.new(script, name: "interface descriptions"))
|
|
142
|
+
return result if result.failure?
|
|
143
|
+
|
|
144
|
+
updated = descriptions
|
|
145
|
+
return result if plan.changes.all? do |change|
|
|
146
|
+
updated.fetch(@strategy.interface_key(change.interface), "") == change.new_description
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
Result.new(steps: result.steps,
|
|
150
|
+
error: DeviceError.new("interface descriptions were not confirmed by readback",
|
|
151
|
+
code: :description_unconfirmed, host: @device.host, phase: :verify))
|
|
152
|
+
rescue Error => error
|
|
153
|
+
raise unless result&.success?
|
|
154
|
+
|
|
155
|
+
Result.new(steps: result.steps, error: error)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def check_change_support!(phase)
|
|
159
|
+
return if @strategy.supports?(:interface_description_changes)
|
|
160
|
+
|
|
161
|
+
raise UnsupportedOperation.new("interface description changes are unsupported for this vendor",
|
|
162
|
+
code: :description_unsupported, host: @device.host, phase: phase)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
|
|
166
|
+
def evidence_for(discovered, current)
|
|
167
|
+
interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
|
|
168
|
+
if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
|
|
169
|
+
peers.first.neighbor_interface.empty? }
|
|
170
|
+
raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
|
|
171
|
+
host: @device.host, phase: :plan)
|
|
172
|
+
end
|
|
173
|
+
unless interfaces.keys.all? { |interface| current.key?(interface) }
|
|
174
|
+
raise ParsingError.new("neighbor interface was not found in running configuration",
|
|
175
|
+
code: :interface_missing, host: @device.host, phase: :plan)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
interfaces.transform_values do |peers|
|
|
179
|
+
peer = peers.fetch(0)
|
|
180
|
+
[peer.neighbor_name, peer.neighbor_interface, current.fetch(@strategy.interface_key(peer.local_interface))].freeze
|
|
181
|
+
end.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# 设备标识来自解析证据,仍需在生成命令前验证其安全性。
|
|
185
|
+
def change_commands(change)
|
|
186
|
+
InterfaceDescription.validate_interface!(change.interface)
|
|
187
|
+
@strategy.change_commands(change)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Topology 同时是扩展策略的命名空间。显式装配公共定义,确保旧扩展先加载
|
|
4
|
+
# topology/strategy 时,已有常量不会阻止工作流加载;TextFSM 仍延迟到实际解析。
|
|
5
|
+
require_relative "operations/topology"
|
|
6
|
+
|
|
7
|
+
module Net
|
|
8
|
+
module Connector
|
|
9
|
+
module Operations
|
|
10
|
+
autoload :RunningConfig, File.expand_path("operations/running_config", __dir__)
|
|
11
|
+
autoload :ParseOutput, File.expand_path("operations/parse_output", __dir__)
|
|
12
|
+
autoload :LocalBackup, File.expand_path("operations/local_backup", __dir__)
|
|
13
|
+
autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
|
|
14
|
+
autoload :Tftp, File.expand_path("operations/tftp/strategy", __dir__)
|
|
15
|
+
end
|
|
16
|
+
autoload :TftpTarget, File.expand_path("operations/tftp_backup", __dir__)
|
|
17
|
+
autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
|
|
18
|
+
autoload :Backup, File.expand_path("operations/local_backup", __dir__)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Value Required LOCAL_INTERFACE (\S+)
|
|
2
|
+
Value Required NEIGHBOR_NAME (\S+)
|
|
3
|
+
Value Required NEIGHBOR_INTERFACE (\S+)
|
|
4
|
+
|
|
5
|
+
Start
|
|
6
|
+
^\s*Device ID:\s*${NEIGHBOR_NAME}\s*$$
|
|
7
|
+
^\s*Interface:\s*${LOCAL_INTERFACE},\s*Port ID \(outgoing port\):\s*${NEIGHBOR_INTERFACE}\s*$$ -> Record
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Value Required INTERFACE (\S+)
|
|
2
|
+
Value DESCRIPTION (.*)
|
|
3
|
+
|
|
4
|
+
Start
|
|
5
|
+
^interface ${INTERFACE}$$ -> Interface
|
|
6
|
+
|
|
7
|
+
Interface
|
|
8
|
+
^\s+description ${DESCRIPTION}$$
|
|
9
|
+
^!$$ -> Record Start
|
|
10
|
+
^interface .+ -> Continue.Record
|
|
11
|
+
^interface ${INTERFACE}$$
|
|
12
|
+
^end$$ -> Record End
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Value Required LOCAL_INTERFACE (\S+)
|
|
2
|
+
Value NEIGHBOR_NAME (\S+)
|
|
3
|
+
Value Required CHASSIS_ID (\S+)
|
|
4
|
+
Value Required NEIGHBOR_INTERFACE (Port\s+\d+|\S+)
|
|
5
|
+
|
|
6
|
+
Start
|
|
7
|
+
^\s*display lldp neighbor-information list\s*$$
|
|
8
|
+
^\s*${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_INTERFACE}\s+${NEIGHBOR_NAME}\s*$$ -> Record
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Value Required LOCAL_INTERFACE (\S+)
|
|
2
|
+
Value NEIGHBOR_NAME (\S+)
|
|
3
|
+
Value Required CHASSIS_ID (\S+)
|
|
4
|
+
Value Required NEIGHBOR_INTERFACE (Port\s+\d+|\S+)
|
|
5
|
+
|
|
6
|
+
Start
|
|
7
|
+
^\s*display lldp neighbor-information list\s*$$
|
|
8
|
+
^\s*${NEIGHBOR_NAME}\s+${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_INTERFACE}\s*$$ -> Record
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Template, Vendor, Command
|
|
2
|
+
cisco_ios_show_ip_interface_brief.textfsm, cisco_ios, show ip interface brief$
|
|
3
|
+
cisco_cdp_neighbors_detail.textfsm, cisco_ios, show cdp neighbors detail$
|
|
4
|
+
cisco_cdp_neighbors_detail.textfsm, cisco_nxos, show cdp neighbors detail$
|
|
5
|
+
h3c_lldp_name_first.textfsm, h3c, display lldp neighbor-information list$
|
|
6
|
+
h3c_lldp_name_first.textfsm, h3c_wireless, display lldp neighbor-information list$
|
|
7
|
+
hillstone_lldp_neighbors.textfsm, hillstone, show lldp neighbor-information$
|
|
8
|
+
palo_alto_lldp_neighbors.textfsm, palo_alto, show lldp neighbors all$
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Value Required LOCAL_INTERFACE (\S+)
|
|
2
|
+
Value NEIGHBOR_NAME (\S+)
|
|
3
|
+
Value CHASSIS_ID (\S+)
|
|
4
|
+
Value Required NEIGHBOR_INTERFACE (\S+)
|
|
5
|
+
|
|
6
|
+
Start
|
|
7
|
+
^\s*Local information:\s*$$ -> Record
|
|
8
|
+
^\s*Local interface:\s*${LOCAL_INTERFACE}\s*$$
|
|
9
|
+
^\s*Chassis ID:\s*${CHASSIS_ID}\s*$$
|
|
10
|
+
^\s*Port ID:\s*${NEIGHBOR_INTERFACE}\s*$$
|
|
11
|
+
^\s*System name:\s*${NEIGHBOR_NAME}\s*$$
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Value Required INTERFACE (\d+)
|
|
2
|
+
Value DESCRIPTION (.*)
|
|
3
|
+
|
|
4
|
+
Start
|
|
5
|
+
^/(?:c|cfg)/port ${INTERFACE}/name "${DESCRIPTION}"\s*$$ -> Record
|
|
6
|
+
^/(?:c|cfg)/port ${INTERFACE}\s*$$ -> Port
|
|
7
|
+
|
|
8
|
+
Port
|
|
9
|
+
^\s+name "${DESCRIPTION}"\s*$$
|
|
10
|
+
^\s+name ${DESCRIPTION}$$
|
|
11
|
+
^/.* -> Continue.Record
|
|
12
|
+
^/(?:c|cfg)/port ${INTERFACE}/name "${DESCRIPTION}"\s*$$ -> Record Start
|
|
13
|
+
^/(?:c|cfg)/port ${INTERFACE}\s*$$
|