vxlan 0.2.3 → 0.3.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README-zh.md +32 -4
  4. data/README.md +32 -4
  5. data/bin/README.md +203 -0
  6. data/bin/vxlan +8 -0
  7. data/docs/architecture-zh.md +62 -60
  8. data/docs/evidence-contracts.md +54 -0
  9. data/docs/remediation.md +84 -0
  10. data/docs/review-2026-10-19.md +31 -0
  11. data/docs/review-2026-10-21.md +19 -0
  12. data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
  13. data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
  14. data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
  15. data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
  16. data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
  17. data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
  18. data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
  19. data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
  20. data/examples/README.md +20 -0
  21. data/examples/collect.rb +8 -0
  22. data/examples/parse.rb +5 -0
  23. data/lib/vxlan/acl_policy.rb +50 -0
  24. data/lib/vxlan/change_plan/svi_acl.rb +76 -0
  25. data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
  26. data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
  27. data/lib/vxlan/change_plan.rb +131 -0
  28. data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
  29. data/lib/vxlan/cisco_nxos/evpn_l2route.rb +1 -1
  30. data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +65 -11
  31. data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
  32. data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
  33. data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +3 -2
  34. data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
  35. data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +11 -5
  36. data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +10 -4
  37. data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
  38. data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
  39. data/lib/vxlan/cli/apply.rb +172 -0
  40. data/lib/vxlan/cli/collect.rb +210 -0
  41. data/lib/vxlan/cli/connector.rb +23 -0
  42. data/lib/vxlan/cli/options.rb +311 -0
  43. data/lib/vxlan/cli/parse.rb +58 -0
  44. data/lib/vxlan/cli/report.rb +223 -0
  45. data/lib/vxlan/cli/review/fabric.rb +102 -0
  46. data/lib/vxlan/cli/review/report.rb +165 -0
  47. data/lib/vxlan/cli/review.rb +125 -0
  48. data/lib/vxlan/cli/templates/env.example +12 -0
  49. data/lib/vxlan/cli/templates/vxlan.yml +32 -0
  50. data/lib/vxlan/cli.rb +41 -0
  51. data/lib/vxlan/collector.rb +59 -28
  52. data/lib/vxlan/config/evidence.rb +18 -0
  53. data/lib/vxlan/config/review.rb +56 -0
  54. data/lib/vxlan/{configuration.rb → config.rb} +1 -1
  55. data/lib/vxlan/fabric/review.rb +327 -0
  56. data/lib/vxlan/fabric/scope.rb +43 -0
  57. data/lib/vxlan/observation.rb +3 -0
  58. data/lib/vxlan/recommendations.rb +193 -0
  59. data/lib/vxlan/service/catalog.rb +77 -0
  60. data/lib/vxlan/service/mapping.rb +53 -0
  61. data/lib/vxlan/snapshot.rb +3 -0
  62. data/lib/vxlan/subnet_index.rb +88 -0
  63. data/lib/vxlan/version.rb +1 -1
  64. data/lib/vxlan.rb +4 -2
  65. metadata +138 -7
  66. data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
  67. data/lib/vxlan/configuration_review.rb +0 -54
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vxlan
4
+ class Config
5
+ # 配置关联审查独立于语法解析及运行健康检查。
6
+ class Review
7
+ # 绑定归一化配置,用于交叉引用一致性检查。
8
+ def initialize(configuration)
9
+ @configuration = configuration
10
+ end
11
+
12
+ # 检查完整配置中的关联缺失或引用矛盾。
13
+ def call(complete: false)
14
+ raise ArgumentError, "Completeness must be boolean" unless [ true, false ].include?(complete)
15
+ return [] unless complete
16
+
17
+ nve_members_by_vni = @configuration.fetch(:nve).fetch(:members, {})
18
+ vlan_mappings_by_vni = @configuration.fetch(:vlan_vnis).group_by { |_vlan, vni| vni }
19
+ vrf_vnis = @configuration.fetch(:vrfs).values.filter_map { |settings| settings[:vni] }.to_set
20
+ evpn_vnis = @configuration.fetch(:evpn)
21
+ findings = []
22
+ vlan_mappings_by_vni.each do |vni, entries|
23
+ next unless vni && !nve_members_by_vni.key?(vni)
24
+
25
+ findings << { code: :vlan_vni_without_nve, vni: vni, vlan_ids: entries.map(&:first).sort }
26
+ end
27
+ nve_members_by_vni.each do |vni, settings|
28
+ if settings[:associate_vrf]
29
+ findings << { code: :nve_l3_without_vrf, vni: vni } unless vrf_vnis.include?(vni)
30
+ next
31
+ end
32
+
33
+ findings << { code: :nve_l2_without_vlan, vni: vni } unless vlan_mappings_by_vni.key?(vni)
34
+ if @configuration[:nv_overlay_evpn] && !evpn_vnis.key?(vni)
35
+ findings << { code: :nve_l2_without_evpn, vni: vni }
36
+ end
37
+ end
38
+ evpn_vnis.each do |vni, settings|
39
+ member = nve_members_by_vni[vni]
40
+ if !member
41
+ findings << { code: :evpn_vni_without_nve, vni: vni }
42
+ elsif (settings[:type] == "l3") != member[:associate_vrf]
43
+ findings << { code: :vni_layer_conflict, vni: vni, evpn_type: settings[:type] }
44
+ end
45
+ end
46
+ @configuration.fetch(:vrfs).each do |name, settings|
47
+ next unless settings[:vni]
48
+ member = nve_members_by_vni[settings[:vni]]
49
+ code = member ? (!member[:associate_vrf] ? :vni_layer_conflict : nil) : :l3_vni_without_nve
50
+ findings << { code: code, vni: settings[:vni], vrf: name } if code
51
+ end
52
+ findings.sort_by { |finding| [ finding[:vni], finding[:code].to_s ] }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -5,7 +5,7 @@ require_relative "observation"
5
5
 
6
6
  module Vxlan
7
7
  # 配置与运行观测使用相同的平台、型号策略。
8
- class Configuration
8
+ class Config
9
9
  # 按当前平台和型号选择 VXLAN 配置策略。
10
10
  def initialize(platform: :cisco_nxos, model: nil)
11
11
  @profile = ParserRegistry.fetch(platform, model: model)
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "digest"
6
+ require_relative "../change_plan"
7
+ require_relative "../acl_policy"
8
+ require_relative "scope"
9
+ require_relative "../service/catalog"
10
+ require_relative "../service/mapping"
11
+ require_relative "../config/evidence"
12
+ require_relative "../subnet_index"
13
+
14
+ module Vxlan
15
+ module Fabric
16
+ # 显式 Fabric 边界内比较已部署业务;设备角色和部署范围不由多数票推导。
17
+ class Review
18
+ # 比较范围由业务策略统一验证,审查器只负责组织规则与建议。
19
+ def initialize(documents, policy)
20
+ @documents = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
21
+ raise ArgumentError, "重复设备身份" unless @documents.size == documents.size
22
+ @scope = Scope.new(policy)
23
+ end
24
+
25
+ # 缺少任何成员的完整配置时仍报告覆盖,但不据此生成跨设备增量。
26
+ def call
27
+ @findings, @items, @coverage, @subnet_indexes = [], Hash.new { |hash, host| hash[host] = [] }, [], {}
28
+ inventory = @scope.coverage(@documents.keys)
29
+ finding("未归属", nil, "fabric_scope_unassigned", "已采集设备未纳入 Fabric 策略,跨设备审查未覆盖", inventory[:unassigned]) if inventory[:unassigned].any?
30
+ @scope.groups.each do |fabric, group|
31
+ configs = group.values.flatten.to_h { |host| [ host, configuration(host) ] }
32
+ unknown = configs.select { |_host, config| config.nil? }.keys
33
+ unless unknown.empty?
34
+ finding(fabric, nil, "fabric_evidence_incomplete", "成员配置证据不完整,停止该 Fabric 的变更推导", unknown)
35
+ next
36
+ end
37
+ %w[leaves borders].each do |role|
38
+ @role = role
39
+ review_group(fabric, configs.slice(*group[role]), all_configs: configs) if group[role].any?
40
+ end
41
+ @role = "spines"
42
+ group["spines"].each { |host| review_spine(fabric, host, configs.fetch(host)) }
43
+ @role = nil
44
+ review_subnets(fabric, configs)
45
+ end
46
+ { schema_version: 2, scope: "same_role_same_deployed_vni", inventory: inventory, findings: @findings, device_items: @items.to_h, coverage: @coverage }
47
+ end
48
+
49
+ private
50
+
51
+ # 配置完整性是关联缺失和规则对比的前提,运行表部分不影响完整配置比较。
52
+ def configuration(host)
53
+ Config::Evidence.complete_record(@documents[host])
54
+ end
55
+
56
+ # 每个 VNI 只纳入已映射此业务的 Leaf,未部署的 Leaf 仅记录覆盖范围。
57
+ def review_group(fabric, configs, all_configs:)
58
+ services = Service::Catalog.new(configs).services
59
+ policies = configs.transform_values do |config|
60
+ config.fetch("acls", {}).transform_values { |entries| AclPolicy.resolve(config, entries) }
61
+ end
62
+ services.sort.each do |vni, rows|
63
+ @coverage << { fabric: fabric, role: @role, vni: vni, deployed: rows.map { |row| row[:host] }.uniq, not_deployed: configs.keys - rows.map { |row| row[:host] }, incomplete: rows.select { |row| row[:missing].any? }.map { |row| row[:host] }.uniq }
64
+ incomplete = rows.select { |row| row[:missing].any? }
65
+ if incomplete.any?
66
+ finding(fabric, vni, "fabric_service_incomplete", "业务已开始部署,但 VLAN/NVE/EVPN 关联不完整", observations(incomplete) { |row| { missing: row[:missing] } })
67
+ review_missing_mappings(fabric, vni, rows, all_configs)
68
+ end
69
+ if rows.map { |row| row[:host] }.uniq.size != rows.size
70
+ finding(fabric, vni, "fabric_vlan_mapping_ambiguous", "同一 Leaf 将多个 VLAN 映射到相同 VNI", rows.map { |row| row.slice(:host, :vlan) })
71
+ next
72
+ end
73
+ next unless rows.size >= 2
74
+ constraints = review_transport(fabric, vni, rows)
75
+ service_blocked = incomplete.any? || constraints
76
+ # 缺映射行没有可定位 SVI,不能再扩散为网关、MTU 等一串伪差异。
77
+ rows = rows.select { |row| row[:vlan] }
78
+ next unless rows.size >= 2
79
+ compare(fabric, vni, "fabric_vlan_mapping", "相同 VNI 的本地 VLAN 编号不一致", rows) { |row| row[:vlan] }
80
+ # L2 业务没有网关可以是正常设计,但仍须检查承载与控制平面。
81
+ next unless rows.any? { |row| row[:svi] && (row[:svi]["anycast"] || row[:svi].fetch("addresses", []).any?) }
82
+ review_gateway(fabric, vni, rows, all_configs, service_blocked: service_blocked)
83
+ %w[in out].each { |direction| review_acl(fabric, vni, rows, direction, policies: policies, service_blocked: service_blocked) }
84
+ %w[vrf anycast mtu shutdown].each do |field|
85
+ compare(fabric, vni, "fabric_#{field}", "业务 SVI 的 #{field} 不一致", rows) { |row| row[:svi]&.fetch(field, nil) }
86
+ end
87
+ compare(fabric, vni, "fabric_anycast_mac", "Anycast 网关 MAC 不一致", rows) { |row| row[:config]["anycast_mac"] }
88
+ end
89
+ review_acl_catalog(fabric, configs, policies)
90
+ review_address_groups(fabric, configs)
91
+ end
92
+
93
+ # 已有 NVE/EVPN 的缺映射业务单独推导,不把不完整关联当作从未部署。
94
+ def review_missing_mappings(fabric, vni, rows, configs)
95
+ rows.select { |row| row[:missing] == [ "VLAN" ] }.each do |row|
96
+ evidence = observations(rows) { |peer| { missing: peer[:missing], gateway: peer[:svi]&.fetch("addresses", []), vrf: peer[:svi]&.dig("vrf") } }
97
+ item = recommendation(fabric, vni, "fabric_vlan_mapping_missing", "补齐已有业务的 VLAN/VNI 关联", evidence)
98
+ plan = Service::Mapping.infer(vni, row, rows)
99
+ change = plan&.to_h
100
+ conflicts = change ? gateway_conflicts(configs, change, except: [ row[:host], "Vlan#{change['vlan']}" ]) : []
101
+ if conflicts.any?
102
+ item[:evidence].concat(conflicts)
103
+ item[:blockers] << "同 VRF 存在跨 SVI/跨 VNI 网段重叠,不能通过新增映射扩大冲突:#{conflicts.map { |entry| "#{entry[:name]} #{entry[:interface]}" }.join('、')}"
104
+ plan = nil
105
+ end
106
+ if plan
107
+ local_svi = row[:config].fetch("interfaces").fetch("Vlan#{change['vlan']}")
108
+ item[:evidence] = evidence.map do |entry|
109
+ entry[:host] == row[:host] ? entry.merge(vlan: change["vlan"].to_s, value: { missing: [ "VLAN/VNI 映射" ], gateway: local_svi["addresses"], vrf: local_svi["vrf"] }) : entry
110
+ end
111
+ item[:impact] = "将已有 VLAN 接入既有 VXLAN 业务,可能立即扩展广播域;不创建 VLAN,不更改网关或 ACL。"
112
+ item[:prerequisites] << "本机未映射 VLAN 的网关、VRF、Anycast 身份及 ACL 与完整对端一致,NVE/EVPN 也已匹配;确认该业务确应在本机接入。"
113
+ item[:expected] = "本机 VLAN/VNI 关联完整、VNI 状态符合预期,既有网关及 ACL 保持不变;接入业务验收通过。"
114
+ item[:resolves] = [ { code: "nve_l2_without_vlan", vni: vni } ]
115
+ propose(item, plan, service_blocked: false)
116
+ else
117
+ item[:blockers] << "没有唯一且完整的业务对应关系;需核对本机未映射 VLAN、网关、VRF、ACL 与对端配置,禁止按 VNI 数值猜测 VLAN。"
118
+ end
119
+ @items[row[:host]] << item
120
+ end
121
+ end
122
+
123
+ # 一致性约束先于增量生成;承载/路由声明冲突时停止叠加网关和 ACL 变更。
124
+ def review_transport(fabric, vni, rows)
125
+ before = @findings.size
126
+ compare(fabric, vni, "fabric_replication", "NVE 复制方式或组播组声明不一致", rows) { |row| Service::Catalog.nve_member(row[:config], row[:nve])&.slice("replication", "multicast_group") }
127
+ compare(fabric, vni, "fabric_arp_suppression", "ARP 抑制的显式配置不一致", rows) { |row| row[:nve]&.fetch("suppress_arp", nil) }
128
+ compare(fabric, vni, "fabric_route_targets", "EVPN 导入/导出 RT 声明不一致,需核对路由互通与隔离", rows) do |row|
129
+ row[:evpn] && Service::Catalog.route_targets(row[:evpn]["route_targets"], asn: row[:config].dig("bgp", "asn"), vni: vni)
130
+ end
131
+ tenant_rows = rows.select { |row| row[:svi] && row[:svi]["vrf"] }
132
+ compare(fabric, vni, "fabric_tenant_routing", "共同业务的租户 VNI 或路由导入/导出声明不一致", tenant_rows) do |row|
133
+ Service::Catalog.tenant(row[:config], row[:svi]["vrf"])
134
+ end if tenant_rows.size >= 2
135
+ @findings.size > before
136
+ end
137
+
138
+ # Spine 角色的业务承载配置保留为待决项,不能自动补齐或删除疑似残留。
139
+ def review_spine(fabric, host, config)
140
+ services = Service::Catalog.new(host => config).services
141
+ return if services.empty?
142
+ evidence = [ { host: host, value: { role: "spines", l2_vnis: services.keys.sort } } ]
143
+ finding(fabric, nil, "fabric_role_conflict", "声明为 Spine,但存在 L2 业务承载配置", evidence)
144
+ item = recommendation(fabric, nil, "fabric_role_conflict", "核对 Spine 是否应承担业务 VTEP 职责", evidence)
145
+ item[:blockers] << "先确认角色或迁移残留;不能因为出现 NVE 就自动把 Spine 当作 Leaf,不能自动补业务配置或删除 NVE。"
146
+ @items[host] << item
147
+ end
148
+
149
+ # 网关不一致与地址迁移分开;未知目标不做替换,仅允许无冲突的缺失地址补齐。
150
+ def review_gateway(fabric, vni, rows, configs, service_blocked:)
151
+ values = rows.map { |row| row[:svi]&.fetch("addresses", [])&.sort }
152
+ return if values.uniq.one?
153
+ finding(fabric, vni, "fabric_gateway", "同业务网关地址不一致", observations(rows) { |row| row[:svi]&.fetch("addresses", []) })
154
+ expected = values.compact.reject(&:empty?).uniq
155
+ rows.each do |row|
156
+ next if expected.one? && row[:svi]&.fetch("addresses", [])&.sort == expected.first
157
+ item = recommendation(fabric, vni, "fabric_gateway", "网关地址需要一致", observations(rows) { |entry| entry[:svi]&.fetch("addresses", []) })
158
+ change = { "kind" => "svi_gateway_add", "vlan" => row[:vlan].to_i, "vni" => vni, "vrf" => row[:svi]&.dig("vrf"), "address" => expected.one? && expected.first.one? ? expected.first.first : nil }
159
+ context = rows.map { |entry| [ entry[:svi]&.dig("vrf"), entry[:svi]&.dig("anycast"), entry[:config]["anycast_mac"] ] }.uniq
160
+ conflicts = change["address"] ? gateway_conflicts(configs, change) : []
161
+ item[:evidence].concat(conflicts)
162
+ if conflicts.empty? && context.one? && context.first.all? && expected.one? && expected.first.one? &&
163
+ (plan = ChangePlan.candidate(change, config: row[:config]))
164
+ propose(item, plan, service_blocked: service_blocked)
165
+ else
166
+ item[:blockers] << "存在已有地址、跨 SVI/跨 VNI 网段冲突,或 VRF/Anycast 模式/MAC 不一致、缺少唯一网关;需先确认迁移设计,禁止重复添加网关。"
167
+ item[:blockers] << "Fabric 内冲突接口:#{conflicts.map { |conflict| "#{conflict[:name]} #{conflict[:interface]}" }.join('、')}" if conflicts.any?
168
+ end
169
+ @items[row[:host]] << item
170
+ end
171
+ end
172
+
173
+ # 目标机没有重复地址还不够;其他 Leaf 的跨 VNI 重叠同样阻止扩散网关。
174
+ def gateway_conflicts(configs, change, except: nil)
175
+ configs.flat_map do |host, config|
176
+ index = (@subnet_indexes[host] ||= SubnetIndex.new(config))
177
+ excluded = except&.first == host ? except.last : nil
178
+ index.conflicts(address: change["address"], vrf: change["vrf"], vni: change["vni"], except_interface: excluded).map do |entry|
179
+ { host: host, name: @documents[host]["device"]["name"], **entry }
180
+ end
181
+ end
182
+ end
183
+
184
+ # 无 ACL 不构成删除保护的基线;只从一致且已实际挂载的访问控制提出补齐。
185
+ def review_acl(fabric, vni, rows, direction, policies:, service_blocked:)
186
+ bindings = rows.map { |row| row[:svi]&.dig("acl_bindings", direction) }
187
+ definitions = rows.map { |row| name = row[:svi]&.dig("acl_bindings", direction); name && policies.dig(row[:host], name) }
188
+ different_binding = bindings.uniq.size > 1
189
+ different_content = definitions.uniq.size > 1 || bindings.zip(definitions).any? { |name, content| name && content.nil? }
190
+ return unless different_binding || different_content
191
+ evidence = observations(rows) do |row|
192
+ name = row[:svi]&.dig("acl_bindings", direction)
193
+ entries = name && row[:config].fetch("acls", {})[name]
194
+ policy = policies.dig(row[:host], name)
195
+ { acl: name, defined: !entries.nil?, entries: entries, policy_verified: !policy.nil?, address_groups: policy&.address_groups, digest: policy && ChangePlan.fingerprint(policy.to_h) }
196
+ end
197
+ code = different_binding ? "fabric_acl_binding" : "fabric_acl_content"
198
+ finding(fabric, vni, code, "#{direction} 方向 ACL 挂载/有序规则不一致", evidence)
199
+ undefined = evidence.select { |row| row[:value][:acl] && !row[:value][:defined] }
200
+ finding(fabric, vni, "fabric_acl_undefined", "#{direction} 方向已挂载 ACL,但本机没有对应定义", undefined) if undefined.any?
201
+ unresolved = evidence.select { |row| row[:value][:defined] && !row[:value][:policy_verified] }
202
+ finding(fabric, vni, "fabric_acl_dependency", "#{direction} 方向 ACL 依赖对象缺失、为空或尚未解析,不能证明策略一致", unresolved) if unresolved.any?
203
+ names = bindings.compact.uniq
204
+ sources = rows.select { |row| row[:config].fetch("acls", {})[names.first] }
205
+ originals = sources.map { |row| row[:config]["acls"][names.first] }
206
+ unique = sources.map { |row| policies.dig(row[:host], names.first) }.uniq
207
+ rows.each do |row|
208
+ current_name = row[:svi]&.dig("acl_bindings", direction)
209
+ current_entries = names.one? && row[:config].fetch("acls", {})[names.first]
210
+ next if names.one? && unique.one? && unique.first && current_name == names.first && policies.dig(row[:host], names.first) == unique.first
211
+ item = recommendation(fabric, vni, code, "统一 #{direction} 方向访问控制", evidence)
212
+ change = { "kind" => "svi_acl", "vlan" => row[:vlan].to_i, "vni" => vni, "direction" => direction,
213
+ "acl" => names.first, "entries" => originals.first, "create_acl" => current_entries.nil?, "previous_binding" => current_name }
214
+ change["address_groups"] = unique.first.address_groups if unique.one? && unique.first
215
+ context_matches = rows.map { |entry| entry[:svi]&.dig("vrf") }.uniq.one?
216
+ if names.one? && unique.one? && unique.first && context_matches &&
217
+ (plan = ChangePlan.candidate(change, config: row[:config]))
218
+ item[:prerequisites] << "依据是本业务已挂载且有序内容一致的 ACL;即使挂载节点较少,也不以无 ACL 的多数节点为删除依据。确认此业务应统一受该 ACL 保护。"
219
+ propose(item, plan, service_blocked: service_blocked)
220
+ else
221
+ item[:blockers] << "ACL 名称、有序规则、地址组成员、VRF 或当前绑定不能证明一致;需选择权威规则及完整依赖,不自动覆盖或撤销访问控制。"
222
+ shared = change["create_acl"] ? AclPolicy.bindings(row[:config], names.first) - [ { "interface" => "Vlan#{row[:vlan]}", "direction" => direction } ] : []
223
+ if shared.any?
224
+ references = shared.map { |binding| "#{binding['interface']} #{binding['direction']}" }.join("、")
225
+ item[:blockers] << "创建 #{names.first} 还会激活既有引用:#{references}。需合并所有受影响接口核对策略和回退,禁止作为单个 SVI 的补齐执行。"
226
+ end
227
+ end
228
+ @items[row[:host]] << item
229
+ end
230
+ end
231
+
232
+ # 同名 ACL 即使暂未挂载也报告规则债务,避免未来补挂时激活不同策略。
233
+ def review_acl_catalog(fabric, configs, policies)
234
+ names = configs.values.flat_map { |config| config.fetch("acls", {}).keys }.uniq.sort
235
+ names.each do |name|
236
+ rows = configs.filter_map do |host, config|
237
+ entries = config.fetch("acls", {})[name]
238
+ { host: host, entries: entries, semantics: policies.dig(host, name)&.rules || AclPolicy.rules(entries), policy: policies.dig(host, name)&.to_h } if entries
239
+ end
240
+ if rows.map { |row| row[:semantics] }.uniq.size > 1 || rows.map { |row| row[:policy] }.uniq.size > 1
241
+ finding(fabric, nil, "fabric_acl_content", "同名 ACL #{name} 的有序规则或依赖对象不同(包括尚未挂载的定义)", rows)
242
+ end
243
+ unknown = rows.select { |row| row[:policy].nil? }
244
+ finding(fabric, nil, "fabric_acl_dependency", "ACL #{name} 的依赖对象未验证,不能证明策略一致(包括尚未挂载的定义)", unknown) if unknown.any?
245
+ end
246
+ end
247
+
248
+ # 展开引用对象的成员差异,避免只展示相同的 ACL 文本而隐藏实际匹配范围。
249
+ def review_address_groups(fabric, configs)
250
+ # 每台设备的引用去重后直接归入对象索引,避免每个对象再扫描所有引用。
251
+ references = {}
252
+ configs.each do |host, config|
253
+ names = config.fetch("acls", {}).values.flatten.flat_map { |entry| entry["expression"].scan(/\baddrgroup\s+(\S+)/).flatten }.uniq
254
+ names.each { |name| (references[name] ||= []) << { host: host, value: config.fetch("address_groups", {})[name] } }
255
+ end
256
+ references.sort.each do |name, rows|
257
+ next unless rows.map { |row| row[:value] }.uniq.size > 1
258
+ finding(fabric, nil, "fabric_address_group", "ACL 地址组 #{name} 成员不同或缺失;仅比较引用此对象的业务,不自动复制", rows)
259
+ end
260
+ end
261
+
262
+ # 同 VRF 内按规范网段区间扫描,保留跨 VNI 的包含关系;同网段多台证据先合并。
263
+ def review_subnets(fabric, configs)
264
+ subnets = Hash.new { |hash, key| hash[key] = [] }
265
+ configs.each do |host, config|
266
+ config.fetch("interfaces", {}).each do |name, svi|
267
+ next unless name.match?(/\AVlan\d+\z/) && svi["anycast"]
268
+ vni = config.fetch("vlan_vnis", {})[name.delete_prefix("Vlan")]
269
+ next unless vni
270
+ svi.fetch("addresses", []).each do |address|
271
+ network = IPAddr.new(address)
272
+ subnets[[ svi["vrf"], network.ipv4?, network.to_i, network.to_range.last.to_i ]] << { host: host, interface: name, vni: vni, address: address }
273
+ end
274
+ end
275
+ end
276
+ subnets.group_by { |(vrf, ipv4, _start, _finish), _rows| [ vrf, ipv4 ] }.each do |(vrf, _ipv4), networks|
277
+ active = []
278
+ networks.sort_by { |(key, _rows)| key[2] }.each do |key, rows|
279
+ active.reject! { |other_key, _other_rows| other_key[3] < key[2] }
280
+ conflicts = active.filter_map do |_other_key, other_rows|
281
+ combined = other_rows + rows
282
+ combined if combined.map { |row| row[:vni] }.uniq.size > 1
283
+ end
284
+ conflicts << rows if rows.map { |row| row[:vni] }.uniq.size > 1
285
+ conflicts.each do |evidence|
286
+ finding(fabric, nil, "fabric_subnet_vni_conflict", "VRF #{vrf} 的网段在不同 VNI 上重叠,需核对迁移或隔离设计", evidence)
287
+ end
288
+ active << [ key, rows ]
289
+ end
290
+ end
291
+ end
292
+
293
+ # 属性比较保留各叶节点值;显式缺失与 nil 不静默套用设备默认值。
294
+ def compare(fabric, vni, code, title, rows, &value)
295
+ evidence = observations(rows, &value)
296
+ finding(fabric, vni, code, title, evidence) if evidence.map { |row| row[:value] }.uniq.size > 1
297
+ end
298
+
299
+ # 观察附带设备名、地址及本地 VLAN,报告可直接定位配置入口。
300
+ def observations(rows)
301
+ rows.map { |row| { host: row[:host], name: @documents[row[:host]]["device"]["name"], vlan: row[:vlan], value: yield(row) } }
302
+ end
303
+
304
+ # 按业务问题计数,不把证据行数当作问题数量。
305
+ def finding(fabric, vni, code, title, evidence)
306
+ @findings << { fabric: fabric, role: @role, vni: vni, code: code, title: title, evidence: evidence }
307
+ end
308
+
309
+ # 与单设备报告共享建议字段,跨设备来源完整保留。
310
+ def recommendation(fabric, vni, code, title, evidence)
311
+ { code: code, category: "fabric", scope: "#{fabric} / #{@role} / #{vni ? "VNI #{vni}" : '角色核查'}", title: title, status: "blocked", evidence: evidence,
312
+ prerequisites: [ "仅在本 Fabric、相同已部署业务范围内适用;核对业务访问策略与维护窗口。", "下发前重新读取配置,任何变化均使计划过期;逐设备逐项确认,不批量重放。" ], blockers: [],
313
+ commands: [], rollback: [], verify: [ "show running-config", "show nve vni", "show ip interface brief vrf all" ],
314
+ impact: "改变网关或 ACL 可能改变可达性及业务隔离;配置回读通过后仍需业务验收。", expected: "共同业务的网关和访问控制一致,允许/拒绝流量均符合设计。", references: [] }
315
+ end
316
+
317
+ # 类型化增量才可导出可交互执行的计划,冲突项只有证据和待确认信息。
318
+ def propose(item, plan, service_blocked:)
319
+ if service_blocked
320
+ item[:blockers] << "共同业务的承载关联或复制/路由声明存在冲突,先解决这些前置问题,再生成网关或 ACL 增量。"
321
+ return
322
+ end
323
+ item.merge!(status: "candidate", change: plan.to_h, commands: plan.commands, rollback: plan.rollback)
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "ctx_parser/immutable"
5
+
6
+ module Vxlan
7
+ module Fabric
8
+ # Fabric 与角色是业务边界,名称和地址相似不能替代显式成员声明。
9
+ class Scope
10
+ ROLES = %w[leaves borders spines].freeze
11
+ attr_reader :groups
12
+
13
+ # 在入口验证唯一归属;允许单角色为空,但 Fabric 至少有两个成员。
14
+ def initialize(policy)
15
+ unless policy.is_a?(Hash) && policy.keys == [ "fabrics" ] && policy["fabrics"].is_a?(Hash) && policy["fabrics"].any?
16
+ raise ArgumentError, "需要 fabrics 策略"
17
+ end
18
+ @groups = policy["fabrics"].to_h do |name, group|
19
+ unless name.is_a?(String) && !name.empty? && group.is_a?(Hash) && (group.keys - ROLES).empty? && group.any?
20
+ raise ArgumentError, "Fabric 需要明确的角色成员清单"
21
+ end
22
+ roles = ROLES.to_h do |role|
23
+ hosts = group.fetch(role, [])
24
+ raise ArgumentError, "角色成员须为地址列表" unless hosts.is_a?(Array)
25
+ hosts.each { |host| raise ArgumentError, "成员地址无效" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host }
26
+ [ role, CtxParser::Immutable.copy(hosts) ]
27
+ end
28
+ raise ArgumentError, "Fabric 至少需要两个成员" unless roles.values.flatten.size >= 2
29
+ [ name, roles.freeze ]
30
+ end.freeze
31
+ raise ArgumentError, "成员不能重复、跨角色或跨 Fabric" unless hosts.uniq == hosts
32
+ end
33
+
34
+ # 扁平成员集合用于覆盖核对,不改变角色内的比较范围。
35
+ def hosts = groups.values.flat_map { |roles| roles.values.flatten }
36
+
37
+ # 未采集与未纳入是两种不同缺口,均不能被无发现掩盖。
38
+ def coverage(observed_hosts)
39
+ { groups: groups, assigned: hosts & observed_hosts, unassigned: observed_hosts - hosts, missing: hosts - observed_hosts }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -22,5 +22,8 @@ module Vxlan
22
22
  # 展示对象身份与状态,避免泄露原始回显或凭据。
23
23
  def inspect = "#<#{self.class.name} name=#{name} status=#{status} records=#{records.size}>"
24
24
  alias to_s inspect
25
+
26
+ # PP 默认展开 Data 成员,显式沿用脱敏展示。
27
+ def pretty_print(printer) = printer.text(inspect)
25
28
  end
26
29
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cisco_nxos/profile"
4
+ require_relative "config/evidence"
5
+
6
+ module Vxlan
7
+ # 对持久化事实生成可审阅建议;不持有连接器,不下发配置,不推断跨设备 Fabric 归属。
8
+ class Recommendations
9
+ REFERENCE = "https://www.cisco.com/c/en/us/td/docs/dcn/nx-os/nexus9000/103x/configuration/vxlan/cisco-nexus-9000-series-nx-os-vxlan-configuration-guide-release-103x/m_configuring_vxlan_bgp_evpn.html"
10
+ TITLES = {
11
+ "vlan_vni_without_nve" => "VLAN 已映射 VNI,但缺少 NVE 成员",
12
+ "nve_l2_without_vlan" => "L2 NVE 成员缺少 VLAN 映射",
13
+ "nve_l2_without_evpn" => "L2 NVE 成员缺少 EVPN 配置",
14
+ "nve_l3_without_vrf" => "L3 NVE 成员缺少 VRF 关联",
15
+ "evpn_vni_without_nve" => "EVPN VNI 缺少 NVE 成员",
16
+ "vni_layer_conflict" => "VNI 的 L2/L3 属性冲突",
17
+ "l3_vni_without_nve" => "VRF VNI 缺少 NVE 成员"
18
+ }.freeze
19
+
20
+ # 联合证据可以解决本机未知,但角色冲突优先阻断所有草稿;不改变原始发现。
21
+ def self.combine(local, supplemental)
22
+ supplemental = Array(supplemental)
23
+ resolved = supplemental.select { |item| item[:status] == "candidate" }.flat_map { |item| item.fetch(:resolves, []) }
24
+ items = local[:items].reject { |item| resolved.include?(item.slice(:code, :vni)) } + supplemental
25
+ conflict = supplemental.find { |item| item[:code] == "fabric_role_conflict" }
26
+ if conflict
27
+ items = items.map do |item|
28
+ next item unless item[:status] == "candidate"
29
+ item.reject { |key, _| key == :change }.merge(status: "blocked", commands: [], rollback: [], blockers: item.fetch(:blockers, []) + conflict[:blockers])
30
+ end
31
+ end
32
+ local.merge(items: items, counts: items.map { |item| item[:status] }.tally)
33
+ end
34
+
35
+ # 输入为报告 JSON 的字符串键结构,离线与实时报告共用此边界。
36
+ def initialize(document)
37
+ @document = document
38
+ @dimensions = document.dig("snapshot", "dimensions") || {}
39
+ end
40
+
41
+ # 建议与采集完整性分开;没有命中规则并不代表网络健康。
42
+ def call
43
+ items = evidence_tasks
44
+ config = Config::Evidence.complete_record(@document)
45
+ if config
46
+ items.concat(config.fetch("review_findings", []).map { |finding| configuration_item(config, finding) })
47
+ items.concat(undefined_acl_items(config))
48
+ end
49
+ items.concat(runtime_tasks)
50
+ { schema_version: 1, review_only: true, health_assessment: "not_performed", device: @document["device"],
51
+ observed_at: @document.dig("snapshot", "observed_at"), items: items, counts: items.map { |item| item[:status] }.tally }
52
+ end
53
+
54
+ private
55
+
56
+ # 无 VNI、无网关的残留接口也保留悬空引用,不能由共同业务筛选掩盖。
57
+ def undefined_acl_items(config)
58
+ config.fetch("interfaces", {}).flat_map do |name, interface|
59
+ interface.fetch("acl_bindings", {}).filter_map do |direction, acl|
60
+ next if config.fetch("acls", {}).key?(acl)
61
+ result = item("acl_reference_undefined", "#{name} / #{direction}", "接口引用 ACL #{acl},本机缺少定义",
62
+ evidence: { interface: name, direction: direction, acl: acl, addresses: interface.fetch("addresses", []), vlan_vni: config.fetch("vlan_vnis", {})[name.delete_prefix("Vlan")] },
63
+ verify: [ "show running-config", "show ip interface brief vrf all" ])
64
+ result[:blockers] << "先确认接口是否承载业务及 ACL 的权威定义;无地址或无 VNI 可能是残留配置,不能直接推断业务中断,也不能按名称复制其他 Fabric 的 ACL。"
65
+ result[:impact] = "新增 ACL 定义会作用于本机所有同名引用;须核对全部引用及回退范围。"
66
+ result[:references] = []
67
+ result
68
+ end
69
+ end
70
+ end
71
+
72
+ # 建议只保存有关事实,不复制整份配置或敏感命令正文。
73
+ def item(code, scope, title, evidence:, verify:, category: "configuration")
74
+ { code: code, scope: scope, title: title, category: category, status: "blocked", evidence: evidence,
75
+ prerequisites: [], blockers: [], commands: [], rollback: [], verify: verify,
76
+ expected: "重新采集并核对该项事实;业务连通性须另行验收。", references: [ REFERENCE ] }
77
+ end
78
+
79
+ # 仅在完整配置证实关联缺失时讨论增量补齐,不自动删除疑似闲置配置。
80
+ def configuration_item(config, finding)
81
+ vni = finding["vni"]
82
+ result = item(finding["code"], "VNI #{vni}", TITLES.fetch(finding["code"], "配置关联需要核查"),
83
+ evidence: finding, verify: [ "show running-config", "show nve vni", "show nve peers", "show bgp l2vpn evpn summary" ])
84
+ result[:vni] = vni
85
+ result[:prerequisites] = [ "确认该 VNI 应在本机承载业务,而非预配置或退网残留。", "核对当前软件版本、设备角色、同 Fabric 对端的 VLAN/VNI、RD/RT 与复制方式。", "重新采集配置并确认与本报告一致;审批维护窗口后逐台操作,出现异常先回读,勿盲目重放。" ]
86
+ result[:impact] = "补齐可能开始发布路由或扩展二层广播域;须核对隔离边界、对端配置和回退条件。"
87
+ result[:expected] = "VLAN、NVE 与 EVPN 关联一致;预期承载业务的 VNI 状态 Up,BGP/对端正常,业务验收通过。"
88
+ unless vni.is_a?(Integer) && vni.between?(1, 16_777_215)
89
+ result[:blockers] << "VNI 非有效整数,无法生成命令。"
90
+ return result
91
+ end
92
+ vlans = config.fetch("vlan_vnis", {}).select { |_vlan, mapped| mapped == vni }.keys
93
+ nve = config.fetch("nve", {})
94
+ member = nve.fetch("members", {})[vni.to_s]
95
+ evpn = config.fetch("evpn", {})[vni.to_s]
96
+ result[:evidence] = finding.merge("vlan_ids" => vlans, "nve_member" => member, "evpn" => evpn)
97
+ case finding["code"]
98
+ when "nve_l2_without_evpn"
99
+ if l2_context?(config, vni, vlans) && member && member["associate_vrf"] == false && member["replication"] == "bgp" && !evpn && auto_policy?(config)
100
+ candidate(result, evpn_add(vni), evpn_remove(vni), "本机现有 L2 EVPN 均采用 RD auto、RT import/export auto;候选沿用此模式,仍需确认本 VNI 的业务意图。")
101
+ else
102
+ result[:blockers] << "需要唯一 VLAN 映射、L2 BGP 复制以及一致的 RD/RT 策略;缺失或冲突时不能套用 auto。"
103
+ end
104
+ when "vlan_vni_without_nve", "evpn_vni_without_nve"
105
+ if l2_context?(config, vni, vlans) && !member && bgp_replication?(nve) && (evpn ? auto_evpn?(evpn) : auto_policy?(config))
106
+ commands = [ "interface #{nve['interface']}", " member vni #{vni}", " ingress-replication protocol bgp", "exit", "exit" ]
107
+ rollback = [ "interface #{nve['interface']}", " no member vni #{vni}", "exit" ]
108
+ commands.concat(evpn_add(vni)) unless evpn
109
+ rollback.concat(evpn_remove(vni)) unless evpn
110
+ candidate(result, commands, rollback, "本机既有 L2 成员统一使用 BGP 复制、EVPN auto 策略;仅补齐缺失的 NVE/EVPN,不启用额外 ARP 抑制。")
111
+ else
112
+ result[:blockers] << "需确认 VNI 的 L2/L3 角色、VLAN、NVE 接口、复制模式和 RD/RT,当前不能唯一确定增量配置。"
113
+ end
114
+ when "nve_l2_without_vlan"
115
+ result[:blockers] << "请提供本 VNI 对应 VLAN ID,并核对 access/trunk/SVI;不能由 VNI 尾数或其他设备名称猜测。"
116
+ result[:template] = [ "vlan <VLAN_ID>", " vn-segment #{vni}" ]
117
+ else
118
+ result[:blockers] << "需要明确 VRF/VNI 业务映射和 L2/L3 角色;先核对设计,再补齐或清理残留配置。"
119
+ end
120
+ result
121
+ end
122
+
123
+ # 同一 VNI 只能有一个有效 VLAN,且不能同时属于 VRF。
124
+ def l2_context?(config, vni, vlans)
125
+ nve = config.fetch("nve", {})
126
+ config["nv_overlay_evpn"] == true && nve["shutdown"] == false && nve["host_reachability"] == "bgp" &&
127
+ nve["interface"].to_s.match?(/\Anve[1-9]\d*\z/i) && vlans.size == 1 && vlans.first.match?(/\A\d+\z/) && vlans.first.to_i.between?(1, 4094) &&
128
+ config.fetch("vrfs", {}).values.none? { |vrf| vrf["vni"] == vni }
129
+ end
130
+
131
+ # 仅识别本机全部 L2 EVPN 都使用显式 auto 的情况,不推断跨 ASN 策略。
132
+ def auto_policy?(config)
133
+ policies = config.fetch("evpn", {}).values.select { |settings| settings["type"] == "l2" }
134
+ policies.any? && policies.all? { |settings| auto_evpn?(settings) }
135
+ end
136
+
137
+ # 正反方向必须同时存在且没有额外定制 RT。
138
+ def auto_evpn?(settings)
139
+ targets = settings.fetch("route_targets", [])
140
+ settings["type"] == "l2" && settings["rd"] == "auto" && targets.size == 2 &&
141
+ targets.map { |rt| rt["direction"] }.sort == %w[export import] &&
142
+ targets.all? { |rt| rt["target"] == "auto" && rt["evpn"] == false }
143
+ end
144
+
145
+ # 混合组播或静态复制时不生成 BGP 复制候选。
146
+ def bgp_replication?(nve)
147
+ members = nve.fetch("members", {}).values.reject { |settings| settings["associate_vrf"] }
148
+ members.any? && members.all? { |settings| settings["replication"] == "bgp" && !settings["multicast_group"] }
149
+ end
150
+
151
+ # NX-OS L2 EVPN 子模式增量;不包含保存配置命令。
152
+ def evpn_add(vni)
153
+ [ "evpn", " vni #{vni} l2", " rd auto", " route-target import auto", " route-target export auto", "exit", "exit" ]
154
+ end
155
+
156
+ # 只删除本候选新增的子节点,保留既有全局配置。
157
+ def evpn_remove(vni) = [ "evpn", " no vni #{vni} l2", "exit" ]
158
+
159
+ # 候选仍需人工确认业务意图,不能作为自动变更许可。
160
+ def candidate(result, commands, rollback, assumption)
161
+ result[:status] = "candidate"
162
+ result[:prerequisites] << assumption
163
+ result[:commands] = [ "configure terminal", *commands, "end" ]
164
+ result[:rollback] = [ "configure terminal", *rollback, "end" ]
165
+ end
166
+
167
+ # 空或部分证据只生成重新采集任务,不按缺失配置处理。
168
+ def evidence_tasks
169
+ selected = @dimensions.reject { |name, _| name == "collection" }
170
+ selected = { "configuration" => { "status" => "not_requested" } } if selected.empty?
171
+ selected.filter_map do |name, observation|
172
+ next if observation["status"] == "complete" && (name != "configuration" || Config::Evidence.complete_record(@document))
173
+ evidence = { status: observation["status"], issues: observation.fetch("issues", []).map { |issue| issue["code"] } }
174
+ if name == "configuration"
175
+ records = observation.fetch("records", [])
176
+ evidence.merge!(configuration_records: records.size, review_status: records.map { |record| record["review_status"] })
177
+ end
178
+ item("evidence_incomplete", name, "证据不完整,先补采", evidence: evidence,
179
+ verify: Array(CiscoNxos::Profile::COMMANDS[name.to_sym]), category: "evidence").merge(status: "investigate", expected: "完整回显或明确的零记录证明;空响应不能等同无配置。")
180
+ end
181
+ end
182
+
183
+ # 运行异常只生成定位建议;Down 也可能是计划停用,不能自动 no shutdown。
184
+ def runtime_tasks
185
+ %w[nve_vnis nve_peers bgp_neighbors].filter_map do |dimension|
186
+ rows = @dimensions.fetch(dimension, {}).fetch("records", []).select { |row| %w[down idle active connect opensent openconfirm].include?(row["state"].to_s.downcase) }
187
+ next if rows.empty?
188
+ item("#{dimension}_not_up", dimension, "观测到未建立的运行状态", evidence: rows,
189
+ verify: [ CiscoNxos::Profile::COMMANDS[dimension.to_sym], "show running-config" ], category: "runtime").merge(status: "investigate", prerequisites: [ "先确认业务预期,再检查对端、底层路由、接口和配置;不自动重启邻居或启用接口。" ])
190
+ end
191
+ end
192
+ end
193
+ end