vxlan 0.2.1 → 0.2.2

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.
@@ -13,22 +13,22 @@ module Vxlan
13
13
 
14
14
  # 解析当前命令的记录,保留无法识别行的诊断。
15
15
  def parse_rows(text)
16
- lines(text) do |line, number|
16
+ each_content_line(text) do |line, number|
17
17
  if line.match?(/\AVLAN\s+MAC Address\s+Type\s+age\s+Secure\s+NTFY\s+Ports\z/i)
18
18
  @header = true
19
19
  elsif (match = /\A(?:[*+GOCR~]\s+)?(\d+|-)\s+([\da-f]{4}(?:\.[\da-f]{4}){2})\s+(dynamic|static)\s+(\S+)\s+([TF])\s+([TF])\s+(.+)\z/i.match(line))
20
20
  unless match[1] == "-" || (1..4094).cover?(match[1].to_i)
21
- reject(number)
21
+ unparsed_records(number)
22
22
  next
23
23
  end
24
24
  @rows << { vlan_id: match[1] == "-" ? nil : match[1].to_i, mac: CtxParser::Values.mac_address(match[2]),
25
25
  type: match[3].downcase, age: match[4], secure: match[5].upcase == "T", notify: match[6].upcase == "T", destination: match[7],
26
26
  flags: line[/\A([*+GOCR~])\s+/, 1] }
27
27
  elsif !line.match?(/\A(?:Legend:|\* - primary entry, G - Gateway MAC, \(R\) - Routed MAC, O - Overlay MAC|age - seconds since last seen,\+ - primary entry using vPC Peer-Link,|\(T\) - True, \(F\) - False, C - ControlPlane MAC, ~ - vsan,?|\(NA\)- Not Applicable(?: A [–-] ESI Active Path, S [–-] ESI Standby Path)?)\z/i)
28
- reject(number)
28
+ unparsed_records(number)
29
29
  end
30
30
  end
31
- remove_duplicates(@rows, %i[vlan_id mac]) do |row|
31
+ exclude_ambiguous_records!(@rows, %i[vlan_id mac]) do |row|
32
32
  identity = row.values_at(:vlan_id, :mac)
33
33
  if row[:flags] == "G" && row[:type] == "static" && row[:destination].match?(/\A(?:sup-eth\d+\(R\)|(Lo\d+)\(R\)(?: \(\1\))?)\z/i)
34
34
  identity.concat([ :gateway, row[:destination] ])
@@ -17,13 +17,13 @@ module Vxlan
17
17
 
18
18
  # 解析命令表格行,保留数量及格式错误行诊断。
19
19
  def parse_rows(text)
20
- lines(text) do |line, number|
20
+ each_content_line(text) do |line, number|
21
21
  if HEADER.match?(line)
22
22
  @header = true
23
23
  elsif (match = ROW.match(line)) && ip_address?(match[2])
24
24
  @rows << FIELDS.zip(match.captures).to_h.merge(state: match[3].downcase)
25
25
  else
26
- reject(number)
26
+ unparsed_records(number)
27
27
  end
28
28
  end
29
29
  end
@@ -17,19 +17,19 @@ module Vxlan
17
17
 
18
18
  # 解析命令表格行,保留数量及格式错误行诊断。
19
19
  def parse_rows(text)
20
- lines(text) do |line, number|
20
+ each_content_line(text) do |line, number|
21
21
  if HEADER.match?(line)
22
22
  @header = true
23
23
  elsif (match = ROW.match(line))
24
24
  row = FIELDS.zip(match.captures).to_h.compact
25
25
  row[:vni] = row[:vni].to_i
26
26
  unless (1..16_777_215).cover?(row[:vni]) && (!row[:flags] || %w[SA S-ND SU Xconn MS-IR HYB].include?(row[:flags]))
27
- reject(number)
27
+ unparsed_records(number)
28
28
  next
29
29
  end
30
30
  @rows << row.merge(state: row[:state].downcase, type: row[:type].downcase)
31
31
  elsif !line.gsub(/\s+/, " ").match?(/\A(?:Codes: CP - Control Plane DP - Data Plane|UC - Unconfigured SA - Suppress ARP|S-ND - Suppress ND|SU - Suppress Unknown Unicast|Xconn - Crossconnect|MS-IR - Multisite Ingress Replication|HYB - Hybrid IRB mode)\z/)
32
- reject(number)
32
+ unparsed_records(number)
33
33
  end
34
34
  end
35
35
  end
@@ -16,7 +16,7 @@ module Vxlan
16
16
  def parse_rows(text)
17
17
  totals = []
18
18
  previous = nil
19
- lines(text) do |line, number|
19
+ each_content_line(text) do |line, number|
20
20
  issue_count = @issues.size
21
21
  if line.match?(/\AGroup\s+Port-(?:Channel)?\s+Type\s+Protocol\s+Member Ports\z/i)
22
22
  @header = true
@@ -25,31 +25,31 @@ module Vxlan
25
25
  previous = nil
26
26
  next
27
27
  elsif (match = /\A(\d+)\s+(Po\d+)\(([^\s()]+)\)\s+(\S+)\s+(\S+)\s*(.*)\z/i.match(line))
28
- previous = { group: match[1].to_i, interface: match[2], flags: match[3], type: match[4], protocol: match[5], members: members(match[6]) }
28
+ previous = { group: match[1].to_i, interface: match[2], flags: match[3], type: match[4], protocol: match[5], members: parse_members(match[6]) }
29
29
  @rows << previous
30
30
  elsif line.match?(/\A(?:Eth(?:ernet)?\S+\([^)]+\)\s*)+\z/i) && previous
31
- previous[:members].concat(members(line))
31
+ previous[:members].concat(parse_members(line))
32
32
  elsif (match = /\ANumber of port-channels\s*:\s*(\d+)\z/i.match(line))
33
33
  totals << match[1].to_i
34
34
  previous = nil
35
35
  elsif !line.match?(/\A(?:Flags:\s+D - Down\s+P - Up in port-channel \(members\)|I - Individual\s+H - Hot-standby \(LACP only\)|s - Suspended\s+r - Module-removed|b - BFD Session Wait|S - Switched\s+R - Routed|U - Up \(port-channel\)|p - Up in delay-lacp mode \(member\)|M - Not in use\. Min-links not met)\z/)
36
- reject(number)
36
+ unparsed_records(number)
37
37
  previous = nil
38
38
  end
39
39
  previous = nil if @issues.size > issue_count
40
40
  end
41
41
  entries = @rows.flat_map { |row| row[:members] }
42
- remove_duplicates(entries, nil) { |member| member[:interface].sub(/\AEth(?:ernet)?/i, "Eth") }
42
+ exclude_ambiguous_records!(entries, nil) { |member| member[:interface].sub(/\AEth(?:ernet)?/i, "Eth") }
43
43
  unambiguous = entries.each_with_object({}.compare_by_identity) { |member, index| index[member] = true }
44
44
  @rows.each do |row|
45
45
  row[:members].select! { |member| unambiguous.key?(member) }
46
46
  end
47
- totals.each { |total| mismatch(total, @rows.size) }
47
+ totals.each { |total| count_mismatch(total, @rows.size) }
48
48
  @empty = true if totals == [ 0 ] && @rows.empty?
49
49
  end
50
50
 
51
51
  # 将回显的聚合成员归一化为接口身份及状态标志。
52
- def members(text)
52
+ def parse_members(text)
53
53
  return [] if text.empty? || text == "--"
54
54
  text.split.filter_map do |word|
55
55
  match = /\A(Eth(?:ernet)?[\d\/]+)\(([^\s()]+)\)\z/i.match(word)
@@ -24,20 +24,23 @@ module Vxlan
24
24
 
25
25
  # 解析运行配置,不依赖传输或资产清单。
26
26
  def parse_output(text, complete:)
27
- configuration = CiscoNxos::Configuration.new
28
- @issues = configuration.issues
27
+ configuration_state = CiscoNxos::ConfigurationState.new
28
+ @issues = configuration_state.issues
29
29
  @conflicts = {}
30
30
  @policy_sequences = {}
31
+ @policy_sources = {}.compare_by_identity
31
32
  @sources = {}
33
+ @global_scopes = {}
32
34
  @setting_sources = {}.compare_by_identity
33
35
  @configuration_conflict_count = 0
34
36
  @feature_sources = []
35
37
  @disabled_feature_sources = []
36
- data = configuration.data
38
+ data = configuration_state.data
37
39
  read_configuration(text, comments: /\A!/, boundaries: /\Aend\z/, document_class: Document).each { |block| parse_block(block, data) }
38
40
  @issues.concat(document.issues)
41
+ finalize_policies(data)
39
42
  # 所有声明读取完毕后再关联模板与网段,避免配置先后顺序改变关联结果。
40
- configuration.finish
43
+ configuration_state.finalize_facts
41
44
  @feature_sources.each { |block, feature| capture(block, feature, [ :features, data[:features].index(feature) ]) }
42
45
  @disabled_feature_sources.each { |block, feature| capture(block, feature, [ :disabled_features, data[:disabled_features].index(feature) ]) }
43
46
  data[:config_digest] = Digest::SHA256.hexdigest(@cursor.lines.map(&:raw).reject { |line| line.strip.empty? || line.lstrip.start_with?("!") }.map(&:rstrip).join("\n"))
@@ -59,9 +62,8 @@ module Vxlan
59
62
  def parse_setting(block, data)
60
63
  case block.text
61
64
  when /\Ahostname (\S+)\z/
62
- issue(block, :duplicate_hostname) if data[:hostname]
63
- data[:hostname] = $1
64
- capture(block, data[:hostname], [ :hostname ])
65
+ name = $1
66
+ store_global_scope(data, :hostname, block, empty: nil, duplicate_code: :duplicate_hostname) { name }
65
67
  when /\Afeature (.+)\z/
66
68
  data[:features] << $1
67
69
  @feature_sources << [ block, $1 ]
@@ -71,14 +73,13 @@ module Vxlan
71
73
  when /\Ainterface breakout module (\d+) port (\d+) map (\d+g-\d+x)\z/
72
74
  module_id, port, mapping = integer($1, 1..65_535), integer($2, 1..65_535), $3
73
75
  key = "#{module_id}/#{port}"
74
- store(data[:breakouts], key, block, path: [ :breakouts, key ]) { { module_id: module_id, port: port, map: mapping } }
76
+ store_unique_fact(data[:breakouts], key, block, path: [ :breakouts, key ]) { { module_id: module_id, port: port, map: mapping } }
75
77
  when /\Ainterface (\S+)\z/
76
78
  name = $1.sub(/\Avlan/i, "Vlan")
77
79
  if name.match?(/\Anve\d+\z/i)
78
- issue(block, :multiple_nve_interfaces) if data[:nve][:interface]
79
- data[:nve] = scope(block, [ :nve ]) { nve(block).merge(interface: name) }
80
+ store_global_scope(data, :nve, block, empty: { members: {} }, duplicate_code: :multiple_nve_interfaces) { nve(block).merge(interface: name) }
80
81
  else
81
- store(data[:interfaces], name, block, path: [ :interfaces, name ]) { interface(block) }
82
+ store_unique_fact(data[:interfaces], name, block, path: [ :interfaces, name ]) { interface(block) }
82
83
  settings = data[:interfaces][name]
83
84
  # VRF 歧义不能等同于默认 VRF;排除此接口,阻止跨接口借址和网段推导。
84
85
  if settings && @setting_sources.fetch(settings, {}).key?(:vrf) && !settings.key?(:vrf)
@@ -97,7 +98,7 @@ module Vxlan
97
98
 
98
99
  mappings = block.children.select { |child| child.text.start_with?("vn-segment") }
99
100
  ids.uniq.each do |id|
100
- store(data[:vlan_vnis], id, block, path: [ :vlan_vnis, id ]) do
101
+ store_unique_fact(data[:vlan_vnis], id, block, path: [ :vlan_vnis, id ]) do
101
102
  raise ArgumentError if mappings.size > 1
102
103
  vni = mappings.first && analyze(mappings.first) { integer(mappings.first.text.delete_prefix("vn-segment "), 1..16_777_215) }
103
104
  raise ArgumentError if vni && ids.uniq.size != 1
@@ -105,32 +106,35 @@ module Vxlan
105
106
  end
106
107
  end
107
108
  block.children.each { |child| ignore(child) unless mappings.include?(child) }
108
- when /\Avrf context (\S+)\z/ then store(data[:vrfs], $1, block, path: [ :vrfs, $1 ]) { vrf(block) }
109
+ when /\Avrf context (\S+)\z/ then store_unique_fact(data[:vrfs], $1, block, path: [ :vrfs, $1 ]) { vrf(block) }
109
110
  when "evpn"
110
111
  analyze_children(block) do |child|
111
112
  if (match = /\Avni (\d+) (l[23])\z/i.match(child.text))
112
113
  id = integer(match[1], 1..16_777_215)
113
- store(data[:evpn], id, child, path: [ :evpn, id ]) { vrf(child).merge(type: match[2].downcase) }
114
+ store_unique_fact(data[:evpn], id, child, path: [ :evpn, id ]) { vrf(child).merge(type: match[2].downcase) }
114
115
  else
115
116
  issue(child)
116
117
  end
117
118
  end
118
119
  when /\Avpc domain (\d+)\z/
119
- issue(block, :duplicate_vpc_domain) if data[:vpc_domain]
120
- id = integer($1, 1..1000)
121
- data[:vpc_domain] = scope(block, [ :vpc_domain ]) { vpc(block).merge(id: id) }
120
+ identifier = $1
121
+ store_global_scope(data, :vpc_domain, block, empty: nil, duplicate_code: :duplicate_vpc_domain) do
122
+ id = integer(identifier, 1..1000)
123
+ vpc(block).merge(id: id)
124
+ end
122
125
  when /\Arouter bgp (\d+)\z/
123
- asn = integer($1, 1..4_294_967_295)
124
- issue(block, :duplicate_bgp_process) if data[:bgp].any?
125
- data[:bgp] = scope(block, [ :bgp ]) { routing(block, :bgp).merge(asn: asn) }
126
- when /\Arouter ospf (\S+)\z/ then store(data[:ospf], $1, block, path: [ :ospf, $1 ]) { routing(block, :ospf) }
126
+ identifier = $1
127
+ store_global_scope(data, :bgp, block, empty: {}, duplicate_code: :duplicate_bgp_process) do
128
+ asn = integer(identifier, 1..4_294_967_295)
129
+ routing(block, :bgp).merge(asn: asn)
130
+ end
131
+ when /\Arouter ospf (\S+)\z/ then store_unique_fact(data[:ospf], $1, block, path: [ :ospf, $1 ]) { routing(block, :ospf) }
127
132
  when /\Aip prefix-list / then prefix_list(block, data[:prefix_lists], family: "ipv4")
128
133
  when /\Aipv6 prefix-list / then prefix_list(block, data[:ipv6_prefix_lists], family: "ipv6")
129
134
  when /\Aroute-map (\S+) (permit|deny) (\d+)\z/
130
135
  name, action, sequence = $1, $2, integer($3, 1..65_535)
131
136
  entries = (data[:route_maps][name] ||= [])
132
- issue(block, :duplicate_route_map_entry) if register_policy_sequence(:route_maps, name, sequence)
133
- entries << scope(block, [ :route_maps, name, entries.size ]) { route_map(block).merge(action: action, sequence: sequence) }
137
+ policy_entry(:route_maps, name, sequence, entries, block) { route_map(block).merge(action: action, sequence: sequence) }
134
138
  when /\Aip access-list (\S+)\z/
135
139
  name = $1
136
140
  entries = block.children.filter_map do |child|
@@ -145,7 +149,7 @@ module Vxlan
145
149
  end
146
150
  end
147
151
  end
148
- store(data[:acls], name, block, path: [ :acls, name ]) { entries }
152
+ store_unique_fact(data[:acls], name, block, path: [ :acls, name ]) { entries }
149
153
  when /\Aip route /
150
154
  data[:static_routes] << scope(block, [ :static_routes, data[:static_routes].size ]) { static_route(block) }
151
155
  when "nv overlay evpn", "no nv overlay evpn"
@@ -210,8 +214,8 @@ module Vxlan
210
214
  values = { peer_keepalive: {}, virtual_peer_link: {}, peer_gateway: false, peer_switch: false, layer3_peer_router: false }
211
215
  analyze_children(block) do |child|
212
216
  case child.text
213
- when /\Apeer-keepalive (.+)\z/ then assign(values, :peer_keepalive, pairs($1, %w[destination source vrf]), child)
214
- when /\Avirtual peer-link (.+)\z/ then assign(values, :virtual_peer_link, pairs($1, %w[destination source dscp]), child)
217
+ when /\Apeer-keepalive (.+)\z/ then assign(values, :peer_keepalive, parse_keyword_pairs($1, %w[destination source vrf]), child)
218
+ when /\Avirtual peer-link (.+)\z/ then assign(values, :virtual_peer_link, parse_keyword_pairs($1, %w[destination source dscp]), child)
215
219
  when "peer-gateway" then values[:peer_gateway] = true
216
220
  when "peer-switch" then values[:peer_switch] = true
217
221
  when "layer3 peer-router" then values[:layer3_peer_router] = true
@@ -224,12 +228,12 @@ module Vxlan
224
228
  end
225
229
 
226
230
  # 提取支持的键值配置对。
227
- def pairs(text, allowed)
231
+ def parse_keyword_pairs(text, allowed)
228
232
  words = text.split
229
233
  raise ArgumentError unless words.size.even? && (words.each_slice(2).map(&:first) - allowed).empty?
230
234
  values = words.each_slice(2).to_h.transform_keys(&:to_sym)
231
235
  raise ArgumentError unless values.size == words.size / 2
232
- %i[source destination].each { |key| values[key] = address(values[key]) if values[key] }
236
+ %i[source destination].each { |key| values[key] = ip_address(values[key]) if values[key] }
233
237
  integer(values[:dscp], 0..63) if values[:dscp]
234
238
  values
235
239
  end
@@ -238,7 +242,7 @@ module Vxlan
238
242
  def static_route(block)
239
243
  words = block.text.split
240
244
  raise ArgumentError if words.size < 4
241
- { prefix: prefix(words[2]), next_hop: words[3], options: words.drop(4).join(" ") }
245
+ { prefix: ipv4_prefix(words[2]), next_hop: words[3], options: words.drop(4).join(" ") }
242
246
  end
243
247
 
244
248
  # 解析十进制整数,并校验声明的边界。
@@ -247,13 +251,13 @@ module Vxlan
247
251
  CtxParser::Values.integer(value, min: range.min, max: range.max)
248
252
  end
249
253
 
250
- # 校验接口地址,同时保留网络前缀。
251
- def address(value)
254
+ # 校验并归一化 IPv4 或 IPv6 主机地址,不接受网络前缀。
255
+ def ip_address(value)
252
256
  CtxParser::Values.ip_address(value, allow_zone: true)
253
257
  end
254
258
 
255
259
  # 校验网络前缀,不静默修正错误语法。
256
- def prefix(value, preserve_host_bits: false) = CtxParser::Values.ipv4_prefix(value, preserve_host_bits: preserve_host_bits)
260
+ def ipv4_prefix(value, preserve_host_bits: false) = CtxParser::Values.ipv4_prefix(value, preserve_host_bits: preserve_host_bits)
257
261
 
258
262
  # 单值配置可重复确认,冲突后移除歧义字段并使两端来源失效。
259
263
  def assign(values, key, value, block)
@@ -276,15 +280,22 @@ module Vxlan
276
280
  issue(block, :conflicting_configuration_value)
277
281
  end
278
282
 
279
- # 保存已解析配置,同时保留其来源。
280
- def store(hash, key, block, path:)
283
+ # 全局单例共享唯一身份规则,歧义后恢复空形状供下游安全读取。
284
+ def store_global_scope(data, key, block, empty:, duplicate_code:)
285
+ store_unique_fact(@global_scopes, key, block, path: [ key ], duplicate_code: duplicate_code) { yield }
286
+ ensure
287
+ data[key] = @global_scopes.fetch(key, empty)
288
+ end
289
+
290
+ # 保存唯一身份的事实及来源;重复身份整体排除,后续声明不能恢复。
291
+ def store_unique_fact(hash, key, block, path:, duplicate_code: :duplicate_identity)
281
292
  identity = [ hash.object_id, key ]
282
293
  if hash.key?(key) || @conflicts[identity]
283
294
  @configuration_conflict_count += 1
284
295
  @conflicts[identity] = true
285
296
  hash.delete(key)
286
297
  @cursor.mark(@sources[identity].index, state: :invalid) if @sources[identity]
287
- issue(block, :duplicate_identity)
298
+ issue(block, duplicate_code)
288
299
  return
289
300
  end
290
301
  @sources[identity] = block
@@ -14,7 +14,7 @@ module Vxlan
14
14
  values, section = { attributes: {}, peer_links: [], members: [] }, nil
15
15
  labels = { "vPC domain id" => :domain_id, "Peer status" => :peer_status, "vPC keep-alive status" => :keepalive_status,
16
16
  "Configuration consistency status" => :consistency_status, "vPC role" => :role }
17
- lines(text) do |line, number|
17
+ each_content_line(text) do |line, number|
18
18
  if line.match?(/\Aid\s+Port\s+Status\s+Active vlans\z/i)
19
19
  section = :peer_links
20
20
  elsif line.match?(/\Aid\s+Port\s+Status\s+Consistency\s+Reason\s+Active vlans\z/i)
@@ -25,14 +25,14 @@ module Vxlan
25
25
  if section == :members
26
26
  member = /\A(\S+)\s+(.+?)\s{2,}([\d, .-]+)\z/.match(match[4])
27
27
  unless member
28
- reject(number)
28
+ unparsed_records(number)
29
29
  next
30
30
  end
31
31
  row.merge!(consistency: member[1], reason: member[2], active_vlans_summary: member[3])
32
32
  elsif match[4].match?(/\A[\d, .-]+\z/)
33
33
  row[:active_vlans_summary] = rest.join
34
34
  else
35
- reject(number)
35
+ unparsed_records(number)
36
36
  next
37
37
  end
38
38
  values[section] << row
@@ -46,18 +46,18 @@ module Vxlan
46
46
  if key
47
47
  @header = true
48
48
  if values.key?(key) || value.empty? || (key == :domain_id && !(value.match?(/\A\d+\z/) && (1..1000).cover?(value.to_i)))
49
- reject(number)
49
+ unparsed_records(number)
50
50
  else
51
51
  values[key] = key == :domain_id ? value.to_i : value
52
52
  end
53
53
  elsif label.match?(/\A(?:Per-vlan consistency status|Type-2 consistency status|Number of vPCs configured|Peer Gateway|Dual-active excluded VLANs|Graceful Consistency Check|Auto-recovery status|Delay-restore(?: SVI| Orphan-port)? status|Operational Layer3 Peer-router|Virtual-peerlink mode)\z/) && !value.empty?
54
- reject(number) if values[:attributes].key?(label)
54
+ unparsed_records(number) if values[:attributes].key?(label)
55
55
  values[:attributes][label] = value
56
56
  else
57
- reject(number)
57
+ unparsed_records(number)
58
58
  end
59
59
  elsif !line.match?(/\A(?:vPC Peer-link status|vPC status|Legend:|\(\*\) - local vPC is down, forwarding via vPC peer-link|Please check "show vpc consistency-parameters vpc <vpc-num>" for the|consistency reason of down vpc and for type-2 consistency reasons for|any vpc\.)\z/i)
60
- reject(number)
60
+ unparsed_records(number)
61
61
  end
62
62
  end
63
63
  return unless @header
@@ -66,9 +66,9 @@ module Vxlan
66
66
  values.values_at(:peer_links, :members).flatten.each { |row| row[:active_vlans_truncated] = row[:active_vlans_summary].include?("...") }
67
67
  count = values[:attributes]["Number of vPCs configured"]
68
68
  if count
69
- count.match?(/\A\d+\z/) ? mismatch(count.to_i, values[:members].size) : @issues << { code: :invalid_vpc_count }
69
+ count.match?(/\A\d+\z/) ? count_mismatch(count.to_i, values[:members].size) : @issues << { code: :invalid_vpc_count }
70
70
  end
71
- %i[peer_links members].each { |key| remove_duplicates(values[key], [ :id ]) }
71
+ %i[peer_links members].each { |key| exclude_ambiguous_records!(values[key], [ :id ]) }
72
72
  @rows << values
73
73
  end
74
74
  end
@@ -12,7 +12,7 @@ module Vxlan
12
12
  values = { addresses: [], acl_bindings: {}, anycast: false }
13
13
  analyze_children(block) do |child|
14
14
  case child.text
15
- when /\Aip address (\S+)(?: secondary)?\z/ then values[:addresses] << prefix($1, preserve_host_bits: true)
15
+ when /\Aip address (\S+)(?: secondary)?\z/ then values[:addresses] << ipv4_prefix($1, preserve_host_bits: true)
16
16
  when /\Aip unnumbered (loopback\d+)\z/i then assign(values, :unnumbered_interface, $1, child)
17
17
  when /\Avrf member (\S+)\z/ then assign(values, :vrf, $1, child)
18
18
  when /\Aip router ospf (\S+) area (\S+)\z/ then assign(values, :ospf, "#{$1} area #{$2}", child)
@@ -48,7 +48,7 @@ module Vxlan
48
48
  analyze_children(child) { |setting| nve_member(setting, member) }
49
49
  raise ArgumentError if member[:replication] == "bgp" && member[:multicast_group]
50
50
 
51
- store(values[:members], id, child, path: @source_path + [ :members, id ]) { member }
51
+ store_unique_fact(values[:members], id, child, path: @source_path + [ :members, id ]) { member }
52
52
  when /\Adescription (.+)\z/ then assign(values, :description, $1, child)
53
53
  when /\Aglobal mcast-group (\S+)\z/ then assign(values, :multicast_group, multicast_address($1), child)
54
54
  when /\Asource-interface (\S+)\z/ then assign(values, :source_interface, $1, child)
@@ -80,7 +80,7 @@ module Vxlan
80
80
 
81
81
  # 校验 VXLAN 复制使用的组播地址。
82
82
  def multicast_address(value)
83
- group = address(value)
83
+ group = ip_address(value)
84
84
  raise ArgumentError unless IPAddr.new("224.0.0.0/4").include?(group)
85
85
 
86
86
  group
@@ -12,24 +12,46 @@ module Vxlan
12
12
  return ignore(block) if /\A(?:ip|ipv6) prefix-list \S+ description .+\z/.match?(block.text)
13
13
  match = /\A(?:ip|ipv6) prefix-list (\S+) seq (\d+) (permit|deny) (\S+)(?: ge (\d+))?(?: le (\d+))?\z/.match(block.text)
14
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
15
  sequence = integer(match[2], 1..4_294_967_295)
21
16
  collection = family == "ipv6" ? :ipv6_prefix_lists : :prefix_lists
22
- raise ArgumentError if register_policy_sequence(collection, match[1], sequence)
23
- entries << { sequence: sequence, action: match[3], prefix: subnet, ge: ge, le: le }
24
- capture(block, entries.last, [ collection, match[1], entries.size - 1 ])
17
+ entries = (lists[match[1]] ||= [])
18
+ policy_entry(collection, match[1], sequence, entries, block) do
19
+ subnet = network_prefix(match[4], family: family)
20
+ length, width = IPAddr.new(subnet).prefix, family == "ipv6" ? 128 : 32
21
+ ge, le = match[5]&.to_i, match[6]&.to_i
22
+ raise ArgumentError if (ge && !(length..width).cover?(ge)) || (le && !((ge || length)..width).cover?(le))
23
+
24
+ { sequence: sequence, action: match[3], prefix: subnet, ge: ge, le: le }
25
+ end
25
26
  end
26
27
 
27
- # 单次解析用索引识别重复序号,避免长策略逐条扫描已有条目。
28
- def register_policy_sequence(collection, name, sequence)
28
+ # 用固定槽位排除冲突序号,避免反复扫描或移动整个策略数组。
29
+ def policy_entry(collection, name, sequence, entries, block)
29
30
  key = [ collection, name, sequence ]
30
- duplicate = @policy_sequences.key?(key)
31
- @policy_sequences[key] = true
32
- duplicate
31
+ if (previous = @policy_sequences[key])
32
+ entries[previous.fetch(:index)] = nil
33
+ @cursor.mark(previous.fetch(:block).index, state: :invalid)
34
+ code = collection == :route_maps ? :duplicate_route_map_entry : :invalid_configuration_value
35
+ return issue(block, code)
36
+ end
37
+ @policy_sequences[key] = { index: entries.size, block: block }
38
+ entries << nil
39
+ entry = yield
40
+ entries[-1] = entry
41
+ @policy_sources[entry] = block
42
+ entry
43
+ end
44
+
45
+ # 冲突处理完毕后按最终索引捕获来源,路径与公开结果保持一致。
46
+ def finalize_policies(data)
47
+ %i[prefix_lists ipv6_prefix_lists route_maps].each do |collection|
48
+ data.fetch(collection).each do |name, entries|
49
+ entries.compact!
50
+ entries.each_with_index do |entry, index|
51
+ capture(@policy_sources.fetch(entry), entry, [ collection, name, index ])
52
+ end
53
+ end
54
+ end
33
55
  end
34
56
 
35
57
  # 解析有序 route-map 条目及策略引用。
@@ -5,18 +5,28 @@ module Vxlan
5
5
  module Syntax
6
6
  # 路由作用域保留邻居、模板、VRF 与地址族的归属。
7
7
  module Routing
8
+ BGP_LEAF = /\A(?:no )?(?:remote-as|inherit peer|update-source|route-map|next-hop-self|route-reflector-client|advertise-pip|advertise l2vpn evpn|send-community|retain route-target)\b/
9
+ private_constant :BGP_LEAF
10
+
8
11
  private
9
12
 
10
13
  # 解析支持的路由作用域及协议关系。
11
- def routing(block, protocol, context = :process)
14
+ def routing(block, protocol, context = :process, family: nil, parent: nil)
12
15
  values = { vrfs: {}, neighbors: {}, templates: {}, address_families: {}, redistribute: [] }
13
16
  analyze_children(block) do |child|
17
+ next issue(child) if protocol != :bgp && BGP_LEAF.match?(child.text)
18
+ next issue(child) unless routing_leaf?(child.text, protocol, context, family, parent)
19
+
14
20
  case child.text
15
21
  when /\Avrf (\S+)\z/ then routing_scope(values, :vrfs, $1, child, protocol, context)
16
22
  when /\Atemplate peer (\S+)\z/ then routing_scope(values, :templates, $1, child, protocol, context)
17
23
  when /\Aneighbor (\S+)\z/ then routing_scope(values, :neighbors, $1, child, protocol, context)
18
24
  when /\Aaddress-family (.+)\z/ then routing_scope(values, :address_families, $1, child, protocol, context)
19
- when /\Arouter-id (\S+)\z/ then assign(values, :router_id, address($1), child)
25
+ when /\Arouter-id (\S+)\z/
26
+ router_id = ip_address($1)
27
+ raise ArgumentError unless IPAddr.new(router_id).ipv4?
28
+
29
+ assign(values, :router_id, router_id, child)
20
30
  when /\Aremote-as (\S+)\z/
21
31
  value = $1
22
32
  integer(value, 1..4_294_967_295) unless %w[internal external].include?(value)
@@ -42,9 +52,12 @@ module Vxlan
42
52
  assign(values, :extended_community, enabled, child) if kind
43
53
  when /\Anetwork (\S+)(?: route-map (\S+))?\z/
44
54
  subnet, route_map = $1, $2
45
- raise ArgumentError unless protocol == :bgp
55
+ unless protocol == :bgp && context == :address_families && %i[process vrfs].include?(parent) &&
56
+ /\Aipv[46] (?:unicast|multicast)\z/.match?(family)
57
+ next issue(child)
58
+ end
46
59
 
47
- (values[:networks] ||= []) << { prefix: network_prefix(subnet), route_map: route_map }.compact
60
+ (values[:networks] ||= []) << { prefix: network_prefix(subnet, family: family.split.first), route_map: route_map }.compact
48
61
  when /\Aredistribute (\S+)(?: (?!route-map\b)(\S+))?(?: route-map (\S+))?\z/
49
62
  values[:redistribute] << { protocol: $1, process: $2, route_map: $3 }.compact
50
63
  when /\Aretain route-target (all|route-map \S+)\z/ then assign(values, :retain_route_target, $1, child)
@@ -56,18 +69,44 @@ module Vxlan
56
69
  values
57
70
  end
58
71
 
72
+ # 已建模叶子必须位于其使用方作用域,不能只因文字可解析就接受事实。
73
+ def routing_leaf?(text, protocol, context, family, parent)
74
+ process_family = context == :address_families && %i[process vrfs].include?(parent)
75
+ case text
76
+ when /\A(?:remote-as|inherit peer|update-source)\b/
77
+ protocol == :bgp && %i[neighbors templates].include?(context)
78
+ when /\Aroute-map\b/
79
+ protocol == :bgp && context == :address_families && %i[neighbors templates].include?(parent)
80
+ when /\Aredistribute\b/
81
+ if protocol == :ospf
82
+ %i[process vrfs].include?(context)
83
+ else
84
+ process_family && /\Aipv[46] (?:unicast|multicast)\z/.match?(family)
85
+ end
86
+ when /\Aretain route-target\b/
87
+ protocol == :bgp && process_family && family == "l2vpn evpn"
88
+ when /\Arouter-id\b/
89
+ %i[process vrfs].include?(context)
90
+ else true
91
+ end
92
+ end
93
+
59
94
  # 只进入当前事实模型可表达的子作用域,阻止任意递归及跨协议误归属。
60
95
  def routing_scope(values, kind, name, block, protocol, context)
61
96
  supported = case kind
62
97
  when :vrfs then context == :process
63
98
  when :templates then protocol == :bgp && context == :process
64
99
  when :neighbors then protocol == :bgp && %i[process vrfs].include?(context)
65
- when :address_families then protocol == :bgp && %i[process vrfs neighbors templates].include?(context)
100
+ when :address_families
101
+ protocol == :bgp && %i[process vrfs neighbors templates].include?(context) &&
102
+ /\A(?:ipv[46] (?:unicast|multicast)|vpnv[46] unicast|l2vpn evpn)\z/.match?(name)
66
103
  end
67
104
  return issue(block) unless supported
68
105
 
69
106
  conflicts_before = @configuration_conflict_count
70
- store(values[kind], name, block, path: @source_path + [ kind, name ]) { routing(block, protocol, kind) }
107
+ store_unique_fact(values[kind], name, block, path: @source_path + [ kind, name ]) do
108
+ routing(block, protocol, kind, family: kind == :address_families ? name : nil, parent: context)
109
+ end
71
110
  # 邻居及模板的子地址族冲突也使继承身份失效;保留同进程其他邻居。
72
111
  if protocol == :bgp && %i[neighbors templates].include?(kind) && @configuration_conflict_count > conflicts_before
73
112
  exclude_conflicted_scope(values[kind], name, block)
@@ -8,7 +8,9 @@ module Vxlan
8
8
  private_constant :INVALID
9
9
 
10
10
  # 检查原始正文,避免 strip 或字段解析先吞掉控制字符。
11
- def self.invalid_output?(text) = INVALID.match?(text)
11
+ def self.invalid_output?(text, allow_cr_newlines: false)
12
+ INVALID.match?(text) || (!allow_cr_newlines && /\r(?!\n)/.match?(text))
13
+ end
12
14
  end
13
15
  end
14
16
  end
@@ -34,7 +34,7 @@ module Vxlan
34
34
  begin
35
35
  connector.open do |session|
36
36
  reader = CtxDriver::Reader.new(session: session, clock: @clock)
37
- if (code = prepare(reader))
37
+ if (code = prepare_terminal(reader))
38
38
  dimensions.each { |name| observations[name] = failure(name, code) }
39
39
  observations[:collection] = failure(:collection, code)
40
40
  next
@@ -45,7 +45,7 @@ module Vxlan
45
45
  observations[name] = Observation.new(name: name, status: :complete, completed_at: @clock.call,
46
46
  issues: [ { code: :vpc_not_configured } ])
47
47
  else
48
- observations[name] = read(reader, name, config, profile)
48
+ observations[name] = collect_observation(reader, name, config, profile)
49
49
  end
50
50
  end
51
51
  session_finished = true
@@ -72,7 +72,7 @@ module Vxlan
72
72
  private
73
73
 
74
74
  # 采集设备回显前,禁用分页并归一化终端宽度。
75
- def prepare(reader)
75
+ def prepare_terminal(reader)
76
76
  PREPARATION_COMMANDS.each do |command|
77
77
  capture = reader.read(command)
78
78
  return capture.failure.fetch(:code) unless capture.success?
@@ -81,7 +81,7 @@ module Vxlan
81
81
  end
82
82
 
83
83
  # 解析失败限定在单条查询内;Reader 负责传输与回执校验。
84
- def read(reader, name, configuration, profile)
84
+ def collect_observation(reader, name, configuration, profile)
85
85
  command = profile.command(name)
86
86
  capture = reader.read(command)
87
87
  metadata = { started_at: capture.started_at, completed_at: capture.completed_at, evidence: capture.evidence }
@@ -15,6 +15,7 @@ module Vxlan
15
15
 
16
16
  nve_members_by_vni = @configuration.fetch(:nve).fetch(:members, {})
17
17
  vlan_mappings_by_vni = @configuration.fetch(:vlan_vnis).group_by { |_vlan, vni| vni }
18
+ vrf_vnis = @configuration.fetch(:vrfs).values.filter_map { |settings| settings[:vni] }.to_set
18
19
  evpn_vnis = @configuration.fetch(:evpn)
19
20
  findings = []
20
21
  vlan_mappings_by_vni.each do |vni, entries|
@@ -23,14 +24,24 @@ module Vxlan
23
24
  findings << { code: :vlan_vni_without_nve, vni: vni, vlan_ids: entries.map(&:first).sort }
24
25
  end
25
26
  nve_members_by_vni.each do |vni, settings|
26
- next if settings[:associate_vrf]
27
+ if settings[:associate_vrf]
28
+ findings << { code: :nve_l3_without_vrf, vni: vni } unless vrf_vnis.include?(vni)
29
+ next
30
+ end
27
31
 
28
32
  findings << { code: :nve_l2_without_vlan, vni: vni } unless vlan_mappings_by_vni.key?(vni)
29
33
  if @configuration[:nv_overlay_evpn] && !evpn_vnis.key?(vni)
30
34
  findings << { code: :nve_l2_without_evpn, vni: vni }
31
35
  end
32
36
  end
33
- evpn_vnis.each_key { |vni| findings << { code: :evpn_vni_without_nve, vni: vni } unless nve_members_by_vni.key?(vni) }
37
+ evpn_vnis.each do |vni, settings|
38
+ member = nve_members_by_vni[vni]
39
+ if !member
40
+ findings << { code: :evpn_vni_without_nve, vni: vni }
41
+ elsif (settings[:type] == "l3") != member[:associate_vrf]
42
+ findings << { code: :vni_layer_conflict, vni: vni, evpn_type: settings[:type] }
43
+ end
44
+ end
34
45
  @configuration.fetch(:vrfs).each do |name, settings|
35
46
  next unless settings[:vni]
36
47
  member = nve_members_by_vni[settings[:vni]]