vxlan 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52225eb340f37fe90fbfc251436c8ee3311c636f11f7ed3693c9d72f2a49a87e
4
- data.tar.gz: b108dd357215c64fe29ee5e2f68a6f0595fcfe0b1530fdb1731bec772c50bff8
3
+ metadata.gz: ec6112b96a159497c2b87ac94ef257e7a3baf710a43b9449ff450f757afe3c36
4
+ data.tar.gz: c8766a7269d4dab9bbacc40026a045fcaf9522fb8052145e22f7d61619919dfb
5
5
  SHA512:
6
- metadata.gz: af783c1a3288bc317659d38f69143ea99b2a34b24e0fac2db6bb5269f78f3ca9231cdb064df434d546216f3aa20af776793a886949bc7a0658745cf93575f043
7
- data.tar.gz: 37d06a9e9405aeb8120e4be6c8027c23a04db700a9a8cdce87f03747c24b19fbdca9c4949c9f43ae77927df6728fbbf4c93063dc483cc3af7618bd7dd4f9b956
6
+ metadata.gz: d40b5df068d87188cf5eda73ac5cbd4cbb70bc4133424caf641f9cc0a4a6f7bdbe0b7a1439682a0b7b54d266de4c425f712fcc13d721a017b7fefa9d2edf596a
7
+ data.tar.gz: c4dc37ebef89f10a0692b26510f46b74f65e54a31bf7be3f134e84732bc45327239568bdc4acbdedc85fba25d188c53ef3f4addfec8bfef6f9c522bab6acdc5c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## 0.2.2 (2026-10-18)
6
+
7
+ ### Changed
8
+
9
+ - Name mutable NX-OS parsing state `CiscoNxos::ConfigurationState`; retain the former `Configuration` constant, require path, and `finish` method as compatible aliases.
10
+ - Clarify internal method names for conflict exclusion, diagnostics, address conversion, route-map consumers, and observation collection without changing parsed record fields.
11
+
12
+ ### Fixed
13
+
14
+ - Exclude repeated prefix-list and route-map sequence identities, including malformed payloads, without restoring them later; preserve independent entries and capture their final array paths.
15
+ - Validate routing leaf ownership, supported address families, network prefix families, and IPv4 router IDs before accepting facts or projecting policy consumers.
16
+ - Exclude repeated hostname, BGP process, NVE interface, and vPC domain identities without restoring them on later declarations; invalidate their original source captures.
17
+ - Stop configured neighbor and policy projections through unsupported peer-template inheritance chains.
18
+ - Reject bare carriage returns in runtime output while preserving CRLF and existing configuration newline handling.
19
+ - Scan inline EVPN next hops by byte offset to avoid repeated copies of the remaining line.
20
+ - Report explicit EVPN/NVE layer mismatches and L3 NVE members without a matching configured VRF, independently of syntax completeness; retain valid VLAN-backed L3 VNIs.
21
+
22
+ ## 0.2.1 (2026-10-17)
23
+
24
+ - Require ctx_parser and ctx_driver 0.2.x so configuration APIs and command capture behavior resolve consistently.
25
+
5
26
  ## 0.2.0 (2026-10-17)
6
27
 
7
28
  ### Changed
data/README-zh.md CHANGED
@@ -21,7 +21,7 @@ gem "vxlan"
21
21
 
22
22
  然后执行 `bundle install`;也可直接执行 `gem install vxlan`。Ruby 加载入口为 `vxlan`,公开命名空间为 `Vxlan`;gem 包名与 Ruby 命名空间分别维护。
23
23
 
24
- 模块运行依赖:[`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver)。 gemspec 的运行依赖不添加版本约束;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
24
+ 模块运行依赖:[`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver)。 共享 Gatework 依赖要求兼容的 0.2.x API;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
25
25
 
26
26
  发布产物和校验值见 [GitHub Releases](https://github.com/gatework/vxlan/releases),已发布版本以 [RubyGems](https://rubygems.org/gems/vxlan) 为准。
27
27
 
@@ -72,10 +72,18 @@ observation.records.each { |record| p record }
72
72
 
73
73
  VLAN ID、VNI、VRF、NVE 成员是不同身份。配置审查通过 VNI 关联 VLAN 映射与 NVE 成员,不推断运行可达性。`BgpNeighbors` 枚举应用 peer-template 继承后的配置邻居,不代表已建立的 BGP 会话。`PrefixListReferences` 表达 route-map 选择器及路由使用方的引用关系,引用本身不能证明路由被接受。
74
74
 
75
+ 关联诊断包括:显式 EVPN 或 VRF 定义与 NVE 成员二层/三层角色矛盾时的 `vni_layer_conflict`,以及三层 NVE 成员没有对应 VNI 的配置 VRF 时的 `nve_l3_without_vrf`。VLAN 映射本身不能证明二层角色,支持通过 VLAN 承载的三层 VNI。这些诊断仅基于完整配置证据生成,不改变语法解析状态。
76
+
77
+ 路由事实保留协议及所属作用域。`network` 必须位于匹配的 IPv4/IPv6 进程或 VRF 地址族中;邻居策略映射属于邻居或 peer-template 的地址族。不支持的路由地址族及位置错误的设置保持部分证据,不生成对应策略使用方。
78
+
79
+ prefix-list 或 route-map 在同种类、同名称内出现重复序号时,排除该序号的全部声明,后续声明不能恢复。独立序号仍作为部分事实保留,来源捕获路径使用最终结果索引;部分策略条目不能证明整份策略的行为。
80
+
75
81
  ## 开发与测试
76
82
 
77
83
  共享类按实际职责命名,平台信息放在命名空间中。直接对应设备命令的解析器保留文件名和类名。
78
84
 
85
+ `CiscoNxos::ConfigurationState` 持有单次解析期间的可变事实,`Vxlan::Configuration` 是公开解析入口。原 `CiscoNxos::Configuration` 常量及其 `finish` 方法保留为兼容别名。内部方法名区分文本解析(`parse_next_hops`)、诊断记录(`unparsed_records`)和冲突排除(`exclude_ambiguous_records!`,移除重复身份的全部记录)。
86
+
79
87
  ```sh
80
88
  bundle install
81
89
  bundle exec rake
data/README.md CHANGED
@@ -21,7 +21,7 @@ gem "vxlan"
21
21
 
22
22
  Then run `bundle install`, or install directly with `gem install vxlan`. The require path is `vxlan` and the public namespace is `Vxlan`; package names and Ruby namespaces are intentionally separate.
23
23
 
24
- Internal runtime dependencies: [`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver). The gemspec declares runtime dependencies without version constraints. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
24
+ Internal runtime dependencies: [`ctx_parser`](https://github.com/gatework/ctx_parser), [`ctx_driver`](https://github.com/gatework/ctx_driver). Shared Gatework dependencies require the compatible 0.2.x API. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
25
25
 
26
26
  Release artifacts and checksums are available from [GitHub Releases](https://github.com/gatework/vxlan/releases). Check [RubyGems](https://rubygems.org/gems/vxlan) for published versions.
27
27
 
@@ -72,10 +72,18 @@ The lifecycle connector is a host adapter exposing `open { |session| }` (and `pl
72
72
 
73
73
  VLAN IDs, VNIs, VRFs, and NVE members are separate identities. Configuration review joins VLAN mappings and NVE members by VNI; it does not infer operational reachability. `BgpNeighbors` enumerates configured neighbors after peer-template inheritance, not established BGP sessions. `PrefixListReferences` describes route-map selectors and their routing consumers; those references do not by themselves prove route acceptance.
74
74
 
75
+ Association findings include `vni_layer_conflict` for explicit EVPN or VRF definitions that disagree with the NVE member's L2/L3 role, and `nve_l3_without_vrf` for an L3 NVE member without a configured VRF using its VNI. A VLAN mapping alone does not establish an L2 role: VLAN-backed L3 VNIs are supported. These findings are emitted only from complete configuration evidence and do not change the syntax status.
76
+
77
+ Routing facts retain their protocol and owner scope. Network statements require a matching IPv4/IPv6 process or VRF address family; neighbor policy maps belong to neighbor or peer-template address families. Unsupported routing families and misplaced settings remain partial evidence and produce no corresponding policy consumer.
78
+
79
+ Repeated prefix-list or route-map sequence identities are excluded within their policy name and kind, including subsequent declarations of that sequence. Independent sequences remain available as partial facts; their source capture paths use the final result indices. These partial policy entries do not establish the complete policy's behavior.
80
+
75
81
  ## Development and tests
76
82
 
77
83
  Name shared classes by their responsibility and use the namespace for platform context. Preserve filenames and class names that directly match device commands.
78
84
 
85
+ `CiscoNxos::ConfigurationState` holds mutable facts during a single parse; `Vxlan::Configuration` is the public parsing entry point. The former `CiscoNxos::Configuration` constant and its `finish` method remain compatible aliases. Internal method names distinguish parsing (`parse_next_hops`), diagnostics (`unparsed_records`), and conflict exclusion (`exclude_ambiguous_records!`, which removes every record with a repeated identity).
86
+
79
87
  ```sh
80
88
  bundle install
81
89
  bundle exec rake
@@ -49,7 +49,7 @@ bundle exec rake 'motor:netstack:vxlan[456,LAB]'
49
49
 
50
50
  Cisco N9K 的具体策略位于 [`lib/vxlan/cisco_nxos`](../lib/vxlan/cisco_nxos/profile.rb),按命令拆分为 `parsers/show_ip_ospf_neighbors.rb` 等独立文件。直接调用 `Vxlan::CiscoNxos::Parsers::ShowIpOspfNeighbors.parse(cli_output, complete: true)` 即可离线解析。`ParserRegistry.fetch(:cisco_nxos, model: device.model)` 选择平台与型号策略;命令清单和解析类统一在 `CiscoNxos::Profile` 中声明。原解析入口继续转换为 `Vxlan::Observation`。公共接口、结果对象和扩展方式见 [`../ctx_parser`](https://github.com/gatework/ctx_parser)。
51
51
 
52
- 配置解析按职责组合:`ShowRunningConfig` 编排命令解析,`Syntax::Interfaces/Routing/Policies` 分别解释接口/NVE、路由上下文及策略语法,`CtxParser::Configuration` 管理逐行状态、嵌套来源路径与捕获,`CiscoNxos::Configuration` 在全部块解析后归约特性列表、校验 BGP 模板引用并关联 SVI/VLAN/VNI 网段。接口地址与网段使用 `CtxParser::Values.ipv4_prefix` 的同一校验。重复身份仍排除歧义记录;语法未知、引用不完整与来源行号继续进入原有 `Result`,供入库边界判断。
52
+ 配置解析按职责组合:`ShowRunningConfig` 编排命令解析,`Syntax::Interfaces/Routing/Policies` 分别解释接口/NVE、路由上下文及策略语法,`CtxParser::Configuration` 管理逐行状态、嵌套来源路径与捕获,`CiscoNxos::ConfigurationState` 持有单次解析状态(旧名 `CiscoNxos::Configuration` 保留为兼容别名),在全部块解析后归约特性列表、校验 BGP 模板引用并关联 SVI/VLAN/VNI 网段。接口地址与网段使用 `CtxParser::Values.ipv4_prefix` 的同一校验。重复身份仍排除歧义记录;语法未知、引用不完整与来源行号继续进入原有 `Result`,供入库边界判断。
53
53
 
54
54
  设备连接默认关闭旧版 SSH 算法协商。确认单台旧设备确有算法兼容需求后,可在其现有 `settings["connector"]["legacy_ssh"]` 中显式保存布尔值 `true`;省略或 `false` 保持关闭,字符串、NULL 等非布尔值会在连接前拒绝。该设置直接传给 net-connector,主机密钥仍严格核验 `storage/net_connector/known_hosts`,不启用 Telnet 回退。连接异常仅保留允许列表中的诊断码(例如 `unsupported_host_key_algorithm`),未知码归为 `connection_failed`;VXLAN 未采集维度保存该原因,不保存底层异常文案或 cause。
55
55
 
@@ -10,7 +10,7 @@ module Vxlan
10
10
  @bgp = bgp
11
11
  end
12
12
 
13
- # 应用继承的对等体模板配置后,逐个返回实际生效的 BGP 邻居。
13
+ # 合并受支持的对等体模板后,逐个返回配置邻居,不推断运行会话状态。
14
14
  def each
15
15
  return enum_for(:each) unless block_given?
16
16
 
@@ -22,6 +22,9 @@ module Vxlan
22
22
  next if inherited_name && !peer_templates.key?(inherited_name)
23
23
 
24
24
  peer_template = peer_templates.fetch(inherited_name, {})
25
+ # 模板自身继承尚不受支持,不能把部分模板当作已解析配置。
26
+ next if peer_template[:inherit_peer]
27
+
25
28
  address_families = peer_template.fetch(:address_families, {}).merge(neighbor_configuration.fetch(:address_families, {})) do |_name, template_family, neighbor_family|
26
29
  template_family.merge(neighbor_family.compact).merge(route_maps: template_family.fetch(:route_maps, {}).merge(neighbor_family.fetch(:route_maps, {})))
27
30
  end
@@ -1,84 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ctx_parser/values"
3
+ require_relative "configuration_state"
4
4
 
5
5
  module Vxlan
6
6
  module CiscoNxos
7
- # 归纳 NX-OS 模板引用、功能身份及 SVI、VLAN、VNI 子网关系。
8
- class Configuration
9
- attr_reader :data, :issues
10
-
11
- # 初始化单次配置解析使用的可变 NX-OS 事实容器。
12
- def initialize
13
- @data = { hostname: nil, features: [], disabled_features: [], breakouts: {}, interfaces: {}, vlan_vnis: {}, vrfs: {}, nve: { members: {} },
14
- evpn: {}, bgp: {}, ospf: {}, vpc_domain: nil, unnumbered_addresses: {}, prefix_lists: {}, ipv6_prefix_lists: {}, route_maps: {}, acls: {}, static_routes: [] }
15
- @issues = []
16
- end
17
-
18
- # 解析结束后关联跨块引用,避免声明顺序影响结果。
19
- def finish
20
- validate_templates
21
- resolve_unnumbered
22
- issues << { code: :conflicting_feature_state } if (data[:features] & data[:disabled_features]).any?
23
- issues << { code: :hostname_missing } unless data[:hostname]
24
- data[:features] = data[:features].uniq.sort
25
- data[:disabled_features] = data[:disabled_features].uniq.sort
26
- data[:segments] = segments
27
- data
28
- end
29
-
30
- # 展示对象身份与状态,避免泄露原始回显或凭据。
31
- def inspect = "#<#{self.class} interfaces=#{data[:interfaces].size} issues=#{issues.size}>"
32
-
33
- private
34
-
35
- # 借用的 IPv4 地址仅作为主机身份,不猜测链路网段。
36
- def resolve_unnumbered
37
- interfaces = data[:interfaces]
38
- by_name = interfaces.group_by { |name, _values| name.downcase }
39
- interfaces.each do |name, values|
40
- next unless values[:unnumbered_interface]
41
-
42
- sources = by_name.fetch(values[:unnumbered_interface].downcase, [])
43
- source = sources.one? ? sources.first.last : nil
44
- if source && source[:addresses].one? && values[:addresses].empty? &&
45
- !source[:unnumbered_interface] && source.fetch(:vrf, "default") == values.fetch(:vrf, "default")
46
- data[:unnumbered_addresses][name] = [ "#{source[:addresses].first.split('/').first}/32" ]
47
- else
48
- issues << { code: :unresolved_unnumbered_interface, interface: name }
49
- end
50
- end
51
- end
52
-
53
- # 报告未解析的继承模板,不虚构模板配置。
54
- def validate_templates
55
- bgp = data[:bgp]
56
- templates = bgp.fetch(:templates, {})
57
- issues << { code: :unsupported_peer_inheritance } if templates.values.any? { |settings| settings[:inherit_peer] }
58
- [ bgp, *bgp.fetch(:vrfs, {}).values ].each do |scope|
59
- scope.fetch(:neighbors, {}).each_value do |neighbor|
60
- next unless neighbor[:inherit_peer] && !templates.key?(neighbor[:inherit_peer])
61
-
62
- issues << { code: :missing_peer_template }
63
- end
64
- end
65
- end
66
-
67
- # 根据显式配置构建 VLAN 与 VNI 网段关联。
68
- def segments
69
- data[:interfaces].filter_map do |name, values|
70
- match = /\AVlan(\d+)\z/.match(name)
71
- next unless match && data[:vlan_vnis][match[1].to_i] && values[:addresses].any?
72
- unless values[:addresses].one? && values[:vrf]
73
- issues << { code: :unsupported_svi, interface: name }
74
- next
75
- end
76
- source = values[:addresses].first
77
- { vlan_id: match[1].to_i, vni: data[:vlan_vnis].fetch(match[1].to_i), vrf: values[:vrf],
78
- prefix: CtxParser::Values.ipv4_prefix(source), gateway: source.split("/").first,
79
- anycast: values[:anycast], acl_bindings: values[:acl_bindings] }
80
- end
81
- end
82
- end
7
+ # 兼容既有加载路径;配置解析状态由 ConfigurationState 表达。
8
+ Configuration = ConfigurationState
83
9
  end
84
10
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/values"
4
+
5
+ module Vxlan
6
+ module CiscoNxos
7
+ # 归纳 NX-OS 模板引用、功能身份及 SVI、VLAN、VNI 子网关系。
8
+ class ConfigurationState
9
+ attr_reader :data, :issues
10
+
11
+ # 初始化单次配置解析使用的可变 NX-OS 事实容器。
12
+ def initialize
13
+ @data = { hostname: nil, features: [], disabled_features: [], breakouts: {}, interfaces: {}, vlan_vnis: {}, vrfs: {}, nve: { members: {} },
14
+ evpn: {}, bgp: {}, ospf: {}, vpc_domain: nil, unnumbered_addresses: {}, prefix_lists: {}, ipv6_prefix_lists: {}, route_maps: {}, acls: {}, static_routes: [] }
15
+ @issues = []
16
+ end
17
+
18
+ # 解析结束后关联跨块引用,避免声明顺序影响结果。
19
+ def finalize_facts
20
+ peer_template_issues
21
+ resolve_unnumbered_addresses
22
+ issues << { code: :conflicting_feature_state } if (data[:features] & data[:disabled_features]).any?
23
+ issues << { code: :hostname_missing } unless data[:hostname]
24
+ data[:features] = data[:features].uniq.sort
25
+ data[:disabled_features] = data[:disabled_features].uniq.sort
26
+ data[:segments] = build_svi_segments
27
+ data
28
+ end
29
+
30
+ # 兼容原有解析状态的收尾入口。
31
+ alias finish finalize_facts
32
+
33
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
34
+ def inspect = "#<#{self.class} interfaces=#{data[:interfaces].size} issues=#{issues.size}>"
35
+
36
+ private
37
+
38
+ # 借用的 IPv4 地址仅作为主机身份,不猜测链路网段。
39
+ def resolve_unnumbered_addresses
40
+ interfaces = data[:interfaces]
41
+ by_name = interfaces.group_by { |name, _values| name.downcase }
42
+ interfaces.each do |name, values|
43
+ next unless values[:unnumbered_interface]
44
+
45
+ sources = by_name.fetch(values[:unnumbered_interface].downcase, [])
46
+ source = sources.one? ? sources.first.last : nil
47
+ if source && source[:addresses].one? && values[:addresses].empty? &&
48
+ !source[:unnumbered_interface] && source.fetch(:vrf, "default") == values.fetch(:vrf, "default")
49
+ data[:unnumbered_addresses][name] = [ "#{source[:addresses].first.split('/').first}/32" ]
50
+ else
51
+ issues << { code: :unresolved_unnumbered_interface, interface: name }
52
+ end
53
+ end
54
+ end
55
+
56
+ # 报告未解析的继承模板,不虚构模板配置。
57
+ def peer_template_issues
58
+ bgp = data[:bgp]
59
+ templates = bgp.fetch(:templates, {})
60
+ issues << { code: :unsupported_peer_inheritance } if templates.values.any? { |settings| settings[:inherit_peer] }
61
+ [ bgp, *bgp.fetch(:vrfs, {}).values ].each do |scope|
62
+ scope.fetch(:neighbors, {}).each_value do |neighbor|
63
+ next unless neighbor[:inherit_peer] && !templates.key?(neighbor[:inherit_peer])
64
+
65
+ issues << { code: :missing_peer_template }
66
+ end
67
+ end
68
+ end
69
+
70
+ # 根据显式配置构建 VLAN 与 VNI 网段关联。
71
+ def build_svi_segments
72
+ data[:interfaces].filter_map do |name, values|
73
+ match = /\AVlan(\d+)\z/.match(name)
74
+ next unless match && data[:vlan_vnis][match[1].to_i] && values[:addresses].any?
75
+ unless values[:addresses].one? && values[:vrf]
76
+ issues << { code: :unsupported_svi, interface: name }
77
+ next
78
+ end
79
+ source = values[:addresses].first
80
+ { vlan_id: match[1].to_i, vni: data[:vlan_vnis].fetch(match[1].to_i), vrf: values[:vrf],
81
+ prefix: CtxParser::Values.ipv4_prefix(source), gateway: source.split("/").first,
82
+ anycast: values[:anycast], acl_bindings: values[:acl_bindings] }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -15,7 +15,7 @@ module Vxlan
15
15
  @banner ||= CtxParser::Banner.new
16
16
  return :ignored if @banner.consume(line.text)
17
17
 
18
- if Terminal.invalid_output?(line.raw)
18
+ if Terminal.invalid_output?(line.raw, allow_cr_newlines: true)
19
19
  issues << { code: :incomplete_configuration, line: line.number }
20
20
  return :ignored
21
21
  end
@@ -11,6 +11,9 @@ module Vxlan
11
11
  "host ip" => :host_ip, "prod" => :producer, "flags" => :flags, "seq no" => :sequence,
12
12
  "next-hops" => :next_hops, "next hop (s)" => :next_hops, "next hop(s)" => :next_hops }.freeze
13
13
  HEADER = /Topology ID|Topology|Mac Address|Host IP|Prod|Flags|Seq No|Next-Hops|Next Hop \(s\)|Next Hop\(s\)/i
14
+ NEXT_HOP = /\G(?<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
15
+ NEXT_HOP_SEPARATOR = /\G(?:\s*,\s*|\s+)/
16
+ private_constant :NEXT_HOP, :NEXT_HOP_SEPARATOR
14
17
 
15
18
  private
16
19
 
@@ -44,11 +47,11 @@ module Vxlan
44
47
  if fields && line.match?(/\A\d+\s/)
45
48
  values = line.split(/\s+/, fields.size)
46
49
  raise ArgumentError unless values.size == fields.size
47
- previous = route(fields.zip(values).to_h, type)
50
+ previous = parse_route_fields(fields.zip(values).to_h, type)
48
51
  next_hop_index = nil
49
52
  @rows << previous
50
53
  elsif previous && source.match?(/\A\s+/)
51
- hops = next_hops(line)
54
+ hops = parse_next_hops(line)
52
55
  # 首条续行建立索引,后续仅追加新下一跳;非法续行不会改动已有事实。
53
56
  unless next_hop_index
54
57
  previous[:next_hops].uniq!
@@ -73,8 +76,8 @@ module Vxlan
73
76
 
74
77
  # 汇总已解析记录,并报告未解决或不完整的来源状态。
75
78
  def finish(complete)
76
- @totals.each { |count| mismatch(count, @rows.size) }
77
- remove_duplicates(@rows, %i[topology_id mac host_ip producer])
79
+ @totals.each { |count| count_mismatch(count, @rows.size) }
80
+ exclude_ambiguous_records!(@rows, %i[topology_id mac host_ip producer])
78
81
  @issues << { code: :contradictory_empty } if @empty && @rows.any?
79
82
  @issues << { code: :missing_completeness_evidence } if @rows.empty? && !@empty && @totals != [ 0 ]
80
83
  @issues << { code: :output_incomplete } unless complete
@@ -82,47 +85,53 @@ module Vxlan
82
85
  end
83
86
 
84
87
  # 归一化单条 EVPN 路由及回显的身份字段。
85
- def route(values, type)
86
- values[:topology_id] = number(values[:topology_id])
88
+ def parse_route_fields(values, type)
89
+ values[:topology_id] = parse_uint32(values[:topology_id])
87
90
  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)
88
91
  values[:mac] = CtxParser::Values.mac_address(values[:mac])
89
- values[:sequence] = number(values[:sequence]) if values[:sequence]
90
- values[:host_ip] = ip_address(values.fetch(:host_ip)) if type == :mac_ip
91
- values[:next_hops] = next_hops(values.fetch(:next_hops))
92
+ values[:sequence] = parse_uint32(values[:sequence]) if values[:sequence]
93
+ values[:host_ip] = parse_ip_address(values.fetch(:host_ip)) if type == :mac_ip
94
+ values[:next_hops] = parse_next_hops(values.fetch(:next_hops))
92
95
  values
93
96
  end
94
97
 
95
98
  # 归一化 EVPN 路由关联的全部下一跳。
96
- def next_hops(text)
99
+ def parse_next_hops(text)
97
100
  result = []
98
- until text.empty?
99
- 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)
101
+ # 字节视图让偏移定位保持线性;逐项扫描,不重复复制剩余长行。
102
+ encoding = text.encoding
103
+ text = text.b
104
+ offset = 0
105
+ while offset < text.bytesize
106
+ match = NEXT_HOP.match(text, offset)
100
107
  raise ArgumentError unless match
101
- token = match[:hop]
108
+ token = match[:hop].force_encoding(encoding)
102
109
  value = case token
103
110
  when /\ALocal\z/i then { local: true }
104
- when /\AIfindex\s+(\d+)\z/i then { ifindex: number($1) }
111
+ when /\AIfindex\s+(\d+)\z/i then { ifindex: parse_uint32($1) }
105
112
  when /\A(?:Eth|Po|nve|Lo|Vlan|vPC)/i then { interface: token }
106
- else { address: ip_address(token) }
113
+ else { address: parse_ip_address(token) }
107
114
  end
108
- value[:label] = number(match[:label]) if match[:label]
115
+ value[:label] = parse_uint32(match[:label]) if match[:label]
109
116
  result << value
110
- remainder = text[match.end(0)..]
111
- break if remainder.empty?
112
- text = remainder.sub(/\A(?:\s*,\s*|\s+)/, "")
113
- raise ArgumentError if text.empty? || text.start_with?(",")
117
+ offset = match.end(0)
118
+ break if offset == text.bytesize
119
+
120
+ separator = NEXT_HOP_SEPARATOR.match(text, offset)
121
+ raise ArgumentError unless separator && separator.end(0) < text.bytesize
122
+ offset = separator.end(0)
114
123
  end
115
124
  raise ArgumentError if result.empty?
116
125
  result
117
126
  end
118
127
 
119
128
  # 解析数值测量,不将未知文本转换为零。
120
- def number(value)
129
+ def parse_uint32(value)
121
130
  CtxParser::Values.integer(value, max: UINT32.end)
122
131
  end
123
132
 
124
133
  # 归一化主机地址,保留 IPv4 与 IPv6 语义。
125
- def ip_address(value)
134
+ def parse_ip_address(value)
126
135
  CtxParser::Values.ip_address(value, allow_zone: true)
127
136
  end
128
137
  end
@@ -53,7 +53,7 @@ module Vxlan
53
53
  @issues << { code: :format_unrecognized } unless @header || @empty
54
54
  @issues << { code: :contradictory_empty } if @empty && @rows.any?
55
55
  @issues << { code: :output_incomplete } unless complete
56
- remove_duplicates(@rows, self.class::IDENTITY) if self.class::IDENTITY
56
+ exclude_ambiguous_records!(@rows, self.class::IDENTITY) if self.class::IDENTITY
57
57
  CtxParser::Result.new(status: @issues.empty? ? :complete : :partial, records: @rows, issues: @issues)
58
58
  end
59
59
 
@@ -71,7 +71,7 @@ module Vxlan
71
71
  end
72
72
 
73
73
  # 逐行提供有效表格内容,并记录明确空表证据。
74
- def lines(text, separators: false)
74
+ def each_content_line(text, separators: false)
75
75
  text.each_line.with_index(1) do |source, number|
76
76
  line = source.strip
77
77
  next if line.empty? || (!separators && line.match?(/\A[-=+\s]+\z/))
@@ -84,12 +84,12 @@ module Vxlan
84
84
  end
85
85
 
86
86
  # 记录声明总数与实际解析数量不一致的情况。
87
- def mismatch(expected, observed)
87
+ def count_mismatch(expected, observed)
88
88
  @issues << { code: :count_mismatch, expected: expected, observed: observed } unless expected == observed
89
89
  end
90
90
 
91
- # 记录不支持或无效的行,不将其接受为完整证据。
92
- def reject(number)
91
+ # 追加行诊断;调用方负责决定是否继续处理当前记录。
92
+ def unparsed_records(number)
93
93
  @issues << { code: :unparsed_row, line: number }
94
94
  end
95
95
 
@@ -98,8 +98,8 @@ module Vxlan
98
98
  CtxParser::Values.ip_address?(value, allow_zone: true)
99
99
  end
100
100
 
101
- # 返回可作为权威清单的结果前,先识别身份冲突。
102
- def remove_duplicates(rows, fields, &identity)
101
+ # 原地排除重复身份的全部记录,不选取其中任意一条。
102
+ def exclude_ambiguous_records!(rows, fields, &identity)
103
103
  identities = CtxParser::Records.new(rows, identity: fields, &identity)
104
104
  return unless identities.duplicates?
105
105
  rows.replace(identities.unambiguous)
@@ -20,7 +20,7 @@ module Vxlan
20
20
  # 解析命令表格行,保留数量及格式错误行诊断。
21
21
  def parse_rows(text)
22
22
  scope, prefix_columns, prefix_seen, peers = {}, nil, {}, {}
23
- lines(text) do |line, number|
23
+ each_content_line(text) do |line, number|
24
24
  if HEADER.match?(line)
25
25
  @header = true
26
26
  elsif (match = /\ABGP summary information for VRF (\S+), address family (.+)/.match(line))
@@ -28,7 +28,7 @@ module Vxlan
28
28
  prefix_columns = nil
29
29
  elsif (match = /\ABGP router identifier (\S+), local AS number (\S+)/.match(line))
30
30
  scope.merge!(router_id: match[1].delete_suffix(","), local_as: match[2])
31
- reject(number) unless ip_address?(scope[:router_id]) && scope[:local_as].match?(/\A\d+(?:\.\d+)?\z/)
31
+ unparsed_records(number) unless ip_address?(scope[:router_id]) && scope[:local_as].match?(/\A\d+(?:\.\d+)?\z/)
32
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
33
  prefix_columns = line.end_with?("Type-12") ? %i[type2 type3 type4 type5 type12] : %i[type2 type3 type4 type5]
34
34
  elsif prefix_columns
@@ -39,14 +39,14 @@ module Vxlan
39
39
  peer[:prefixes_by_type] = prefix_columns.zip(words.drop(4).map(&:to_i)).to_h
40
40
  prefix_seen[peer.object_id] = true
41
41
  else
42
- reject(number)
42
+ unparsed_records(number)
43
43
  end
44
44
  elsif (match = ROW.match(line))
45
45
  row = FIELDS.zip(match.captures).to_h.merge(scope)
46
46
  INTEGERS.each { |key| row[key] = row[key].to_i }
47
47
  unless ip_address?(row[:peer_ip]) && row[:version] == 4 && (1..4_294_967_295).cover?(row[:remote_as]) &&
48
48
  row[:state].match?(/\A(?:\d+|Idle(?: \(Admin\))?|Active|Connect|OpenSent|OpenConfirm|Shutdown)\z/i)
49
- reject(number)
49
+ unparsed_records(number)
50
50
  next
51
51
  end
52
52
  row[:state] = row[:state].downcase.tr(" ", "_")
@@ -56,7 +56,7 @@ module Vxlan
56
56
  # 统计表按完整作用域查找;重复邻居仍由最终身份检查整体隔离。
57
57
  peers[row.values_at(:vrf, :address_family, :peer_ip)] ||= row
58
58
  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)
59
- reject(number)
59
+ unparsed_records(number)
60
60
  end
61
61
  end
62
62
  if prefix_columns && prefix_seen.size != @rows.size
@@ -15,7 +15,7 @@ module Vxlan
15
15
  # 解析当前命令的记录,保留无法识别行的诊断。
16
16
  def parse_rows(text)
17
17
  row, scope, totals = nil, nil, []
18
- lines(text, separators: true) do |line, number|
18
+ each_content_line(text, separators: true) do |line, number|
19
19
  if line.match?(/\A[-=+\s]+\z/)
20
20
  scope = nil
21
21
  next
@@ -33,11 +33,11 @@ module Vxlan
33
33
  next
34
34
  end
35
35
  unless row
36
- reject(number)
36
+ unparsed_records(number)
37
37
  next
38
38
  end
39
39
  case line
40
- when /\A--More--/i then reject(number)
40
+ when /\A--More--/i then unparsed_records(number)
41
41
  when /\ASystem Name:\s*(\S.*)\z/i then row[:neighbor_name] = $1
42
42
  when /\AInterface address\(es\):(?:\s+\d+)?\z/i then scope = :interface_addresses
43
43
  when /\AMgmt address\(es\):(?:\s+\d+)?\z/i then scope = :management_addresses
@@ -46,13 +46,13 @@ module Vxlan
46
46
  if %i[interface_addresses management_addresses].include?(scope) && ip_address?(value)
47
47
  row[scope] << IPAddr.new(value).to_s
48
48
  else
49
- reject(number)
49
+ unparsed_records(number)
50
50
  end
51
51
  when /\APlatform:\s*(.+?),\s*Capabilities:\s*(.+)\z/i
52
52
  row.merge!(platform: $1, capabilities: $2)
53
53
  scope = nil
54
54
  when /\AInterface:\s*(\S+),\s*Port ID \(outgoing port\):\s*(\S.*)\z/i
55
- reject(number) if row.key?(:interface)
55
+ unparsed_records(number) if row.key?(:interface)
56
56
  row.merge!(interface: $1, neighbor_interface: $2)
57
57
  scope = nil
58
58
  when /\AHoldtime:\s*(\d+) sec\z/i then row[:holdtime] = $1.to_i
@@ -66,11 +66,11 @@ module Vxlan
66
66
  row[:attributes][$1.downcase.tr(" ", "_").to_sym] = $2
67
67
  scope = nil
68
68
  else
69
- scope == :version ? row[:version] = [ row[:version], line ].reject(&:empty?).join("\n") : reject(number)
69
+ scope == :version ? row[:version] = [ row[:version], line ].reject(&:empty?).join("\n") : unparsed_records(number)
70
70
  end
71
71
  end
72
72
  @rows << row if row
73
- totals.each { |total| mismatch(total, @rows.size) }
73
+ totals.each { |total| count_mismatch(total, @rows.size) }
74
74
  @empty = true if totals == [ 0 ] && @rows.empty?
75
75
  @rows.reject! do |entry|
76
76
  entry[:neighbor_name] ||= entry[:device_id].sub(/\([^)]*\)\z/, "").split(".").first
@@ -16,7 +16,7 @@ module Vxlan
16
16
  # 解析当前命令的记录,保留无法识别行的诊断。
17
17
  def parse_rows(text)
18
18
  layout = nil
19
- lines(text) do |line, number|
19
+ each_content_line(text) do |line, number|
20
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
21
  layout = line
22
22
  @header = true
@@ -26,7 +26,7 @@ module Vxlan
26
26
  words = line.split
27
27
  state = words.index { |word| %w[up down connected notconnect disabled admin-down].include?(word.downcase) }
28
28
  unless layout && state && INTERFACE.match?(words[0])
29
- reject(number)
29
+ unparsed_records(number)
30
30
  next
31
31
  end
32
32
  row = { interface: words[0], state: words[state].downcase, detail: words.drop(state + 1).join(" ") }
@@ -37,38 +37,38 @@ module Vxlan
37
37
  row.merge!(vrf: words[1], ip_address: words[3])
38
38
  row.merge!(speed: words[4], mtu: words[5].to_i) if words.size == 6
39
39
  else
40
- reject(number)
40
+ unparsed_records(number)
41
41
  next
42
42
  end
43
43
  when /\A(?:Ethernet|Port-channel)\s+VLAN/i
44
44
  unless state == 4 && words.size >= 8
45
- reject(number)
45
+ unparsed_records(number)
46
46
  next
47
47
  end
48
48
  row.merge!(vlan: words[1], type: words[2], mode: words[3], reason: words[5...-2].join(" "), speed: words[-2])
49
49
  row[layout.start_with?("Ethernet") ? :port_channel : :protocol] = words[-1]
50
50
  when /\AInterface\s+Status/i
51
51
  unless state == 1
52
- reject(number)
52
+ unparsed_records(number)
53
53
  next
54
54
  end
55
55
  row[:description] = words.drop(2).join(" ")
56
56
  when /\AInterface\s+Secondary/i
57
57
  unless state >= 2
58
- reject(number)
58
+ unparsed_records(number)
59
59
  next
60
60
  end
61
61
  row.merge!(secondary_vlan: words[1...state].join(" "), reason: words.drop(state + 1).join(" "))
62
62
  when /\APort\s+Status Reason/i
63
63
  unless state == 1 && words.size >= 4 && words.last.match?(/\A\d+\z/)
64
- reject(number)
64
+ unparsed_records(number)
65
65
  next
66
66
  end
67
67
  row.merge!(reason: words[2...-1].join(" "), mtu: words.last.to_i)
68
68
  else
69
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
70
  unless match
71
- reject(number)
71
+ unparsed_records(number)
72
72
  next
73
73
  end
74
74
  row.merge!(name: match[1], state: match[2].downcase, vlan: match[3], duplex: match[4], speed: match[5], type: match[6],