vxlan 0.1.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 +21 -0
- data/LICENSE +22 -0
- data/README-zh.md +90 -0
- data/README.md +90 -0
- data/docs/architecture-zh.md +95 -0
- data/docs/release.md +13 -0
- data/examples/offline.rb +16 -0
- data/lib/vxlan/bgp_neighbors.rb +29 -0
- data/lib/vxlan/cisco_nxos/configuration.rb +84 -0
- data/lib/vxlan/cisco_nxos/configuration_document.rb +31 -0
- data/lib/vxlan/cisco_nxos/configuration_parsing/interfaces.rb +91 -0
- data/lib/vxlan/cisco_nxos/configuration_parsing/policies.rb +58 -0
- data/lib/vxlan/cisco_nxos/configuration_parsing/routing.rb +62 -0
- data/lib/vxlan/cisco_nxos/evpn_l2route.rb +118 -0
- data/lib/vxlan/cisco_nxos/parser.rb +105 -0
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +80 -0
- data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +86 -0
- data/lib/vxlan/cisco_nxos/parsers/show_interface_brief.rb +84 -0
- data/lib/vxlan/cisco_nxos/parsers/show_ip_ospf_neighbors.rb +48 -0
- data/lib/vxlan/cisco_nxos/parsers/show_l2route_evpn_mac_all.rb +14 -0
- data/lib/vxlan/cisco_nxos/parsers/show_l2route_evpn_mac_ip_all.rb +14 -0
- data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +42 -0
- data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +49 -0
- data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +39 -0
- data/lib/vxlan/cisco_nxos/parsers/show_port_channel_summary.rb +49 -0
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +303 -0
- data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +77 -0
- data/lib/vxlan/cisco_nxos/profile.rb +39 -0
- data/lib/vxlan/collector.rb +127 -0
- data/lib/vxlan/configuration.rb +20 -0
- data/lib/vxlan/configuration_review.rb +43 -0
- data/lib/vxlan/l2route_parser.rb +21 -0
- data/lib/vxlan/observation.rb +24 -0
- data/lib/vxlan/parser_registry.rb +9 -0
- data/lib/vxlan/prefix_list_references.rb +89 -0
- data/lib/vxlan/runtime_parser.rb +22 -0
- data/lib/vxlan/snapshot.rb +23 -0
- data/lib/vxlan/version.rb +6 -0
- data/lib/vxlan.rb +16 -0
- metadata +140 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vxlan
|
|
4
|
+
module CiscoNxos
|
|
5
|
+
module ConfigurationParsing
|
|
6
|
+
# 前缀列表与路由映射保留地址族及引用选择器。
|
|
7
|
+
module Policies
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# 解析有序 IP 前缀列表条目及匹配边界。
|
|
11
|
+
def prefix_list(block, lists, family:)
|
|
12
|
+
return ignore(block) if /\A(?:ip|ipv6) prefix-list \S+ description .+\z/.match?(block.text)
|
|
13
|
+
match = /\A(?:ip|ipv6) prefix-list (\S+) seq (\d+) (permit|deny) (\S+)(?: ge (\d+))?(?: le (\d+))?\z/.match(block.text)
|
|
14
|
+
raise ArgumentError unless match
|
|
15
|
+
subnet = network_prefix(match[4], family: family)
|
|
16
|
+
length, width = IPAddr.new(subnet).prefix, family == "ipv6" ? 128 : 32
|
|
17
|
+
ge, le = match[5]&.to_i, match[6]&.to_i
|
|
18
|
+
raise ArgumentError if (ge && !(length..width).cover?(ge)) || (le && !((ge || length)..width).cover?(le))
|
|
19
|
+
entries = (lists[match[1]] ||= [])
|
|
20
|
+
sequence = integer(match[2], 1..4_294_967_295)
|
|
21
|
+
raise ArgumentError if entries.any? { |entry| entry[:sequence] == sequence }
|
|
22
|
+
entries << { sequence: sequence, action: match[3], prefix: subnet, ge: ge, le: le }
|
|
23
|
+
collection = family == "ipv6" ? :ipv6_prefix_lists : :prefix_lists
|
|
24
|
+
capture(block, entries.last, [ collection, match[1], entries.size - 1 ])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 解析有序 route-map 条目及策略引用。
|
|
28
|
+
def route_map(block)
|
|
29
|
+
values = { prefix_lists: [], additional_matches: [], sets: [] }
|
|
30
|
+
analyze_children(block) do |child|
|
|
31
|
+
case child.text
|
|
32
|
+
when /\Amatch (ip|ipv6) (address|next-hop|route-source) prefix-list (.+)\z/
|
|
33
|
+
family, selector, names = $1 == "ip" ? "ipv4" : "ipv6", $2, $3.split
|
|
34
|
+
values[:prefix_lists].concat(names) if family == "ipv4" && selector == "address"
|
|
35
|
+
(values[:prefix_list_matches] ||= []) << { address_family: family, selector: selector, names: names }
|
|
36
|
+
when /\Amatch / then values[:additional_matches] << child.text
|
|
37
|
+
when /\Aset / then values[:sets] << child.text
|
|
38
|
+
when /\Adescription / then ignore(child)
|
|
39
|
+
else issue(child)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
values
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 归一化已配置的网络前缀及所属地址族。
|
|
46
|
+
def network_prefix(value, family: nil)
|
|
47
|
+
unless value.is_a?(String) && /\A[0-9a-f:.]+\/\d+\z/i.match?(value)
|
|
48
|
+
raise ArgumentError, "A network prefix is required"
|
|
49
|
+
end
|
|
50
|
+
network = IPAddr.new(value)
|
|
51
|
+
raise ArgumentError if family == "ipv4" && !network.ipv4? || family == "ipv6" && !network.ipv6?
|
|
52
|
+
|
|
53
|
+
"#{network}/#{network.prefix}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vxlan
|
|
4
|
+
module CiscoNxos
|
|
5
|
+
module ConfigurationParsing
|
|
6
|
+
# 路由作用域保留邻居、模板、VRF 与地址族的归属。
|
|
7
|
+
module Routing
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# 解析支持的路由作用域及协议关系。
|
|
11
|
+
def routing(block, protocol)
|
|
12
|
+
values = { vrfs: {}, neighbors: {}, templates: {}, address_families: {}, redistribute: [] }
|
|
13
|
+
analyze_children(block) do |child|
|
|
14
|
+
case child.text
|
|
15
|
+
when /\Avrf (\S+)\z/ then store(values[:vrfs], $1, child, path: @source_path + [ :vrfs, $1 ]) { routing(child, protocol) }
|
|
16
|
+
when /\Atemplate peer (\S+)\z/ then store(values[:templates], $1, child, path: @source_path + [ :templates, $1 ]) { routing(child, protocol) }
|
|
17
|
+
when /\Aneighbor (\S+)\z/ then store(values[:neighbors], $1, child, path: @source_path + [ :neighbors, $1 ]) { routing(child, protocol) }
|
|
18
|
+
when /\Aaddress-family (.+)\z/ then store(values[:address_families], $1, child, path: @source_path + [ :address_families, $1 ]) { routing(child, protocol) }
|
|
19
|
+
when /\Arouter-id (\S+)\z/ then values[:router_id] = address($1)
|
|
20
|
+
when /\Aremote-as (\S+)\z/
|
|
21
|
+
value = $1
|
|
22
|
+
integer(value, 1..4_294_967_295) unless %w[internal external].include?(value)
|
|
23
|
+
values[:remote_as] = value
|
|
24
|
+
when /\Ainherit peer (\S+)\z/ then values[:inherit_peer] = $1
|
|
25
|
+
when /\Aupdate-source (\S+)\z/ then values[:update_source] = $1
|
|
26
|
+
when /\Adescription (.+)\z/ then values[:description] = $1
|
|
27
|
+
when /\Aroute-map (\S+) (in|out)\z/
|
|
28
|
+
name, direction = $1, $2.to_sym
|
|
29
|
+
maps = (values[:route_maps] ||= {})
|
|
30
|
+
raise ArgumentError if maps.key?(direction)
|
|
31
|
+
|
|
32
|
+
maps[direction] = name
|
|
33
|
+
when /\A(no )?next-hop-self( all)?\z/
|
|
34
|
+
values[:next_hop_self], values[:next_hop_self_all] = $1.nil?, $1.nil? && !$2.nil?
|
|
35
|
+
when /\A(no )?(bfd|route-reflector-client|advertise-pip|advertise l2vpn evpn)\z/
|
|
36
|
+
enabled, setting = $1.nil?, $2
|
|
37
|
+
key = { "bfd" => :bfd, "route-reflector-client" => :route_reflector_client,
|
|
38
|
+
"advertise-pip" => :advertise_pip, "advertise l2vpn evpn" => :advertise_l2vpn_evpn }.fetch(setting)
|
|
39
|
+
values[key] = enabled
|
|
40
|
+
when /\A(no )?send-community(?: (extended|both))?\z/
|
|
41
|
+
enabled, kind = $1.nil?, $2
|
|
42
|
+
values[:standard_community] = enabled unless kind == "extended"
|
|
43
|
+
values[:extended_community] = enabled if kind
|
|
44
|
+
when /\Anetwork (\S+)(?: route-map (\S+))?\z/
|
|
45
|
+
subnet, route_map = $1, $2
|
|
46
|
+
raise ArgumentError unless protocol == :bgp
|
|
47
|
+
|
|
48
|
+
(values[:networks] ||= []) << { prefix: network_prefix(subnet), route_map: route_map }.compact
|
|
49
|
+
when /\Aredistribute (\S+)(?: (?!route-map\b)(\S+))?(?: route-map (\S+))?\z/
|
|
50
|
+
values[:redistribute] << { protocol: $1, process: $2, route_map: $3 }.compact
|
|
51
|
+
when /\Aretain route-target (all|route-map \S+)\z/ then values[:retain_route_target] = $1
|
|
52
|
+
when /\A(?:no )?(?:log-neighbor-changes|log-adjacency-changes|soft-reconfiguration inbound|passive-interface)\b/,
|
|
53
|
+
/\A(?:maximum-paths|default-information originate|auto-cost reference-bandwidth)\b/ then ignore(child)
|
|
54
|
+
else issue(child)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
values
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
module CiscoNxos
|
|
7
|
+
# 按声明列解析 MAC、MAC-IP 行,仅将续行关联到已确认的前一条路由。
|
|
8
|
+
class EvpnL2route < Parser
|
|
9
|
+
UINT32 = 0..4_294_967_295
|
|
10
|
+
LABELS = { "topology" => :topology_id, "topology id" => :topology_id, "mac address" => :mac,
|
|
11
|
+
"host ip" => :host_ip, "prod" => :producer, "flags" => :flags, "seq no" => :sequence,
|
|
12
|
+
"next-hops" => :next_hops, "next hop (s)" => :next_hops, "next hop(s)" => :next_hops }.freeze
|
|
13
|
+
HEADER = /Topology ID|Topology|Mac Address|Host IP|Prod|Flags|Seq No|Next-Hops|Next Hop \(s\)|Next Hop\(s\)/i
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# 解析命令表格行,保留数量及格式错误行诊断。
|
|
18
|
+
def parse_rows(text)
|
|
19
|
+
type = self.class::TYPE
|
|
20
|
+
@totals = []
|
|
21
|
+
fields = previous = nil
|
|
22
|
+
text.each_line.with_index(1) do |source, number|
|
|
23
|
+
line = source.strip
|
|
24
|
+
next if line.empty? || line.match?(/\A[-=\s]+\z/) || line.match?(/\A(?:Flags\s*-\s*)?(?:\([A-Za-z0-9]+\):[A-Za-z0-9 ,\/-]+\s*)+\z/)
|
|
25
|
+
if line.match?(/\ATopology\b/i)
|
|
26
|
+
labels = line.gsub(/\s+/, " ").scan(HEADER)
|
|
27
|
+
fields = labels.map { |label| LABELS.fetch(label.downcase) }
|
|
28
|
+
required = %i[topology_id mac producer next_hops] + (type == :mac_ip ? [ :host_ip ] : [])
|
|
29
|
+
unless line.gsub(/\s+/, " ").gsub(HEADER, "").strip.empty? && fields.uniq == fields &&
|
|
30
|
+
(required - fields).empty? && (fields - required - %i[flags sequence]).empty? &&
|
|
31
|
+
fields.first(2) == %i[topology_id mac] && fields.last == :next_hops
|
|
32
|
+
fields = nil
|
|
33
|
+
@issues << { code: :unknown_header, line: number }
|
|
34
|
+
end
|
|
35
|
+
previous = nil
|
|
36
|
+
elsif line.match?(/\ANo (?:MAC(?:-IP)? )?(?:routes|entries)(?: found)?\.?\z/i)
|
|
37
|
+
@empty = true
|
|
38
|
+
previous = nil
|
|
39
|
+
elsif (match = /\ATotal (?:number of )?(?:MAC(?:-IP)? )?(?:routes|entries)(?: displayed)?\s*:\s*(\d+)\z/i.match(line))
|
|
40
|
+
@totals << match[1].to_i
|
|
41
|
+
previous = nil
|
|
42
|
+
else
|
|
43
|
+
begin
|
|
44
|
+
if fields && line.match?(/\A\d+\s/)
|
|
45
|
+
values = line.split(/\s+/, fields.size)
|
|
46
|
+
raise ArgumentError unless values.size == fields.size
|
|
47
|
+
previous = route(fields.zip(values).to_h, type)
|
|
48
|
+
@rows << previous
|
|
49
|
+
elsif previous && source.match?(/\A\s+/)
|
|
50
|
+
previous[:next_hops] = (previous[:next_hops] + next_hops(line)).uniq
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError
|
|
53
|
+
end
|
|
54
|
+
rescue ArgumentError, IPAddr::Error
|
|
55
|
+
previous = nil
|
|
56
|
+
@issues << { code: :unparsed_row, line: number }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 汇总已解析记录,并报告未解决或不完整的来源状态。
|
|
63
|
+
def finish(complete)
|
|
64
|
+
@totals.each { |count| mismatch(count, @rows.size) }
|
|
65
|
+
remove_duplicates(@rows, %i[topology_id mac host_ip producer])
|
|
66
|
+
@issues << { code: :contradictory_empty } if @empty && @rows.any?
|
|
67
|
+
@issues << { code: :missing_completeness_evidence } if @rows.empty? && !@empty && @totals != [ 0 ]
|
|
68
|
+
@issues << { code: :output_incomplete } unless complete
|
|
69
|
+
CtxParser::Result.new(status: @issues.empty? ? :complete : :partial, records: @rows, issues: @issues)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 归一化单条 EVPN 路由及回显的身份字段。
|
|
73
|
+
def route(values, type)
|
|
74
|
+
values[:topology_id] = number(values[:topology_id])
|
|
75
|
+
raise ArgumentError unless values[:mac].match?(/\A(?:[0-9a-f]{4}(?:\.[0-9a-f]{4}){2}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})\z/i)
|
|
76
|
+
values[:mac] = CtxParser::Values.mac(values[:mac])
|
|
77
|
+
values[:sequence] = number(values[:sequence]) if values[:sequence]
|
|
78
|
+
values[:host_ip] = host(values.fetch(:host_ip)) if type == :mac_ip
|
|
79
|
+
values[:next_hops] = next_hops(values.fetch(:next_hops))
|
|
80
|
+
values
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 归一化 EVPN 路由关联的全部下一跳。
|
|
84
|
+
def next_hops(text)
|
|
85
|
+
result = []
|
|
86
|
+
until text.empty?
|
|
87
|
+
match = /\A(?<hop>Ifindex\s+\d+|Local|(?:Eth(?:ernet)?|Po(?:rt-channel)?|nve|Lo(?:opback)?|Vlan|vPC)[\d\/.:-]+|[\da-f:.]+)(?:\s*\(Label:\s*(?<label>\d+)\))?(?=\s|,|\z)/i.match(text)
|
|
88
|
+
raise ArgumentError unless match
|
|
89
|
+
token = match[:hop]
|
|
90
|
+
value = case token
|
|
91
|
+
when /\ALocal\z/i then { local: true }
|
|
92
|
+
when /\AIfindex\s+(\d+)\z/i then { ifindex: number($1) }
|
|
93
|
+
when /\A(?:Eth|Po|nve|Lo|Vlan|vPC)/i then { interface: token }
|
|
94
|
+
else { address: host(token) }
|
|
95
|
+
end
|
|
96
|
+
value[:label] = number(match[:label]) if match[:label]
|
|
97
|
+
result << value
|
|
98
|
+
remainder = text[match.end(0)..]
|
|
99
|
+
break if remainder.empty?
|
|
100
|
+
text = remainder.sub(/\A(?:\s*,\s*|\s+)/, "")
|
|
101
|
+
raise ArgumentError if text.empty? || text.start_with?(",")
|
|
102
|
+
end
|
|
103
|
+
raise ArgumentError if result.empty?
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 解析数值测量,不将未知文本转换为零。
|
|
108
|
+
def number(value)
|
|
109
|
+
CtxParser::Values.integer(value, max: UINT32.end)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 归一化主机地址,保留 IPv4 与 IPv6 语义。
|
|
113
|
+
def host(value)
|
|
114
|
+
CtxParser::Values.host(value, allow_zone: true)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/parser"
|
|
4
|
+
require "ctx_parser/records"
|
|
5
|
+
require "ctx_parser/input"
|
|
6
|
+
require "ctx_parser/values"
|
|
7
|
+
|
|
8
|
+
module Vxlan
|
|
9
|
+
module CiscoNxos
|
|
10
|
+
# NX-OS 清单完整要求格式、空表证据与采集完整性均经过校验。
|
|
11
|
+
class Parser < CtxParser::Parser
|
|
12
|
+
EMPTY = nil
|
|
13
|
+
IDENTITY = nil
|
|
14
|
+
BOUNDARY = nil
|
|
15
|
+
UNSUPPORTED = /^\s*(?:%\s*)?(?:Invalid (?:command|input)\b.*|(?:This command is )?not supported\.?|(?:ERROR:\s*)?Feature\b.*not enabled\.?)\s*$/i
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# 解释当前命令回显,保留不支持或格式错误的证据。
|
|
20
|
+
def parse_output(text, complete:)
|
|
21
|
+
begin
|
|
22
|
+
text = CtxParser::Input.text(text, allow_nul: true)
|
|
23
|
+
rescue EncodingError
|
|
24
|
+
return CtxParser::Result.new(status: :partial, issues: [ { code: :invalid_encoding } ])
|
|
25
|
+
end
|
|
26
|
+
if self.class::UNSUPPORTED&.match?(text)
|
|
27
|
+
return CtxParser::Result.new(status: :unsupported, issues: [ { code: :command_unsupported } ])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@issues, @rows, @empty, @header = [], [], false, false
|
|
31
|
+
parse_content(normalize(text), complete: complete)
|
|
32
|
+
finish(complete)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 解析 NX-OS 表格前,先归一化终端回车符。
|
|
36
|
+
def normalize(text) = CtxParser::Input.text(text, newlines: :delete_cr, allow_nul: true)
|
|
37
|
+
|
|
38
|
+
# 分别解析各路由作用域,或分派单个命令表。
|
|
39
|
+
def parse_content(text, complete:)
|
|
40
|
+
return if self.class::BOUNDARY && parse_scopes(text, complete: complete)
|
|
41
|
+
|
|
42
|
+
parse_rows(text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 汇总已解析记录,并报告未解决或不完整的来源状态。
|
|
46
|
+
def finish(complete)
|
|
47
|
+
@issues << { code: :missing_completeness_evidence } if @rows.empty? && !@empty
|
|
48
|
+
@issues << { code: :format_unrecognized } unless @header || @empty
|
|
49
|
+
@issues << { code: :contradictory_empty } if @empty && @rows.any?
|
|
50
|
+
@issues << { code: :output_incomplete } unless complete
|
|
51
|
+
remove_duplicates(@rows, self.class::IDENTITY) if self.class::IDENTITY
|
|
52
|
+
CtxParser::Result.new(status: @issues.empty? ? :complete : :partial, records: @rows, issues: @issues)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 逐 VRF、进程独立检查,避免完整表掩盖同级表截断。
|
|
56
|
+
def parse_scopes(text, complete:)
|
|
57
|
+
blocks = text.split(/(?=#{self.class::BOUNDARY})/).reject { |block| block.strip.empty? }
|
|
58
|
+
return false unless blocks.size > 1
|
|
59
|
+
|
|
60
|
+
results = blocks.map { |block| self.class.parse(block, complete: complete) }
|
|
61
|
+
@rows = results.flat_map(&:records)
|
|
62
|
+
@issues = results.flat_map(&:issues)
|
|
63
|
+
@header = results.all?(&:complete?) || @rows.any?
|
|
64
|
+
@empty = results.all? { |result| result.complete? && result.records.empty? }
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 逐行提供有效表格内容,并记录明确空表证据。
|
|
69
|
+
def lines(text, separators: false)
|
|
70
|
+
text.each_line.with_index(1) do |source, number|
|
|
71
|
+
line = source.strip
|
|
72
|
+
next if line.empty? || (!separators && line.match?(/\A[-=+\s]+\z/))
|
|
73
|
+
if self.class::EMPTY && line.match?(/\A(?:No #{self.class::EMPTY}(?: found)?|0 #{self.class::EMPTY})\.?\z/i)
|
|
74
|
+
@empty = true
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
yield line, number
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 记录声明总数与实际解析数量不一致的情况。
|
|
82
|
+
def mismatch(expected, observed)
|
|
83
|
+
@issues << { code: :count_mismatch, expected: expected, observed: observed } unless expected == observed
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 记录不支持或无效的行,不将其接受为完整证据。
|
|
87
|
+
def reject(number)
|
|
88
|
+
@issues << { code: :unparsed_row, line: number }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 校验主机地址有效性,不让格式错误值继续传播。
|
|
92
|
+
def valid_host?(value)
|
|
93
|
+
CtxParser::Values.valid_host?(value, allow_zone: true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 返回可作为权威清单的结果前,先识别身份冲突。
|
|
97
|
+
def remove_duplicates(rows, fields, &identity)
|
|
98
|
+
identities = CtxParser::Records.new(rows, identity: fields, &identity)
|
|
99
|
+
return unless identities.duplicates?
|
|
100
|
+
rows.replace(identities.unambiguous)
|
|
101
|
+
@issues << { code: :duplicate_identity, count: identities.duplicate_identity_count }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
module CiscoNxos
|
|
7
|
+
module Parsers
|
|
8
|
+
# 从 BGP 汇总解析 EVPN 邻居状态及可选路由类型统计。
|
|
9
|
+
class ShowBgpL2vpnEvpnSummary < Parser
|
|
10
|
+
EMPTY = "(?:BGP )?neighbors"
|
|
11
|
+
IDENTITY = %i[vrf address_family peer_ip].freeze
|
|
12
|
+
BOUNDARY = /^\s*BGP summary information for VRF /
|
|
13
|
+
HEADER = /\ANeighbor\s+V\s+AS\s+MsgRcvd\s+MsgSent\s+TblVer\s+InQ\s+OutQ\s+Up\/Down\s+State\/PfxRcd\z/i
|
|
14
|
+
ROW = /\A([\da-f:.]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/i
|
|
15
|
+
FIELDS = %i[peer_ip version remote_as messages_received messages_sent table_version input_queue output_queue uptime state].freeze
|
|
16
|
+
INTEGERS = %i[version remote_as messages_received messages_sent table_version input_queue output_queue].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# 解析命令表格行,保留数量及格式错误行诊断。
|
|
21
|
+
def parse_rows(text)
|
|
22
|
+
scope, prefix_columns, prefix_seen = {}, nil, []
|
|
23
|
+
lines(text) do |line, number|
|
|
24
|
+
if HEADER.match?(line)
|
|
25
|
+
@header = true
|
|
26
|
+
elsif (match = /\ABGP summary information for VRF (\S+), address family (.+)/.match(line))
|
|
27
|
+
scope = { vrf: match[1], address_family: match[2] }
|
|
28
|
+
prefix_columns = nil
|
|
29
|
+
elsif (match = /\ABGP router identifier (\S+), local AS number (\S+)/.match(line))
|
|
30
|
+
scope.merge!(router_id: match[1].delete_suffix(","), local_as: match[2])
|
|
31
|
+
reject(number) unless valid_host?(scope[:router_id]) && scope[:local_as].match?(/\A\d+(?:\.\d+)?\z/)
|
|
32
|
+
elsif line.match?(/\ANeighbor\s+T\s+AS\s+PfxRcd\s+Type-2\s+Type-3\s+Type-4\s+Type-5(?:\s+Type-12)?\z/)
|
|
33
|
+
prefix_columns = line.end_with?("Type-12") ? %i[type2 type3 type4 type5 type12] : %i[type2 type3 type4 type5]
|
|
34
|
+
elsif prefix_columns
|
|
35
|
+
words = line.split
|
|
36
|
+
peer = @rows.find { |row| row[:peer_ip] == words[0] && row[:vrf] == scope[:vrf] }
|
|
37
|
+
if words.size == prefix_columns.size + 4 && %w[I E].include?(words[1]) && words.drop(2).all? { |word| word.match?(/\A\d+\z/) } &&
|
|
38
|
+
peer && !prefix_seen.include?(peer.object_id) && peer[:remote_as] == words[2].to_i && peer[:prefixes_received] == words[3].to_i
|
|
39
|
+
peer[:prefixes_by_type] = prefix_columns.zip(words.drop(4).map(&:to_i)).to_h
|
|
40
|
+
prefix_seen << peer.object_id
|
|
41
|
+
else
|
|
42
|
+
reject(number)
|
|
43
|
+
end
|
|
44
|
+
elsif (match = ROW.match(line))
|
|
45
|
+
row = FIELDS.zip(match.captures).to_h.merge(scope)
|
|
46
|
+
INTEGERS.each { |key| row[key] = row[key].to_i }
|
|
47
|
+
unless valid_host?(row[:peer_ip]) && row[:version] == 4 && (1..4_294_967_295).cover?(row[:remote_as]) &&
|
|
48
|
+
row[:state].match?(/\A(?:\d+|Idle(?: \(Admin\))?|Active|Connect|OpenSent|OpenConfirm|Shutdown)\z/i)
|
|
49
|
+
reject(number)
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
row[:state] = row[:state].downcase.tr(" ", "_")
|
|
53
|
+
row[:prefixes_received] = row[:state].match?(/\A\d+\z/) ? row[:state].to_i : nil
|
|
54
|
+
row[:state] = "established" if row[:prefixes_received]
|
|
55
|
+
@rows << row
|
|
56
|
+
elsif !line.match?(/\A(?:BGP table version is \d+(?:, L2VPN EVPN config peers \d+, capable peers \d+)?|\d+ network entries and \d+ paths using \d+ bytes of memory|BGP attribute entries \[\d+\/\d+\], BGP AS path entries \[\d+\/\d+\]|BGP community entries \[\d+\/\d+\], BGP clusterlist entries \[\d+\/\d+\])\z/i)
|
|
57
|
+
reject(number)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
if prefix_columns && prefix_seen.size != @rows.size
|
|
61
|
+
@issues << { code: :missing_prefix_statistics, expected: @rows.size, observed: prefix_seen.size }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 归一化平台值,同时保留未知或不支持的状态。
|
|
66
|
+
def normalize(text)
|
|
67
|
+
output = []
|
|
68
|
+
super.each_line do |line|
|
|
69
|
+
if output.last&.strip&.end_with?("State/") && line.strip == "PfxRcd"
|
|
70
|
+
output[-1] = output.last.rstrip + line.strip + "\n"
|
|
71
|
+
else
|
|
72
|
+
output << line
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
output.join
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
module CiscoNxos
|
|
7
|
+
module Parsers
|
|
8
|
+
# 在所属邻居记录内解释 CDP 详细字段。
|
|
9
|
+
class ShowCdpNeighborsDetail < Parser
|
|
10
|
+
EMPTY = "(?:CDP neighbors|CDP entries|neighbors|entries)".freeze
|
|
11
|
+
IDENTITY = %i[device_id interface neighbor_interface].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# 解析当前命令的记录,保留无法识别行的诊断。
|
|
16
|
+
def parse_rows(text)
|
|
17
|
+
row, scope, totals = nil, nil, []
|
|
18
|
+
lines(text, separators: true) do |line, number|
|
|
19
|
+
if line.match?(/\A[-=+\s]+\z/)
|
|
20
|
+
scope = nil
|
|
21
|
+
next
|
|
22
|
+
end
|
|
23
|
+
if (match = /\ATotal (?:entries|cdp entries) displayed\s*:\s*(\d+)\z/i.match(line))
|
|
24
|
+
totals << match[1].to_i
|
|
25
|
+
scope = nil
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
if (match = /\ADevice ID:\s*(\S.*)\z/i.match(line))
|
|
29
|
+
@rows << row if row
|
|
30
|
+
row = { device_id: match[1], interface_addresses: [], management_addresses: [], attributes: {} }
|
|
31
|
+
scope = nil
|
|
32
|
+
@header = true
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
unless row
|
|
36
|
+
reject(number)
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
case line
|
|
40
|
+
when /\A--More--/i then reject(number)
|
|
41
|
+
when /\ASystem Name:\s*(\S.*)\z/i then row[:neighbor_name] = $1
|
|
42
|
+
when /\AInterface address\(es\):(?:\s+\d+)?\z/i then scope = :interface_addresses
|
|
43
|
+
when /\AMgmt address\(es\):(?:\s+\d+)?\z/i then scope = :management_addresses
|
|
44
|
+
when /\AIP(?:v[46])? Address:\s*(\S+)\z/i
|
|
45
|
+
value = $1
|
|
46
|
+
if %i[interface_addresses management_addresses].include?(scope) && valid_host?(value)
|
|
47
|
+
row[scope] << IPAddr.new(value).to_s
|
|
48
|
+
else
|
|
49
|
+
reject(number)
|
|
50
|
+
end
|
|
51
|
+
when /\APlatform:\s*(.+?),\s*Capabilities:\s*(.+)\z/i
|
|
52
|
+
row.merge!(platform: $1, capabilities: $2)
|
|
53
|
+
scope = nil
|
|
54
|
+
when /\AInterface:\s*(\S+),\s*Port ID \(outgoing port\):\s*(\S.*)\z/i
|
|
55
|
+
reject(number) if row.key?(:interface)
|
|
56
|
+
row.merge!(interface: $1, neighbor_interface: $2)
|
|
57
|
+
scope = nil
|
|
58
|
+
when /\AHoldtime:\s*(\d+) sec\z/i then row[:holdtime] = $1.to_i
|
|
59
|
+
when /\AVersion:\z/i
|
|
60
|
+
scope = :version
|
|
61
|
+
row[:version] = ""
|
|
62
|
+
when /\A((?:Local|Remote) Interface MAC):\s*([\da-f]{2}(?::[\da-f]{2}){5})\z/i
|
|
63
|
+
row[:attributes][$1.downcase.tr(" ", "_").to_sym] = $2.downcase
|
|
64
|
+
scope = nil
|
|
65
|
+
when /\A(Advertisement Version|Duplex|MTU|Native VLAN|VTP Management Domain|Physical Location|DC_UUID):\s*(.+)\z/i
|
|
66
|
+
row[:attributes][$1.downcase.tr(" ", "_").to_sym] = $2
|
|
67
|
+
scope = nil
|
|
68
|
+
else
|
|
69
|
+
scope == :version ? row[:version] = [ row[:version], line ].reject(&:empty?).join("\n") : reject(number)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@rows << row if row
|
|
73
|
+
totals.each { |total| mismatch(total, @rows.size) }
|
|
74
|
+
@empty = true if totals == [ 0 ] && @rows.empty?
|
|
75
|
+
@rows.reject! do |entry|
|
|
76
|
+
entry[:neighbor_name] ||= entry[:device_id].sub(/\([^)]*\)\z/, "").split(".").first
|
|
77
|
+
if entry.values_at(:neighbor_name, :interface, :neighbor_interface).any? { |value| value.nil? || value.empty? }
|
|
78
|
+
@issues << { code: :incomplete_cdp_neighbor }
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
module CiscoNxos
|
|
7
|
+
module Parsers
|
|
8
|
+
# 根据响应表头解释接口汇总列。
|
|
9
|
+
class ShowInterfaceBrief < Parser
|
|
10
|
+
EMPTY = "interfaces".freeze
|
|
11
|
+
IDENTITY = %i[interface].freeze
|
|
12
|
+
INTERFACE = /\A(?:mgmt\d+|Eth(?:ernet)?\d[\w\/.-]*|Po\d+|port-channel\d+|Vlan\d+|Lo\d+|loopback\d+|nve\d+)\z/i
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# 解析当前命令的记录,保留无法识别行的诊断。
|
|
17
|
+
def parse_rows(text)
|
|
18
|
+
layout = nil
|
|
19
|
+
lines(text) do |line, number|
|
|
20
|
+
if line.match?(/\A(?:Port\s+VRF\s+Status\s+IP Address(?:\s+Speed\s+MTU)?|(?:Port|Interface)\s+Name\s+Status\s+Vlan\s+Duplex\s+Speed\s+Type|(?:Ethernet|Port-channel)\s+VLAN\s+Type\s+Mode\s+Status\s+Reason\s+Speed\s+(?:Port|Protocol)|Interface\s+Status\s+Description|Interface\s+Secondary VLAN\(Type\)\s+Status Reason|Port\s+Status Reason\s+MTU)\z/i)
|
|
21
|
+
layout = line
|
|
22
|
+
@header = true
|
|
23
|
+
elsif line.match?(/\AInterface(?:\s+Ch #)?\z/i)
|
|
24
|
+
next
|
|
25
|
+
else
|
|
26
|
+
words = line.split
|
|
27
|
+
state = words.index { |word| %w[up down connected notconnect disabled admin-down].include?(word.downcase) }
|
|
28
|
+
unless layout && state && INTERFACE.match?(words[0])
|
|
29
|
+
reject(number)
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
row = { interface: words[0], state: words[state].downcase, detail: words.drop(state + 1).join(" ") }
|
|
33
|
+
case layout
|
|
34
|
+
when /\APort\s+VRF/i
|
|
35
|
+
if state == 2 && [ 4, 6 ].include?(words.size) && (words.size == 4 || words[5].match?(/\A\d+\z/)) &&
|
|
36
|
+
(words[3] == "--" || valid_host?(words[3]))
|
|
37
|
+
row.merge!(vrf: words[1], ip_address: words[3])
|
|
38
|
+
row.merge!(speed: words[4], mtu: words[5].to_i) if words.size == 6
|
|
39
|
+
else
|
|
40
|
+
reject(number)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
when /\A(?:Ethernet|Port-channel)\s+VLAN/i
|
|
44
|
+
unless state == 4 && words.size >= 8
|
|
45
|
+
reject(number)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
row.merge!(vlan: words[1], type: words[2], mode: words[3], reason: words[5...-2].join(" "), speed: words[-2])
|
|
49
|
+
row[layout.start_with?("Ethernet") ? :port_channel : :protocol] = words[-1]
|
|
50
|
+
when /\AInterface\s+Status/i
|
|
51
|
+
unless state == 1
|
|
52
|
+
reject(number)
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
row[:description] = words.drop(2).join(" ")
|
|
56
|
+
when /\AInterface\s+Secondary/i
|
|
57
|
+
unless state >= 2
|
|
58
|
+
reject(number)
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
row.merge!(secondary_vlan: words[1...state].join(" "), reason: words.drop(state + 1).join(" "))
|
|
62
|
+
when /\APort\s+Status Reason/i
|
|
63
|
+
unless state == 1 && words.size >= 4 && words.last.match?(/\A\d+\z/)
|
|
64
|
+
reject(number)
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
row.merge!(reason: words[2...-1].join(" "), mtu: words.last.to_i)
|
|
68
|
+
else
|
|
69
|
+
match = /\A\S+\s+(.*?)\s+(connected|notconnect|disabled|admin-down|up|down)\s+(\d+|routed|trunk|unassigned|--)\s+(full|half|a-full|a-half|auto|--)\s+(\S+)\s+(.+)\z/i.match(line)
|
|
70
|
+
unless match
|
|
71
|
+
reject(number)
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
row.merge!(name: match[1], state: match[2].downcase, vlan: match[3], duplex: match[4], speed: match[5], type: match[6],
|
|
75
|
+
detail: match.captures.drop(2).join(" "))
|
|
76
|
+
end
|
|
77
|
+
@rows << row
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
module CiscoNxos
|
|
7
|
+
module Parsers
|
|
8
|
+
# 逐进程、VRF 独立核对 OSPF 邻居总数。
|
|
9
|
+
class ShowIpOspfNeighbors < Parser
|
|
10
|
+
EMPTY = "(?:OSPF )?neighbors"
|
|
11
|
+
IDENTITY = %i[process vrf neighbor_id interface].freeze
|
|
12
|
+
BOUNDARY = /^\s*OSPF Process ID /
|
|
13
|
+
HEADER = /\ANeighbor ID\s+Pri\s+State\s+Up Time\s+Address\s+Interface\z/i
|
|
14
|
+
ROW = /\A([\d.]+)\s+(\d+)\s+(\S+(?:\/\s*\S+)?)\s+(\S+)\s+([\d.]+)\s+(\S+)\z/
|
|
15
|
+
FIELDS = %i[neighbor_id priority state uptime address interface].freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# 解析命令表格行,保留数量及格式错误行诊断。
|
|
20
|
+
def parse_rows(text)
|
|
21
|
+
scope, totals = {}, []
|
|
22
|
+
lines(text) do |line, number|
|
|
23
|
+
if HEADER.match?(line)
|
|
24
|
+
@header = true
|
|
25
|
+
elsif (match = /\AOSPF Process ID (\S+) VRF (\S+)\z/i.match(line))
|
|
26
|
+
scope = { process: match[1], vrf: match[2] }
|
|
27
|
+
elsif (match = /\ATotal number of neighbors:\s*(\d+)\z/i.match(line))
|
|
28
|
+
totals << match[1].to_i
|
|
29
|
+
elsif (match = ROW.match(line))
|
|
30
|
+
row = FIELDS.zip(match.captures).to_h.merge(scope)
|
|
31
|
+
row[:priority] = row[:priority].to_i
|
|
32
|
+
unless valid_host?(row[:neighbor_id]) && valid_host?(row[:address]) && (0..255).cover?(row[:priority]) &&
|
|
33
|
+
row[:state].match?(/\A(?:DOWN|ATTEMPT|INIT|2WAY|EXSTART|EXCHANGE|LOADING|FULL)\/(?:\s*(?:DR|BDR|DROTHER|-))\z/i)
|
|
34
|
+
reject(number)
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
@rows << row.merge(state: row[:state].downcase.delete(" "))
|
|
38
|
+
else
|
|
39
|
+
reject(number)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
totals.each { |count| mismatch(count, @rows.size) }
|
|
43
|
+
@empty = true if @rows.empty? && totals == [ 0 ]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|