vxlan 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/README-zh.md +32 -4
- data/README.md +32 -4
- data/bin/README.md +203 -0
- data/bin/vxlan +8 -0
- data/docs/architecture-zh.md +62 -60
- data/docs/evidence-contracts.md +54 -0
- data/docs/remediation.md +84 -0
- data/docs/review-2026-10-19.md +31 -0
- data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
- data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
- data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
- data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
- data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
- data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
- data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
- data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
- data/examples/README.md +20 -0
- data/examples/collect.rb +8 -0
- data/examples/parse.rb +5 -0
- data/lib/vxlan/acl_policy.rb +50 -0
- data/lib/vxlan/change_plan/svi_acl.rb +76 -0
- data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
- data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
- data/lib/vxlan/change_plan.rb +131 -0
- data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
- data/lib/vxlan/cisco_nxos/evpn_l2route.rb +2 -2
- data/lib/vxlan/cisco_nxos/parser.rb +2 -2
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +67 -13
- data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
- data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +5 -4
- data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +40 -8
- data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +11 -5
- data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
- data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
- data/lib/vxlan/cli/apply.rb +172 -0
- data/lib/vxlan/cli/collect.rb +210 -0
- data/lib/vxlan/cli/connector.rb +23 -0
- data/lib/vxlan/cli/options.rb +311 -0
- data/lib/vxlan/cli/parse.rb +58 -0
- data/lib/vxlan/cli/report.rb +223 -0
- data/lib/vxlan/cli/review/fabric.rb +102 -0
- data/lib/vxlan/cli/review/report.rb +165 -0
- data/lib/vxlan/cli/review.rb +125 -0
- data/lib/vxlan/cli/templates/env.example +12 -0
- data/lib/vxlan/cli/templates/vxlan.yml +32 -0
- data/lib/vxlan/cli.rb +41 -0
- data/lib/vxlan/collector.rb +59 -28
- data/lib/vxlan/config/evidence.rb +18 -0
- data/lib/vxlan/config/review.rb +56 -0
- data/lib/vxlan/{configuration.rb → config.rb} +1 -1
- data/lib/vxlan/fabric/review.rb +324 -0
- data/lib/vxlan/fabric/scope.rb +43 -0
- data/lib/vxlan/observation.rb +3 -0
- data/lib/vxlan/recommendations.rb +193 -0
- data/lib/vxlan/service/catalog.rb +77 -0
- data/lib/vxlan/service/mapping.rb +53 -0
- data/lib/vxlan/snapshot.rb +3 -0
- data/lib/vxlan/subnet_index.rb +88 -0
- data/lib/vxlan/version.rb +1 -1
- data/lib/vxlan.rb +4 -2
- metadata +137 -7
- data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
- 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
|
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
references = configs.transform_values { |config| config.fetch("acls", {}).values.flatten.flat_map { |entry| entry["expression"].scan(/\baddrgroup\s+(\S+)/).flatten }.uniq }
|
|
251
|
+
names = references.values.flatten.uniq.sort
|
|
252
|
+
names.each do |name|
|
|
253
|
+
rows = configs.filter_map { |host, config| { host: host, value: config.fetch("address_groups", {})[name] } if references[host].include?(name) }
|
|
254
|
+
next unless rows.map { |row| row[:value] }.uniq.size > 1
|
|
255
|
+
finding(fabric, nil, "fabric_address_group", "ACL 地址组 #{name} 成员不同或缺失;仅比较引用此对象的业务,不自动复制", rows)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# 同 VRF 内按规范网段区间扫描,保留跨 VNI 的包含关系;同网段多台证据先合并。
|
|
260
|
+
def review_subnets(fabric, configs)
|
|
261
|
+
subnets = Hash.new { |hash, key| hash[key] = [] }
|
|
262
|
+
configs.each do |host, config|
|
|
263
|
+
config.fetch("interfaces", {}).each do |name, svi|
|
|
264
|
+
next unless name.match?(/\AVlan\d+\z/) && svi["anycast"]
|
|
265
|
+
vni = config.fetch("vlan_vnis", {})[name.delete_prefix("Vlan")]
|
|
266
|
+
next unless vni
|
|
267
|
+
svi.fetch("addresses", []).each do |address|
|
|
268
|
+
network = IPAddr.new(address)
|
|
269
|
+
subnets[[ svi["vrf"], network.ipv4?, network.to_i, network.to_range.last.to_i ]] << { host: host, interface: name, vni: vni, address: address }
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
subnets.group_by { |(vrf, ipv4, _start, _finish), _rows| [ vrf, ipv4 ] }.each do |(vrf, _ipv4), networks|
|
|
274
|
+
active = []
|
|
275
|
+
networks.sort_by { |(key, _rows)| key[2] }.each do |key, rows|
|
|
276
|
+
active.reject! { |other_key, _other_rows| other_key[3] < key[2] }
|
|
277
|
+
conflicts = active.filter_map do |_other_key, other_rows|
|
|
278
|
+
combined = other_rows + rows
|
|
279
|
+
combined if combined.map { |row| row[:vni] }.uniq.size > 1
|
|
280
|
+
end
|
|
281
|
+
conflicts << rows if rows.map { |row| row[:vni] }.uniq.size > 1
|
|
282
|
+
conflicts.each do |evidence|
|
|
283
|
+
finding(fabric, nil, "fabric_subnet_vni_conflict", "VRF #{vrf} 的网段在不同 VNI 上重叠,需核对迁移或隔离设计", evidence)
|
|
284
|
+
end
|
|
285
|
+
active << [ key, rows ]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# 属性比较保留各叶节点值;显式缺失与 nil 不静默套用设备默认值。
|
|
291
|
+
def compare(fabric, vni, code, title, rows, &value)
|
|
292
|
+
evidence = observations(rows, &value)
|
|
293
|
+
finding(fabric, vni, code, title, evidence) if evidence.map { |row| row[:value] }.uniq.size > 1
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# 观察附带设备名、地址及本地 VLAN,报告可直接定位配置入口。
|
|
297
|
+
def observations(rows)
|
|
298
|
+
rows.map { |row| { host: row[:host], name: @documents[row[:host]]["device"]["name"], vlan: row[:vlan], value: yield(row) } }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# 按业务问题计数,不把证据行数当作问题数量。
|
|
302
|
+
def finding(fabric, vni, code, title, evidence)
|
|
303
|
+
@findings << { fabric: fabric, role: @role, vni: vni, code: code, title: title, evidence: evidence }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# 与单设备报告共享建议字段,跨设备来源完整保留。
|
|
307
|
+
def recommendation(fabric, vni, code, title, evidence)
|
|
308
|
+
{ code: code, category: "fabric", scope: "#{fabric} / #{@role} / #{vni ? "VNI #{vni}" : '角色核查'}", title: title, status: "blocked", evidence: evidence,
|
|
309
|
+
prerequisites: [ "仅在本 Fabric、相同已部署业务范围内适用;核对业务访问策略与维护窗口。", "下发前重新读取配置,任何变化均使计划过期;逐设备逐项确认,不批量重放。" ], blockers: [],
|
|
310
|
+
commands: [], rollback: [], verify: [ "show running-config", "show nve vni", "show ip interface brief vrf all" ],
|
|
311
|
+
impact: "改变网关或 ACL 可能改变可达性及业务隔离;配置回读通过后仍需业务验收。", expected: "共同业务的网关和访问控制一致,允许/拒绝流量均符合设计。", references: [] }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# 类型化增量才可导出可交互执行的计划,冲突项只有证据和待确认信息。
|
|
315
|
+
def propose(item, plan, service_blocked:)
|
|
316
|
+
if service_blocked
|
|
317
|
+
item[:blockers] << "共同业务的承载关联或复制/路由声明存在冲突,先解决这些前置问题,再生成网关或 ACL 增量。"
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
item.merge!(status: "candidate", change: plan.to_h, commands: plan.commands, rollback: plan.rollback)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
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
|
data/lib/vxlan/observation.rb
CHANGED
|
@@ -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
|