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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE +22 -0
  4. data/README-zh.md +90 -0
  5. data/README.md +90 -0
  6. data/docs/architecture-zh.md +95 -0
  7. data/docs/release.md +13 -0
  8. data/examples/offline.rb +16 -0
  9. data/lib/vxlan/bgp_neighbors.rb +29 -0
  10. data/lib/vxlan/cisco_nxos/configuration.rb +84 -0
  11. data/lib/vxlan/cisco_nxos/configuration_document.rb +31 -0
  12. data/lib/vxlan/cisco_nxos/configuration_parsing/interfaces.rb +91 -0
  13. data/lib/vxlan/cisco_nxos/configuration_parsing/policies.rb +58 -0
  14. data/lib/vxlan/cisco_nxos/configuration_parsing/routing.rb +62 -0
  15. data/lib/vxlan/cisco_nxos/evpn_l2route.rb +118 -0
  16. data/lib/vxlan/cisco_nxos/parser.rb +105 -0
  17. data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +80 -0
  18. data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +86 -0
  19. data/lib/vxlan/cisco_nxos/parsers/show_interface_brief.rb +84 -0
  20. data/lib/vxlan/cisco_nxos/parsers/show_ip_ospf_neighbors.rb +48 -0
  21. data/lib/vxlan/cisco_nxos/parsers/show_l2route_evpn_mac_all.rb +14 -0
  22. data/lib/vxlan/cisco_nxos/parsers/show_l2route_evpn_mac_ip_all.rb +14 -0
  23. data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +42 -0
  24. data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +49 -0
  25. data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +39 -0
  26. data/lib/vxlan/cisco_nxos/parsers/show_port_channel_summary.rb +49 -0
  27. data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +303 -0
  28. data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +77 -0
  29. data/lib/vxlan/cisco_nxos/profile.rb +39 -0
  30. data/lib/vxlan/collector.rb +127 -0
  31. data/lib/vxlan/configuration.rb +20 -0
  32. data/lib/vxlan/configuration_review.rb +43 -0
  33. data/lib/vxlan/l2route_parser.rb +21 -0
  34. data/lib/vxlan/observation.rb +24 -0
  35. data/lib/vxlan/parser_registry.rb +9 -0
  36. data/lib/vxlan/prefix_list_references.rb +89 -0
  37. data/lib/vxlan/runtime_parser.rb +22 -0
  38. data/lib/vxlan/snapshot.rb +23 -0
  39. data/lib/vxlan/version.rb +6 -0
  40. data/lib/vxlan.rb +16 -0
  41. 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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evpn_l2route"
4
+
5
+ module Vxlan
6
+ module CiscoNxos
7
+ module Parsers
8
+ # 解析 show l2route evpn mac all 的 CLI 回显。
9
+ class ShowL2routeEvpnMacAll < EvpnL2route
10
+ TYPE = :mac
11
+ end
12
+ end
13
+ end
14
+ end