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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../../fabric/review"
|
|
5
|
+
|
|
6
|
+
module Vxlan
|
|
7
|
+
module CLI
|
|
8
|
+
class Review
|
|
9
|
+
# Fabric 文本只呈现已有发现,不重新计算基线或混入下发决策。
|
|
10
|
+
module Fabric
|
|
11
|
+
# 相同事实合并展示;规则版本保持匹配顺序,完整证据由聚合计划保存。
|
|
12
|
+
def fabric_text(audit, documents)
|
|
13
|
+
by_host = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
|
|
14
|
+
lines = [ "Fabric 共同业务一致性", "范围:按显式 Fabric 和角色比较共同 VNI;Leaf、Border 分开比较,Spine 检查业务承载配置。", "部署事实联合 VLAN/NVE/EVPN 识别;不完整部署不等于未部署。保留部署范围差异。", "ACL 比较包含有序规则与依赖对象;不以多数无 ACL 为删除依据。", "" ]
|
|
15
|
+
inventory = audit[:inventory]
|
|
16
|
+
if inventory
|
|
17
|
+
lines << "覆盖:已纳入 #{inventory[:assigned].size} 台;未纳入 #{inventory[:unassigned].size} 台;缺证据 #{inventory[:missing].size} 台。"
|
|
18
|
+
inventory[:groups].each do |fabric, roles|
|
|
19
|
+
lines << "#{fabric}:Leaf #{roles['leaves'].size} / Border #{roles['borders'].size} / Spine #{roles['spines'].size}"
|
|
20
|
+
%w[leaves borders].each do |role|
|
|
21
|
+
services = audit[:coverage].select { |entry| entry[:fabric] == fabric && entry[:role] == role }
|
|
22
|
+
partial = services.select { |entry| entry[:incomplete].any? }
|
|
23
|
+
lines << " #{role}:已部署业务 #{services.size},多设备共同业务 #{services.count { |entry| entry[:deployed].size >= 2 }},关联不完整 #{partial.size}。"
|
|
24
|
+
partial.each { |entry| lines << " VNI #{entry[:vni]} 待补齐:#{entry[:incomplete].map { |host| by_host.dig(host, 'device', 'name') || host }.join('、')}" }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
lines << ""
|
|
28
|
+
end
|
|
29
|
+
audit[:findings].each_with_index do |finding, index|
|
|
30
|
+
lines << "#{index + 1}. #{finding[:fabric]} / #{finding[:role] || '全 Fabric'} / #{finding[:vni] ? "VNI #{finding[:vni]}" : '公共策略'}:#{finding[:title]}"
|
|
31
|
+
end
|
|
32
|
+
audit[:findings].each_with_index do |finding, index|
|
|
33
|
+
lines.concat([ "", "[#{index + 1}] #{finding[:fabric]} / #{finding[:role] || '全 Fabric'} / #{finding[:vni] || '公共策略'} — #{finding[:title]}", "规则:#{finding[:code]}" ])
|
|
34
|
+
evidence = finding[:evidence]
|
|
35
|
+
if finding[:code] == "fabric_address_group"
|
|
36
|
+
defined = evidence.filter_map { |row| row[:value] }
|
|
37
|
+
common = defined.reduce { |all, members| all & members } || []
|
|
38
|
+
lines << " 已定义版本的共同成员(不代表批准基线):#{common.empty? ? '无' : common.join('、')}"
|
|
39
|
+
evidence.group_by { |row| row[:value] }.each do |members, rows|
|
|
40
|
+
names = rows.map { |row| by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
41
|
+
value = members ? "#{members.size} 个成员;共同成员之外:#{(members - common).empty? ? '无' : (members - common).join('、')}" : "未提供定义或未采集对象证据"
|
|
42
|
+
lines.concat([ " #{names.join('、')}", " #{value}" ])
|
|
43
|
+
end
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
unless evidence.is_a?(Array) && evidence.all? { |row| row.is_a?(Hash) }
|
|
47
|
+
lines << JSON.pretty_generate(evidence)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
grouped = evidence.group_by do |row|
|
|
51
|
+
value = row.key?(:value) ? row[:value] : row.reject { |key, _| %i[host name vlan entries semantics].include?(key) }
|
|
52
|
+
value = value.reject { |key, _| key == :entries } if value.is_a?(Hash)
|
|
53
|
+
if row[:semantics]
|
|
54
|
+
policy = row[:policy] ? "规则与依赖版本 #{ChangePlan.fingerprint(row[:policy])[0, 12]}" : "依赖对象未验证"
|
|
55
|
+
value = "#{row[:entries].size} 条规则,#{policy}"
|
|
56
|
+
end
|
|
57
|
+
[ row[:vlan] || row[:interface] || "公共", display_fabric_value(value) ]
|
|
58
|
+
end
|
|
59
|
+
grouped.each do |(vlan, value), members|
|
|
60
|
+
names = members.map { |row| row[:name] || by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
61
|
+
lines.concat([ " VLAN/接口 #{vlan}:#{value}", " #{names.join('、')}" ])
|
|
62
|
+
end
|
|
63
|
+
if evidence.any? { |row| row[:semantics] }
|
|
64
|
+
evidence.group_by { |row| row[:semantics] }.each_with_index do |(rules, members), variant|
|
|
65
|
+
lines << " 规则版本 #{variant + 1}:#{members.map { |row| by_host.dig(row[:host], 'device', 'name') || row[:host] }.join('、')}"
|
|
66
|
+
lines.concat(rules.map { |rule| " #{rule['action']} #{rule['expression']}" })
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
lines.join("\n") + "\n"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 机器字段转换为运维读法,显式保留缺失和未知。
|
|
74
|
+
def display_fabric_value(value)
|
|
75
|
+
if value.is_a?(Hash) && value.key?(:acl)
|
|
76
|
+
return "未挂载 ACL" unless value[:acl]
|
|
77
|
+
return "#{value[:acl]};本机缺少定义" unless value[:defined]
|
|
78
|
+
return "#{value[:acl]};已定义,但依赖对象未验证" unless value[:policy_verified]
|
|
79
|
+
"#{value[:acl]};已定义,版本 #{value[:digest].to_s[0, 12]}"
|
|
80
|
+
elsif value.is_a?(Array)
|
|
81
|
+
value.empty? ? "未配置地址" : value.join("、")
|
|
82
|
+
elsif value.nil?
|
|
83
|
+
"未提供"
|
|
84
|
+
elsif value.is_a?(Hash)
|
|
85
|
+
JSON.generate(value)
|
|
86
|
+
else
|
|
87
|
+
value.to_s
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 实时收尾读取已经保存的事实;审查摘要交给采集摘要一起发布,避免相互覆盖。
|
|
92
|
+
def review_collected_fabric(policy)
|
|
93
|
+
sources = Dir[File.join(directory, "evidence", "*.json")].sort.map do |path|
|
|
94
|
+
body = File.read(path)
|
|
95
|
+
[ File.expand_path(path), body, JSON.parse(body) ]
|
|
96
|
+
end
|
|
97
|
+
build_review(sources, policy: policy, standalone: false) unless sources.empty?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../../recommendations"
|
|
5
|
+
require_relative "../../change_plan"
|
|
6
|
+
|
|
7
|
+
module Vxlan
|
|
8
|
+
module CLI
|
|
9
|
+
class Review
|
|
10
|
+
# 与 topology 同序编排:先合并诊断,再发布设备报告、脚本、聚合计划和摘要。
|
|
11
|
+
module Report
|
|
12
|
+
# 所有比较先完成;每台设备只发布一次最终建议,不再写后回填 Fabric 差异。
|
|
13
|
+
def build_review(sources, policy: nil, standalone: true)
|
|
14
|
+
documents = sources.map { |entry| entry[2] }
|
|
15
|
+
@fabric_audit = policy && Vxlan::Fabric::Review.new(documents, policy).call
|
|
16
|
+
@fabric_text = @fabric_audit && fabric_text(@fabric_audit, documents)
|
|
17
|
+
entries = sources.map do |path, body, document, initial|
|
|
18
|
+
advice = initial || Recommendations.new(document).call
|
|
19
|
+
additions = @fabric_audit && @fabric_audit[:device_items][document["device"]["host"]]
|
|
20
|
+
advice = Recommendations.combine(advice, additions)
|
|
21
|
+
device = document.fetch("device")
|
|
22
|
+
basename = CLI::Report.device_basename(device)
|
|
23
|
+
write_review(document, basename: basename, source: path, source_sha256: Digest::SHA256.hexdigest(body), advice: advice)
|
|
24
|
+
end
|
|
25
|
+
finish_review(entries, source_batch: File.dirname(File.dirname(sources.first[0])), standalone: standalone)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 配置和回退集中于每设备一份 cfg;有限类型动作汇总到批次计划,不接受 cfg 直接执行。
|
|
29
|
+
def write_review(document, basename:, source:, source_sha256:, advice: nil)
|
|
30
|
+
raise ArgumentError, "非法报告名称" unless basename.match?(/\A[A-Za-z0-9_.-]+\z/)
|
|
31
|
+
advice = report_advice(advice || Recommendations.new(document).call).merge(source: source, source_sha256: source_sha256)
|
|
32
|
+
advice[:items] = advice[:items].map(&:dup)
|
|
33
|
+
entry = { device: advice[:device], counts: advice[:counts], basename: basename, report: "devices/#{basename}.txt", source: source,
|
|
34
|
+
source_sha256: source_sha256, script: nil, script_sha256: nil, plans: [], changes: {}, advice: advice }
|
|
35
|
+
config = Config::Evidence.complete_record(document)
|
|
36
|
+
config_sha256 = nil
|
|
37
|
+
advice[:items].each do |item|
|
|
38
|
+
next unless @scripts && config && item[:status] == "candidate" && item[:change]
|
|
39
|
+
plan = ChangePlan.candidate(item[:change], config: config)
|
|
40
|
+
next unless plan
|
|
41
|
+
change = plan.to_h
|
|
42
|
+
id = [ basename, change["kind"], "vlan#{change['vlan']}", change["direction"], ChangePlan.fingerprint(change)[0, 12] ].compact.join("--")
|
|
43
|
+
config_sha256 ||= ChangePlan.fingerprint(config)
|
|
44
|
+
entry[:changes][id] = { schema_version: 1, platform: "cisco_nxos", device: document.fetch("device"), expected_config_sha256: config_sha256,
|
|
45
|
+
source: source, source_sha256: source_sha256, change: change, commands: plan.commands, rollback: plan.rollback,
|
|
46
|
+
scope: item[:scope], title: item[:title], prerequisites: item[:prerequisites], impact: item[:impact], verify: item[:verify] }
|
|
47
|
+
item[:plan_id] = id
|
|
48
|
+
entry[:plans] << id
|
|
49
|
+
end
|
|
50
|
+
candidates = advice[:items].select { |item| item[:status] == "candidate" && item[:commands].any? }
|
|
51
|
+
if @scripts && candidates.any?
|
|
52
|
+
script = script_text(candidates)
|
|
53
|
+
entry[:script] = "scripts/#{basename}.cfg"
|
|
54
|
+
entry[:script_sha256] = Digest::SHA256.hexdigest(script)
|
|
55
|
+
prepare_directory(File.join(directory, "scripts"))
|
|
56
|
+
publish(entry[:script], script)
|
|
57
|
+
end
|
|
58
|
+
prepare_directory(File.join(directory, "devices"))
|
|
59
|
+
publish(entry[:report], device_text(entry, document))
|
|
60
|
+
entry
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# JSON 状态最后提交;中途失败保留未完成检查点和已经发布的事实。
|
|
64
|
+
def finish_review(entries, source_batch:, standalone: true)
|
|
65
|
+
counts = entries.each_with_object(Hash.new(0)) { |entry, total| entry[:counts].each { |key, count| total[key] += count } }
|
|
66
|
+
public_entries = entries.map { |entry| entry.reject { |key, _| %i[advice changes].include?(key) } }
|
|
67
|
+
summary = { layout_version: 3, mode: "review", status: "incomplete", review_only: true, devices: entries.size, counts: counts, source_batch: source_batch,
|
|
68
|
+
reports: public_entries, plan: "plan.json", plan_sha256: nil, fabric_findings: @fabric_audit ? @fabric_audit[:findings].size : 0 }
|
|
69
|
+
publish("summary.json", JSON.pretty_generate(summary) + "\n") if standalone
|
|
70
|
+
plan = { schema_version: 2, platform: "cisco_nxos", mode: "review", devices: public_entries, audits: entries.map { |entry| entry[:advice] },
|
|
71
|
+
changes: entries.each_with_object({}) { |entry, all| all.merge!(entry[:changes]) }, fabric: @fabric_audit && @fabric_audit.reject { |key, _| key == :device_items } }
|
|
72
|
+
body = JSON.pretty_generate(plan) + "\n"
|
|
73
|
+
summary[:plan_sha256] = Digest::SHA256.hexdigest(body)
|
|
74
|
+
publish("plan.json", body)
|
|
75
|
+
publish("plan.json.sha256", summary[:plan_sha256] + "\n")
|
|
76
|
+
events = public_entries.map { |entry| JSON.generate(event: "device_reviewed", reviewed_at: Time.now.utc.iso8601(6), host: entry[:device]["host"], source_sha256: entry[:source_sha256], counts: entry[:counts], report: entry[:report], script: entry[:script], executed: false) }
|
|
77
|
+
publish("events.jsonl", events.join("\n") + (events.empty? ? "" : "\n"))
|
|
78
|
+
text = summary_text(summary)
|
|
79
|
+
if standalone
|
|
80
|
+
publish("summary.txt", text)
|
|
81
|
+
begin
|
|
82
|
+
publish("summary.json", JSON.pretty_generate(summary.merge(status: "complete")) + "\n")
|
|
83
|
+
rescue CLI::Report::Error => error
|
|
84
|
+
restore_checkpoint(JSON.pretty_generate(summary) + "\n") if error.committed?
|
|
85
|
+
raise
|
|
86
|
+
rescue Interrupt
|
|
87
|
+
restore_checkpoint(JSON.pretty_generate(summary.merge(status: "interrupted")) + "\n")
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
@review_summary, @review_text = summary.merge(status: "complete"), text
|
|
92
|
+
@review_summary
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# 默认只输出诊断和核查步骤;命令和动作只在 --scripts 时保留。
|
|
98
|
+
def report_advice(advice)
|
|
99
|
+
return advice if @scripts
|
|
100
|
+
advice.merge(items: advice[:items].map { |item| item.reject { |key, _| %i[change template].include?(key) }.merge(commands: [], rollback: []) })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 每项包含作用域、条件、命令及注释化回退,不将多项当成原子变更。
|
|
104
|
+
def script_text(items)
|
|
105
|
+
lines = [ "! 候选配置,未执行;先审阅同名 devices/*.txt;禁止整批直接粘贴。", "! apply 只读取 plan.json 中明确选择的单项,不执行本 cfg。" ]
|
|
106
|
+
items.each do |item|
|
|
107
|
+
lines.concat([ "", "! #{item[:scope]} / #{item[:title]}", "! 计划项:#{item[:plan_id] || '仅审阅草稿,不支持交互执行'}" ])
|
|
108
|
+
lines.concat(item[:prerequisites].map { |condition| "! 条件:#{condition}" })
|
|
109
|
+
lines.concat(item[:commands])
|
|
110
|
+
lines << "! 条件回退(不自动执行):"
|
|
111
|
+
lines.concat(item[:rollback].map { |command| "! #{command}" })
|
|
112
|
+
lines << "! 核查:#{item[:verify].join(' ; ')}"
|
|
113
|
+
end
|
|
114
|
+
lines.join("\n") + "\n"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 文本按设备、证据、问题、动作顺序组织,完整跨设备证据位于聚合计划。
|
|
118
|
+
def device_text(entry, document)
|
|
119
|
+
advice, device = entry.values_at(:advice, :device)
|
|
120
|
+
lines = [ "设备:#{device['name']}(#{device['host']})", "模式:诊断与计划,未执行设备配置", "证据时间:#{advice[:observed_at] || '未知'}", "来源:#{entry[:source]}", "源文件 SHA256:#{entry[:source_sha256]}",
|
|
121
|
+
"建议:#{entry[:counts]}", "命令文件:#{entry[:script] || '未生成'}", "命令 SHA256:#{entry[:script_sha256] || '无'}", "计划文件:plan.json;交互计划项 #{entry[:plans].size} 个", "" ]
|
|
122
|
+
lines << "采集状态:#{document['status']}" if document["status"]
|
|
123
|
+
configuration_time = document.dig("snapshot", "dimensions", "configuration", "evidence", 0, "observed_at")
|
|
124
|
+
lines << "配置证据时间:#{configuration_time}" if configuration_time
|
|
125
|
+
lines << "当前规则未命中;不代表网络健康。" if advice[:items].empty?
|
|
126
|
+
advice[:items].each_with_index do |item, index|
|
|
127
|
+
lines.concat([ "", "#{index + 1}. #{item[:scope]} — #{item[:title]}", "状态:#{item[:status]};规则:#{item[:code]}", "影响:#{item[:impact]}" ])
|
|
128
|
+
evidence = item[:category] == "fabric" ? Array(item[:evidence]).select { |row| row[:host] == device["host"] } : item[:evidence]
|
|
129
|
+
lines.concat([ "本机依据:", JSON.pretty_generate(evidence) ])
|
|
130
|
+
lines << "跨设备对比:summary.txt;完整原始对比:plan.json 的 audits / fabric。" if item[:category] == "fabric"
|
|
131
|
+
if item[:plan_id]
|
|
132
|
+
lines << "交互计划项:#{item[:plan_id]}"
|
|
133
|
+
lines << "在批次目录运行:vxlan apply --plan plan.json --item #{item[:plan_id]}"
|
|
134
|
+
end
|
|
135
|
+
{ prerequisites: "适用条件", blockers: "待决信息", commands: "候选配置(未执行)", rollback: "条件回退", verify: "只读核查", template: "待补模板(不可执行)" }.each do |key, title|
|
|
136
|
+
values = item[key] || []
|
|
137
|
+
lines.concat([ "#{title}:", *values.map { |value| " #{value}" } ]) if values.any?
|
|
138
|
+
end
|
|
139
|
+
lines << "验收:#{item[:expected]}" if item[:expected]
|
|
140
|
+
lines << "命令参考:#{item[:references].join(' ')}" if item[:references]&.any?
|
|
141
|
+
end
|
|
142
|
+
lines.join("\n") + "\n"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 总览先给计数和入口,再给跨设备差异,详细命令只在设备报告/脚本中展示。
|
|
146
|
+
def summary_text(summary)
|
|
147
|
+
entries, counts = summary.values_at(:reports, :counts)
|
|
148
|
+
active = entries.count { |entry| entry[:counts].values.sum.positive? }
|
|
149
|
+
lines = [ "VXLAN 诊断与配置建议;模式:review;未执行设备变更。", "报告完成状态以 summary.json 为准。", "设备:#{entries.size} 台;有建议 #{active} 台;未命中规则 #{entries.size - active} 台(不代表健康)。",
|
|
150
|
+
"建议:待决 #{counts['blocked']},候选 #{counts['candidate']},排查 #{counts['investigate']};Fabric 聚合差异 #{summary[:fabric_findings]} 项。",
|
|
151
|
+
"计划:#{entries.sum { |entry| entry[:plans].size }} 项可交互执行增量;#{entries.count { |entry| entry[:script] }} 份候选脚本。候选数与可执行计划数不同。",
|
|
152
|
+
"阅读顺序:summary.txt → devices/<设备>.txt → scripts/<设备>.cfg → plan.json 中的指定计划项。",
|
|
153
|
+
"evidence/ 保存程序使用的采集证据,日常阅读无需打开;devices/ 只包含可读诊断。",
|
|
154
|
+
"plan.json.sha256 校验聚合计划;events.jsonl 记录本次离线审查,不代表设备会话或下发回执。", "下发必须 --item 选择单项、交互确认、重读配置并核对指纹;无自动批量执行。", "" ]
|
|
155
|
+
entries.sort_by { |entry| [ entry[:counts].values.sum.positive? ? 0 : 1, entry[:device]["name"].to_s ] }.each do |entry|
|
|
156
|
+
lines.concat([ "#{entry[:device]['host']} #{entry[:device]['name']} 待决=#{entry[:counts].fetch('blocked', 0)} 候选=#{entry[:counts].fetch('candidate', 0)} 排查=#{entry[:counts].fetch('investigate', 0)}",
|
|
157
|
+
" 设备报告:#{entry[:report]}", " 命令文件:#{entry[:script] || '未生成'};可执行计划:#{entry[:plans].size} 项" ])
|
|
158
|
+
end
|
|
159
|
+
lines.concat([ "", @fabric_text ]) if @fabric_text
|
|
160
|
+
lines.join("\n") + "\n"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "report"
|
|
7
|
+
|
|
8
|
+
module Vxlan
|
|
9
|
+
module CLI
|
|
10
|
+
# 只读打开已有批次,生成独立建议批次;不加载凭据或建立设备连接。
|
|
11
|
+
class Review
|
|
12
|
+
# 注入输出流,离线批次可通过 CLI 或宿主调用。
|
|
13
|
+
def initialize(out: $stdout, error: $stderr)
|
|
14
|
+
@out, @error = out, error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 返回零只代表建议生成完成,不代表问题已修复或候选配置已获批准。
|
|
18
|
+
def run(argv)
|
|
19
|
+
options = {}
|
|
20
|
+
parser = OptionParser.new do |p|
|
|
21
|
+
p.banner = "用法:vxlan review --batch DIR [--output DIR](离线生成建议,不执行配置)"
|
|
22
|
+
p.on("--fabric-policy FILE", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |value| options[:fabric_policy] = value }
|
|
23
|
+
p.on("--batch DIR", "已采集批次,含 evidence/*.json") { |value| options[:batch] = value }
|
|
24
|
+
p.on("--[no-]scripts", "可选导出配置建议,默认仅诊断") { |value| options[:scripts] = value }
|
|
25
|
+
p.on("--output DIR", "私有输出根目录,默认批次内 reviews") { |value| options[:output] = value }
|
|
26
|
+
p.on("-h", "--help", "查看帮助") { options[:help] = true }
|
|
27
|
+
end
|
|
28
|
+
rest = parser.parse(argv.dup)
|
|
29
|
+
return @out.puts(parser) || 0 if options[:help]
|
|
30
|
+
raise ArgumentError, "需要批次目录" unless rest.empty? && options[:batch]
|
|
31
|
+
batch = File.expand_path(options[:batch])
|
|
32
|
+
paths = Dir[File.join(batch, "evidence", "*.json")].sort
|
|
33
|
+
raise ArgumentError, "无设备报告" if paths.empty?
|
|
34
|
+
# 先校验所有输入,损坏文件不会留下看似完成的部分建议批次。
|
|
35
|
+
documents = paths.map do |path|
|
|
36
|
+
body = File.read(path)
|
|
37
|
+
document = JSON.parse(body)
|
|
38
|
+
validate(document)
|
|
39
|
+
[ path, body, document, Recommendations.new(document).call ]
|
|
40
|
+
end
|
|
41
|
+
hosts = documents.map { |entry| entry[2]["device"]["host"] }
|
|
42
|
+
names = documents.map { |entry| CLI::Report.device_basename(entry[2].fetch("device")) }
|
|
43
|
+
raise ArgumentError, "设备身份或输出名称重复" unless hosts.uniq == hosts && names.uniq == names
|
|
44
|
+
policy = options[:fabric_policy] && YAML.safe_load(File.read(options[:fabric_policy]), aliases: false)
|
|
45
|
+
Vxlan::Fabric::Scope.new(policy) if policy
|
|
46
|
+
report = CLI::Report.new(options[:output] || File.join(batch, "reviews"), scripts: options[:scripts] == true)
|
|
47
|
+
report.build_review(documents, policy: policy)
|
|
48
|
+
@out.puts "建议已生成:#{report.directory}/summary.txt;#{documents.size} 台;未执行设备变更。"
|
|
49
|
+
0
|
|
50
|
+
rescue Psych::Exception, OptionParser::ParseError, ArgumentError, TypeError, KeyError, JSON::ParserError, SystemCallError, IOError => error
|
|
51
|
+
@error.puts "建议生成失败(#{error.class.name});请检查输入结构、目录权限和 --help;未执行设备变更。"
|
|
52
|
+
2
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# 仅接受宿主保存的设备快照边界;缺少快照的失败采集仍可生成补采任务。
|
|
58
|
+
def validate(document)
|
|
59
|
+
raise ArgumentError, "无设备身份" unless document.is_a?(Hash) && document["device"].is_a?(Hash) && %w[name host].all? { |key| document["device"][key].is_a?(String) }
|
|
60
|
+
host = document["device"]["host"]
|
|
61
|
+
raise ArgumentError, "目标地址须为规范主机地址" unless !host.include?("/") && IPAddr.new(host).to_s == host
|
|
62
|
+
snapshot = document["snapshot"]
|
|
63
|
+
raise ArgumentError, "无快照字段" unless document.key?("snapshot")
|
|
64
|
+
return if snapshot.nil?
|
|
65
|
+
raise ArgumentError, "快照结构错误" unless snapshot.is_a?(Hash) && snapshot["dimensions"].is_a?(Hash)
|
|
66
|
+
if snapshot.key?("target") && (!snapshot["target"].is_a?(Hash) || snapshot["target"]["host"] != host)
|
|
67
|
+
raise ArgumentError, "设备身份与采集目标不一致"
|
|
68
|
+
end
|
|
69
|
+
snapshot["dimensions"].each do |dimension, observation|
|
|
70
|
+
unless observation.is_a?(Hash) && %w[complete partial failed unsupported not_requested].include?(observation["status"]) && observation["records"].is_a?(Array) && observation["records"].all? { |record| record.is_a?(Hash) }
|
|
71
|
+
raise ArgumentError, "观测结构错误"
|
|
72
|
+
end
|
|
73
|
+
array_of_hashes(observation.fetch("issues", []))
|
|
74
|
+
observation["records"].each { |record| validate_record(record, dimension) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 校验规则读取的容器类型,错误消息不包含配置正文。
|
|
79
|
+
def array_of_hashes(value)
|
|
80
|
+
raise ArgumentError, "列表结构错误" unless value.is_a?(Array) && value.all? { |entry| entry.is_a?(Hash) }
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 映射值必须仍是记录,不能将损坏结构解释成空事实。
|
|
85
|
+
def hash_of_hashes(value)
|
|
86
|
+
raise ArgumentError, "映射结构错误" unless value.is_a?(Hash) && value.values.all? { |entry| entry.is_a?(Hash) }
|
|
87
|
+
value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# 在输出目录创建前验证所有会参与建议生成的嵌套字段。
|
|
91
|
+
def validate_record(record, dimension)
|
|
92
|
+
return unless dimension == "configuration"
|
|
93
|
+
array_of_hashes(record.fetch("review_findings", []))
|
|
94
|
+
nve = record.fetch("nve", {})
|
|
95
|
+
raise ArgumentError, "NVE/VLAN 结构错误" unless nve.is_a?(Hash) && record.fetch("vlan_vnis", {}).is_a?(Hash)
|
|
96
|
+
hash_of_hashes(nve.fetch("members", {}))
|
|
97
|
+
hash_of_hashes(record.fetch("vrfs", {}))
|
|
98
|
+
hash_of_hashes(record.fetch("evpn", {})).each_value { |entry| array_of_hashes(entry.fetch("route_targets", [])) }
|
|
99
|
+
hash_of_hashes(record.fetch("interfaces", {})).each_value do |interface|
|
|
100
|
+
addresses, bindings = interface.fetch("addresses", []), interface.fetch("acl_bindings", {})
|
|
101
|
+
unless addresses.is_a?(Array) && addresses.all? { |address| address.is_a?(String) && address.include?("/") } && bindings.is_a?(Hash) && bindings.all? { |direction, name| %w[in out].include?(direction) && name.is_a?(String) }
|
|
102
|
+
raise ArgumentError, "接口结构错误"
|
|
103
|
+
end
|
|
104
|
+
addresses.each { |address| IPAddr.new(address) }
|
|
105
|
+
end
|
|
106
|
+
acls = record.fetch("acls", {})
|
|
107
|
+
raise ArgumentError, "ACL 结构错误" unless acls.is_a?(Hash)
|
|
108
|
+
acls.each_value do |entries|
|
|
109
|
+
array_of_hashes(entries).each do |entry|
|
|
110
|
+
unless entry["sequence"].is_a?(Integer) && %w[permit deny remark].include?(entry["action"]) && entry["expression"].is_a?(String)
|
|
111
|
+
raise ArgumentError, "ACL 规则结构错误"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
groups = record.fetch("address_groups", {})
|
|
116
|
+
raise ArgumentError, "地址组结构错误" unless groups.is_a?(Hash)
|
|
117
|
+
groups.each_value do |members|
|
|
118
|
+
unless members.is_a?(Array) && members.all? { |member| member.is_a?(String) && member.include?("/") && IPAddr.new(member).ipv4? }
|
|
119
|
+
raise ArgumentError, "地址组成员错误"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 已有进程环境优先;密码中的 #、$ 请用单引号包裹。
|
|
2
|
+
# NC_DEVICE_USERNAME=operator
|
|
3
|
+
# NC_DEVICE_PASSWORD='replace-me'
|
|
4
|
+
# NC_ENABLE_PASSWORD='replace-me'
|
|
5
|
+
# 可用 SSH key/agent 时不要求密码;用户名必须明确。
|
|
6
|
+
# NC_CISCO_NXOS_USERNAME=operator
|
|
7
|
+
# NC_CISCO_NXOS_PASSWORD='replace-me'
|
|
8
|
+
# VXLAN_CONFIG=vxlan.yml
|
|
9
|
+
# VXLAN_FABRIC=default
|
|
10
|
+
# VXLAN_PARALLEL=4
|
|
11
|
+
# VXLAN_DIRECTORY=vxlans
|
|
12
|
+
# 使用 Netdisco 时,其凭据遵循 net-connector 的 ND_* 环境约定。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# 公开配置;凭据仅在环境中。CLI > 进程环境 > YAML > .env > 默认。
|
|
2
|
+
fabric: default
|
|
3
|
+
# 并发默认继承公共 NC_PARALLEL;仅需独立覆盖时设置 parallel。
|
|
4
|
+
# parallel: 4
|
|
5
|
+
directory: vxlans
|
|
6
|
+
# 默认采集全部维度;大表可以按需启用,单设备命令始终顺序执行。
|
|
7
|
+
dimensions:
|
|
8
|
+
- configuration
|
|
9
|
+
- cdp_neighbors
|
|
10
|
+
- nve_peers
|
|
11
|
+
- nve_vnis
|
|
12
|
+
- bgp_neighbors
|
|
13
|
+
- ospf_neighbors
|
|
14
|
+
- vpc
|
|
15
|
+
- interfaces
|
|
16
|
+
- port_channels
|
|
17
|
+
- mac_addresses
|
|
18
|
+
- l2route_macs
|
|
19
|
+
- l2route_mac_ips
|
|
20
|
+
ssh:
|
|
21
|
+
host_key_policy: strict
|
|
22
|
+
command_timeout: 60
|
|
23
|
+
login_timeout: 15
|
|
24
|
+
# 只操作此处明确列出的设备,不扫描。替换为实际设备后取消注释。
|
|
25
|
+
devices: []
|
|
26
|
+
# - host: 192.0.2.10
|
|
27
|
+
# name: LEAF-01
|
|
28
|
+
# model: N9K-C93180YC-FX
|
|
29
|
+
# - host: 192.0.2.11
|
|
30
|
+
# name: LEAF-02
|
|
31
|
+
# 可选:复用 net-connector 的 Netdisco YAML;与非空 devices 互斥。
|
|
32
|
+
# netdisco_config: /path/to/net-connector/examples/backup.yml
|
data/lib/vxlan/cli.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "vxlan/version"
|
|
4
|
+
|
|
5
|
+
module Vxlan
|
|
6
|
+
# 命令入口按需加载采集或解析实现;核心库无需加载 CLI。
|
|
7
|
+
module CLI
|
|
8
|
+
# 由调用方拥有退出行为,库入口只返回退出码。
|
|
9
|
+
def self.run(argv, env: ENV, out: $stdout, error: $stderr, input: $stdin, **collect_options)
|
|
10
|
+
arguments = argv.dup
|
|
11
|
+
command = arguments.shift
|
|
12
|
+
case command
|
|
13
|
+
when nil, "--help", "--help-all", "-h"
|
|
14
|
+
return usage(out) if arguments.empty?
|
|
15
|
+
when "--version", "-v"
|
|
16
|
+
return out.puts(Vxlan::VERSION) || 0 if arguments.empty?
|
|
17
|
+
when "collect"
|
|
18
|
+
require_relative "cli/collect"
|
|
19
|
+
return Collect.new(env: env, out: out, error: error, input: input, **collect_options).run(arguments)
|
|
20
|
+
when "apply"
|
|
21
|
+
require_relative "cli/apply"
|
|
22
|
+
return Apply.new(env: env, out: out, error: error, input: input, connector_factory: collect_options[:connector_factory], discovery: collect_options.fetch(:discovery, {})).run(arguments)
|
|
23
|
+
when "review"
|
|
24
|
+
require_relative "cli/review"
|
|
25
|
+
return Review.new(out: out, error: error).run(arguments)
|
|
26
|
+
when "parse"
|
|
27
|
+
require_relative "cli/parse"
|
|
28
|
+
return Parse.new(out: out, error: error, input: input, command: collect_options.fetch(:command, "vxlan parse")).run(arguments)
|
|
29
|
+
end
|
|
30
|
+
error.puts "未知命令或多余参数;使用 vxlan --help 查看用法"
|
|
31
|
+
2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 全局帮助不读取配置、环境文件或设备清单。
|
|
35
|
+
def self.usage(out)
|
|
36
|
+
out.puts "用法:vxlan collect [选项] 只读采集与报告\n vxlan parse [选项] 离线文件或 stdin 解析\n vxlan review --batch DIR 离线修复建议\n vxlan apply --plan FILE --item ID 交互确认后下发\n vxlan --version\n使用 vxlan collect --help 、vxlan parse --help 或 vxlan review --help 查看参数;--help-all 查看完整约定,--examples 查看范例。"
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
private_class_method :usage
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/vxlan/collector.rb
CHANGED
|
@@ -18,36 +18,19 @@ module Vxlan
|
|
|
18
18
|
|
|
19
19
|
# 通过调用方持有的连接器顺序采集请求维度。
|
|
20
20
|
def collect(connector:, target:, fabric_key:, model: nil, dimensions: COMMANDS.keys)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions && (dimensions - COMMANDS.keys).empty?
|
|
24
|
-
raise ArgumentError, "Unknown or duplicate VXLAN dimensions"
|
|
25
|
-
end
|
|
26
|
-
target = CtxParser::Immutable.copy(target)
|
|
27
|
-
fabric_key = CtxParser::Immutable.copy(fabric_key)
|
|
28
|
-
dimensions = dimensions.dup.freeze
|
|
29
|
-
raise ArgumentError, "VXLAN requires Cisco NX-OS" unless connector.platform.to_s == "cisco_nxos"
|
|
30
|
-
profile = ParserRegistry.fetch(connector.platform, model: model)
|
|
21
|
+
target, fabric_key, dimensions, profile = request(target, fabric_key, dimensions, connector.platform, model)
|
|
31
22
|
|
|
32
23
|
observations = {}
|
|
33
|
-
session_finished = false
|
|
24
|
+
session_started = session_finished = false
|
|
34
25
|
begin
|
|
35
26
|
connector.open do |session|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
observations[:collection] = failure(:collection, code)
|
|
27
|
+
if session_started
|
|
28
|
+
dimensions.each { |name| observations[name] ||= failure(name, :session_reentered) }
|
|
29
|
+
observations[:collection] = failure(:collection, :session_reentered)
|
|
40
30
|
next
|
|
41
31
|
end
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if name == :vpc && !reader.failure && unconfigured?(name, config)
|
|
45
|
-
observations[name] = Observation.new(name: name, status: :complete, completed_at: @clock.call,
|
|
46
|
-
issues: [ { code: :vpc_not_configured } ])
|
|
47
|
-
else
|
|
48
|
-
observations[name] = collect_observation(reader, name, config, profile)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
32
|
+
session_started = true
|
|
33
|
+
observations.merge!(read_dimensions(session, dimensions, profile))
|
|
51
34
|
session_finished = true
|
|
52
35
|
end
|
|
53
36
|
rescue StandardError => error
|
|
@@ -57,6 +40,57 @@ module Vxlan
|
|
|
57
40
|
dimensions.each { |name| observations[name] ||= failure(name, code) }
|
|
58
41
|
observations[:collection] = failure(:collection, session_finished ? :session_close_failed : code)
|
|
59
42
|
end
|
|
43
|
+
# 连接器正常返回也不等于已提供会话;缺少回调必须保留全部请求的失败事实。
|
|
44
|
+
unless session_finished || observations.key?(:collection)
|
|
45
|
+
dimensions.each { |name| observations[name] = failure(name, :session_not_opened) }
|
|
46
|
+
observations[:collection] = failure(:collection, :session_not_opened)
|
|
47
|
+
end
|
|
48
|
+
snapshot(target, fabric_key, observations)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 借用已打开的会话;调用方负责连接和关闭,本方法不重连、不提权。
|
|
52
|
+
def collect_session(session:, target:, fabric_key:, platform: :cisco_nxos, model: nil, dimensions: COMMANDS.keys)
|
|
53
|
+
target, fabric_key, dimensions, profile = request(target, fabric_key, dimensions, platform, model)
|
|
54
|
+
snapshot(target, fabric_key, read_dimensions(session, dimensions, profile))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# 连接前固定请求数据,生命周期入口和借用会话入口共用相同校验。
|
|
60
|
+
def request(target, fabric_key, dimensions, platform, model)
|
|
61
|
+
raise ArgumentError, "Target must be a hash" unless target.is_a?(Hash)
|
|
62
|
+
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions && (dimensions - COMMANDS.keys).empty?
|
|
63
|
+
raise ArgumentError, "Unknown or duplicate VXLAN dimensions"
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, "VXLAN requires Cisco NX-OS" unless platform.to_s == "cisco_nxos"
|
|
66
|
+
|
|
67
|
+
[ CtxParser::Immutable.copy(target), CtxParser::Immutable.copy(fabric_key), dimensions.dup.freeze,
|
|
68
|
+
ParserRegistry.fetch(platform, model: model) ]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 配置先于依赖它的运行查询读取;结果维度仍保持请求顺序。
|
|
72
|
+
def read_dimensions(session, dimensions, profile)
|
|
73
|
+
observations = {}
|
|
74
|
+
reader = CtxDriver::Reader.new(session: session, clock: @clock)
|
|
75
|
+
if (code = prepare_terminal(reader))
|
|
76
|
+
dimensions.each { |name| observations[name] = failure(name, code) }
|
|
77
|
+
observations[:collection] = failure(:collection, code)
|
|
78
|
+
return observations
|
|
79
|
+
end
|
|
80
|
+
ordered = dimensions.include?(:configuration) ? [ :configuration, *dimensions.reject { |name| name == :configuration } ] : dimensions
|
|
81
|
+
ordered.each do |name|
|
|
82
|
+
config = observations[:configuration]
|
|
83
|
+
observations[name] = if name == :vpc && !reader.failure && unconfigured?(name, config)
|
|
84
|
+
Observation.new(name: name, status: :complete, completed_at: @clock.call, issues: [ { code: :vpc_not_configured } ])
|
|
85
|
+
else
|
|
86
|
+
collect_observation(reader, name, config, profile)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
dimensions.to_h { |name| [ name, observations.fetch(name) ] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 汇总证据完整性,不将查询成功解释为 Fabric 健康。
|
|
93
|
+
def snapshot(target, fabric_key, observations)
|
|
60
94
|
status = if observations.values.all?(&:complete?)
|
|
61
95
|
:complete
|
|
62
96
|
elsif observations.values.any? { |value| %i[complete partial].include?(value.status) }
|
|
@@ -65,12 +99,9 @@ module Vxlan
|
|
|
65
99
|
:failed
|
|
66
100
|
end
|
|
67
101
|
observations[:collection] ||= Observation.new(name: :collection, status: status, completed_at: @clock.call)
|
|
68
|
-
Snapshot.new(target: target,
|
|
69
|
-
fabric_key: fabric_key, dimensions: observations, observed_at: @clock.call)
|
|
102
|
+
Snapshot.new(target: target, fabric_key: fabric_key, dimensions: observations, observed_at: @clock.call)
|
|
70
103
|
end
|
|
71
104
|
|
|
72
|
-
private
|
|
73
|
-
|
|
74
105
|
# 采集设备回显前,禁用分页并归一化终端宽度。
|
|
75
106
|
def prepare_terminal(reader)
|
|
76
107
|
PREPARATION_COMMANDS.each do |command|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vxlan
|
|
4
|
+
class Config
|
|
5
|
+
# 设备快照只有唯一且完成配置审查的记录,才能用于缺失推导或变更计划。
|
|
6
|
+
module Evidence
|
|
7
|
+
# 外层采集成功不能覆盖记录层的部分解析或多份配置歧义。
|
|
8
|
+
def self.complete_record(document)
|
|
9
|
+
observation = document&.dig("snapshot", "dimensions", "configuration")
|
|
10
|
+
return unless observation && observation["status"] == "complete"
|
|
11
|
+
records = observation["records"]
|
|
12
|
+
return unless records.is_a?(Array) && records.one?
|
|
13
|
+
record = records.first
|
|
14
|
+
record if record.is_a?(Hash) && record["review_status"] == "complete"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|