vxlan 0.4.1 → 0.5.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 +21 -7
- data/README-zh.md +1 -1
- data/README.md +3 -1
- data/bin/README.md +2 -2
- data/docs/superpowers/plans/2026-10-22-ctx-cli.md +138 -0
- data/docs/superpowers/specs/2026-10-22-ctx-cli-design.md +123 -0
- data/lib/vxlan/change_plan/svi_acl.rb +7 -7
- data/lib/vxlan/change_plan.rb +8 -15
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +2 -2
- data/lib/vxlan/cisco_nxos/parsers/show_interface_brief.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_port_channel_summary.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +2 -2
- data/lib/vxlan/cisco_nxos/syntax/policies.rb +1 -1
- data/lib/vxlan/cisco_nxos/syntax/routing.rb +1 -1
- data/lib/vxlan/cli/apply.rb +32 -30
- data/lib/vxlan/cli/collect.rb +13 -5
- data/lib/vxlan/cli/options.rb +47 -44
- data/lib/vxlan/cli/parse.rb +12 -12
- data/lib/vxlan/cli/report.rb +14 -14
- data/lib/vxlan/cli/review/fabric.rb +24 -22
- data/lib/vxlan/cli/review/report.rb +31 -30
- data/lib/vxlan/cli/review.rb +42 -73
- data/lib/vxlan/cli.rb +4 -4
- data/lib/vxlan/collection_plan.rb +1 -1
- data/lib/vxlan/fabric/review.rb +43 -43
- data/lib/vxlan/fabric/scope.rb +6 -6
- data/lib/vxlan/recommendations.rb +26 -26
- data/lib/vxlan/version.rb +1 -1
- metadata +17 -21
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "ctx_cli/input"
|
|
5
|
+
require "ctx_runner/fingerprint"
|
|
4
6
|
require_relative "../../fabric/review"
|
|
5
7
|
|
|
6
8
|
module Vxlan
|
|
@@ -11,35 +13,35 @@ module Vxlan
|
|
|
11
13
|
# 相同事实合并展示;规则版本保持匹配顺序,完整证据由聚合计划保存。
|
|
12
14
|
def fabric_text(audit, documents)
|
|
13
15
|
by_host = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
|
|
14
|
-
lines = [ "Fabric
|
|
16
|
+
lines = [ "Fabric shared-service consistency", "Scope: compare shared VNIs within explicit Fabrics and roles; compare Leaf and Border separately, and check Spine for service-bearing configuration.", "Deployment facts combine VLAN/NVE/EVPN evidence; incomplete deployment does not mean absence. Deployment scope differences are retained.", "ACL comparison includes ordered rules and dependencies; a majority without an ACL is not grounds for removal.", "" ]
|
|
15
17
|
inventory = audit[:inventory]
|
|
16
18
|
if inventory
|
|
17
|
-
lines << "
|
|
19
|
+
lines << "Coverage: included #{inventory[:assigned].size} devices; excluded #{inventory[:unassigned].size} devices; missing evidence #{inventory[:missing].size} devices."
|
|
18
20
|
inventory[:groups].each do |fabric, roles|
|
|
19
|
-
lines << "#{fabric}
|
|
21
|
+
lines << "#{fabric}: Leaf #{roles['leaves'].size} / Border #{roles['borders'].size} / Spine #{roles['spines'].size}"
|
|
20
22
|
%w[leaves borders].each do |role|
|
|
21
23
|
services = audit[:coverage].select { |entry| entry[:fabric] == fabric && entry[:role] == role }
|
|
22
24
|
partial = services.select { |entry| entry[:incomplete].any? }
|
|
23
|
-
lines << " #{role}
|
|
24
|
-
partial.each { |entry| lines << " VNI #{entry[:vni]}
|
|
25
|
+
lines << " #{role}: deployed services #{services.size}, services shared across devices #{services.count { |entry| entry[:deployed].size >= 2 }}, incomplete associations #{partial.size}."
|
|
26
|
+
partial.each { |entry| lines << " VNI #{entry[:vni]} missing: #{entry[:incomplete].map { |host| by_host.dig(host, 'device', 'name') || host }.join(', ')}" }
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
lines << ""
|
|
28
30
|
end
|
|
29
31
|
audit[:findings].each_with_index do |finding, index|
|
|
30
|
-
lines << "#{index + 1}. #{finding[:fabric]} / #{finding[:role] || '
|
|
32
|
+
lines << "#{index + 1}. #{finding[:fabric]} / #{finding[:role] || 'entire Fabric'} / #{finding[:vni] ? "VNI #{finding[:vni]}" : 'shared policy'}: #{finding[:title]}"
|
|
31
33
|
end
|
|
32
34
|
audit[:findings].each_with_index do |finding, index|
|
|
33
|
-
lines.concat([ "", "[#{index + 1}] #{finding[:fabric]} / #{finding[:role] || '
|
|
35
|
+
lines.concat([ "", "[#{index + 1}] #{finding[:fabric]} / #{finding[:role] || 'entire Fabric'} / #{finding[:vni] || 'shared policy'} — #{finding[:title]}", "Rule: #{finding[:code]}" ])
|
|
34
36
|
evidence = finding[:evidence]
|
|
35
37
|
if finding[:code] == "fabric_address_group"
|
|
36
38
|
defined = evidence.filter_map { |row| row[:value] }
|
|
37
39
|
common = defined.reduce { |all, members| all & members } || []
|
|
38
|
-
lines << "
|
|
40
|
+
lines << " Members common to defined versions (not an approved baseline): #{common.empty? ? 'none' : common.join(', ')}"
|
|
39
41
|
evidence.group_by { |row| row[:value] }.each do |members, rows|
|
|
40
42
|
names = rows.map { |row| by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
41
|
-
value = members ? "#{members.size}
|
|
42
|
-
lines.concat([ " #{names.join('
|
|
43
|
+
value = members ? "#{members.size} members; outside the common set: #{(members - common).empty? ? 'none' : (members - common).join(', ')}" : "No definition provided or object evidence not collected"
|
|
44
|
+
lines.concat([ " #{names.join(', ')}", " #{value}" ])
|
|
43
45
|
end
|
|
44
46
|
next
|
|
45
47
|
end
|
|
@@ -51,18 +53,18 @@ module Vxlan
|
|
|
51
53
|
value = row.key?(:value) ? row[:value] : row.reject { |key, _| %i[host name vlan entries semantics].include?(key) }
|
|
52
54
|
value = value.reject { |key, _| key == :entries } if value.is_a?(Hash)
|
|
53
55
|
if row[:semantics]
|
|
54
|
-
policy = row[:policy] ? "
|
|
55
|
-
value = "#{row[:entries].size}
|
|
56
|
+
policy = row[:policy] ? "Rule and dependency version #{CtxRunner::Fingerprint.call(row[:policy])[0, 12]}" : "Dependencies unverified"
|
|
57
|
+
value = "#{row[:entries].size} rules, #{policy}"
|
|
56
58
|
end
|
|
57
|
-
[ row[:vlan] || row[:interface] || "
|
|
59
|
+
[ row[:vlan] || row[:interface] || "shared", display_fabric_value(value) ]
|
|
58
60
|
end
|
|
59
61
|
grouped.each do |(vlan, value), members|
|
|
60
62
|
names = members.map { |row| row[:name] || by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
61
|
-
lines.concat([ " VLAN
|
|
63
|
+
lines.concat([ " VLAN/interface #{vlan}: #{value}", " #{names.join(', ')}" ])
|
|
62
64
|
end
|
|
63
65
|
if evidence.any? { |row| row[:semantics] }
|
|
64
66
|
evidence.group_by { |row| row[:semantics] }.each_with_index do |(rules, members), variant|
|
|
65
|
-
lines << "
|
|
67
|
+
lines << " Rule version #{variant + 1}: #{members.map { |row| by_host.dig(row[:host], 'device', 'name') || row[:host] }.join(', ')}"
|
|
66
68
|
lines.concat(rules.map { |rule| " #{rule['action']} #{rule['expression']}" })
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -73,14 +75,14 @@ module Vxlan
|
|
|
73
75
|
# 机器字段转换为运维读法,显式保留缺失和未知。
|
|
74
76
|
def display_fabric_value(value)
|
|
75
77
|
if value.is_a?(Hash) && value.key?(:acl)
|
|
76
|
-
return "
|
|
77
|
-
return "#{value[:acl]}
|
|
78
|
-
return "#{value[:acl]}
|
|
79
|
-
"#{value[:acl]}
|
|
78
|
+
return "No ACL attached" unless value[:acl]
|
|
79
|
+
return "#{value[:acl]}; local definition missing" unless value[:defined]
|
|
80
|
+
return "#{value[:acl]}; defined, but dependencies unverified" unless value[:policy_verified]
|
|
81
|
+
"#{value[:acl]}; defined, version #{value[:digest].to_s[0, 12]}"
|
|
80
82
|
elsif value.is_a?(Array)
|
|
81
|
-
value.empty? ? "
|
|
83
|
+
value.empty? ? "No address configured" : value.join(", ")
|
|
82
84
|
elsif value.nil?
|
|
83
|
-
"
|
|
85
|
+
"not provided"
|
|
84
86
|
elsif value.is_a?(Hash)
|
|
85
87
|
JSON.generate(value)
|
|
86
88
|
else
|
|
@@ -91,7 +93,7 @@ module Vxlan
|
|
|
91
93
|
# 实时收尾读取已经保存的事实;审查摘要交给采集摘要一起发布,避免相互覆盖。
|
|
92
94
|
def review_collected_fabric(policy)
|
|
93
95
|
sources = Dir[File.join(directory, "evidence", "*.json")].sort.map do |path|
|
|
94
|
-
body =
|
|
96
|
+
body = CtxCli::Input.read(path: path)
|
|
95
97
|
[ File.expand_path(path), body, JSON.parse(body) ]
|
|
96
98
|
end
|
|
97
99
|
build_review(sources, policy: policy, standalone: false) unless sources.empty?
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "ctx_runner/fingerprint"
|
|
4
5
|
require_relative "../../recommendations"
|
|
5
6
|
require_relative "../../change_plan"
|
|
6
7
|
|
|
@@ -27,7 +28,7 @@ module Vxlan
|
|
|
27
28
|
|
|
28
29
|
# 配置和回退集中于每设备一份 cfg;有限类型动作汇总到批次计划,不接受 cfg 直接执行。
|
|
29
30
|
def write_review(document, basename:, source:, source_sha256:, advice: nil)
|
|
30
|
-
raise ArgumentError, "
|
|
31
|
+
raise ArgumentError, "Invalid report name" unless basename.match?(/\A[A-Za-z0-9_.-]+\z/)
|
|
31
32
|
advice = report_advice(advice || Recommendations.new(document).call).merge(source: source, source_sha256: source_sha256)
|
|
32
33
|
advice[:items] = advice[:items].map(&:dup)
|
|
33
34
|
entry = { device: advice[:device], counts: advice[:counts], basename: basename, report: "devices/#{basename}.txt", source: source,
|
|
@@ -39,8 +40,8 @@ module Vxlan
|
|
|
39
40
|
plan = ChangePlan.candidate(item[:change], config: config)
|
|
40
41
|
next unless plan
|
|
41
42
|
change = plan.to_h
|
|
42
|
-
id = [ basename, change["kind"], "vlan#{change['vlan']}", change["direction"],
|
|
43
|
-
config_sha256 ||=
|
|
43
|
+
id = [ basename, change["kind"], "vlan#{change['vlan']}", change["direction"], CtxRunner::Fingerprint.call(change)[0, 12] ].compact.join("--")
|
|
44
|
+
config_sha256 ||= CtxRunner::Fingerprint.call(config)
|
|
44
45
|
entry[:changes][id] = { schema_version: 1, platform: "cisco_nxos", device: document.fetch("device"), expected_config_sha256: config_sha256,
|
|
45
46
|
source: source, source_sha256: source_sha256, change: change, commands: plan.commands, rollback: plan.rollback,
|
|
46
47
|
scope: item[:scope], title: item[:title], prerequisites: item[:prerequisites], impact: item[:impact], verify: item[:verify] }
|
|
@@ -94,14 +95,14 @@ module Vxlan
|
|
|
94
95
|
|
|
95
96
|
# 每项包含作用域、条件、命令及注释化回退,不将多项当成原子变更。
|
|
96
97
|
def script_text(items)
|
|
97
|
-
lines = [ "!
|
|
98
|
+
lines = [ "! Candidate configuration, not executed; review the matching devices/*.txt first. Do not paste the entire batch.", "! apply reads only the explicitly selected item from plan.json; it does not execute this cfg file." ]
|
|
98
99
|
items.each do |item|
|
|
99
|
-
lines.concat([ "", "! #{item[:scope]} / #{item[:title]}", "!
|
|
100
|
-
lines.concat(item[:prerequisites].map { |condition| "!
|
|
100
|
+
lines.concat([ "", "! #{item[:scope]} / #{item[:title]}", "! Plan item: #{item[:plan_id] || 'Review draft only; interactive execution unsupported'}" ])
|
|
101
|
+
lines.concat(item[:prerequisites].map { |condition| "! Prerequisite: #{condition}" })
|
|
101
102
|
lines.concat(item[:commands])
|
|
102
|
-
lines << "!
|
|
103
|
+
lines << "! Conditional rollback (not executed automatically): "
|
|
103
104
|
lines.concat(item[:rollback].map { |command| "! #{command}" })
|
|
104
|
-
lines << "!
|
|
105
|
+
lines << "! Verification: #{item[:verify].join(' ; ')}"
|
|
105
106
|
end
|
|
106
107
|
lines.join("\n") + "\n"
|
|
107
108
|
end
|
|
@@ -109,27 +110,27 @@ module Vxlan
|
|
|
109
110
|
# 文本按设备、证据、问题、动作顺序组织,完整跨设备证据位于聚合计划。
|
|
110
111
|
def device_text(entry, document)
|
|
111
112
|
advice, device = entry.values_at(:advice, :device)
|
|
112
|
-
lines = [ "
|
|
113
|
-
"
|
|
114
|
-
lines << "
|
|
113
|
+
lines = [ "Device: #{device['name']} (#{device['host']})", "Mode: diagnostics and planning; no device configuration executed", "Evidence time: #{advice[:observed_at] || 'unknown'}", "Source: #{entry[:source]}", "Source file SHA256: #{entry[:source_sha256]}",
|
|
114
|
+
"Recommendations: #{entry[:counts]}", "Command file: #{entry[:script] || 'not generated'}", "Command SHA256: #{entry[:script_sha256] || 'none'}", "Plan file: plan.json; interactive plan items: #{entry[:plans].size} items", "" ]
|
|
115
|
+
lines << "Collection status: #{document['status']}" if document["status"]
|
|
115
116
|
configuration_time = document.dig("snapshot", "dimensions", "configuration", "evidence", 0, "observed_at")
|
|
116
|
-
lines << "
|
|
117
|
-
lines << "
|
|
117
|
+
lines << "Configuration evidence time: #{configuration_time}" if configuration_time
|
|
118
|
+
lines << "No current rules matched; this does not establish network health." if advice[:items].empty?
|
|
118
119
|
advice[:items].each_with_index do |item, index|
|
|
119
|
-
lines.concat([ "", "#{index + 1}. #{item[:scope]} — #{item[:title]}", "
|
|
120
|
+
lines.concat([ "", "#{index + 1}. #{item[:scope]} — #{item[:title]}", "Status: #{item[:status]}; rule: #{item[:code]}", "Impact: #{item[:impact]}" ])
|
|
120
121
|
evidence = item[:category] == "fabric" ? Array(item[:evidence]).select { |row| row[:host] == device["host"] } : item[:evidence]
|
|
121
|
-
lines.concat([ "
|
|
122
|
-
lines << "
|
|
122
|
+
lines.concat([ "Local evidence: ", JSON.pretty_generate(evidence) ])
|
|
123
|
+
lines << "Cross-device comparison: summary.txt; full raw comparison: audits / fabric in plan.json." if item[:category] == "fabric"
|
|
123
124
|
if item[:plan_id]
|
|
124
|
-
lines << "
|
|
125
|
-
lines << "
|
|
125
|
+
lines << "Interactive plan item: #{item[:plan_id]}"
|
|
126
|
+
lines << "Run in the batch directory: vxlan apply --plan plan.json --item #{item[:plan_id]}"
|
|
126
127
|
end
|
|
127
|
-
{ prerequisites: "
|
|
128
|
+
{ prerequisites: "Prerequisites", blockers: "Unresolved information", commands: "Candidate configuration (not executed)", rollback: "Conditional rollback", verify: "Read-only verification", template: "Incomplete template (not executable)" }.each do |key, title|
|
|
128
129
|
values = item[key] || []
|
|
129
|
-
lines.concat([ "#{title}
|
|
130
|
+
lines.concat([ "#{title}: ", *values.map { |value| " #{value}" } ]) if values.any?
|
|
130
131
|
end
|
|
131
|
-
lines << "
|
|
132
|
-
lines << "
|
|
132
|
+
lines << "Acceptance: #{item[:expected]}" if item[:expected]
|
|
133
|
+
lines << "Command reference: #{item[:references].join(' ')}" if item[:references]&.any?
|
|
133
134
|
end
|
|
134
135
|
lines.join("\n") + "\n"
|
|
135
136
|
end
|
|
@@ -138,15 +139,15 @@ module Vxlan
|
|
|
138
139
|
def summary_text(summary)
|
|
139
140
|
entries, counts = summary.values_at(:reports, :counts)
|
|
140
141
|
active = entries.count { |entry| entry[:counts].values.sum.positive? }
|
|
141
|
-
lines = [ "VXLAN
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"evidence/
|
|
146
|
-
"plan.json.sha256
|
|
142
|
+
lines = [ "VXLAN diagnostics and configuration recommendations; mode: review; no device changes executed.", "Report completion status is authoritative in summary.json.", "Devices: #{entries.size} devices; with recommendations #{active} devices; no rules matched #{entries.size - active} devices (does not establish health).",
|
|
143
|
+
"Recommendations: unresolved #{counts['blocked']}, candidates #{counts['candidate']}, investigations #{counts['investigate']}; aggregated Fabric differences #{summary[:fabric_findings]} items.",
|
|
144
|
+
"Plan: #{entries.sum { |entry| entry[:plans].size }} changes available for interactive execution; #{entries.count { |entry| entry[:script] }} candidate scripts. Candidate counts differ from executable plan counts.",
|
|
145
|
+
"Read in order: summary.txt -> devices/<device>.txt -> scripts/<device>.cfg -> the selected plan item in plan.json.",
|
|
146
|
+
"evidence/ stores collection evidence used by the program; routine review does not require opening it. devices/ contains readable diagnostics only.",
|
|
147
|
+
"plan.json.sha256 verifies the aggregate plan; events.jsonl records this offline review, not device sessions or execution receipts.", "Applying requires selecting one --item, interactive confirmation, configuration readback and fingerprint verification; there is no automatic bulk execution.", "" ]
|
|
147
148
|
entries.sort_by { |entry| [ entry[:counts].values.sum.positive? ? 0 : 1, entry[:device]["name"].to_s ] }.each do |entry|
|
|
148
|
-
lines.concat([ "#{entry[:device]['host']} #{entry[:device]['name']}
|
|
149
|
-
"
|
|
149
|
+
lines.concat([ "#{entry[:device]['host']} #{entry[:device]['name']} unresolved=#{entry[:counts].fetch('blocked', 0)} candidates=#{entry[:counts].fetch('candidate', 0)} investigations=#{entry[:counts].fetch('investigate', 0)}",
|
|
150
|
+
" Device report: #{entry[:report]}", " Command file: #{entry[:script] || 'not generated'}; executable plans: #{entry[:plans].size} items" ])
|
|
150
151
|
end
|
|
151
152
|
lines.concat([ "", @fabric_text ]) if @fabric_text
|
|
152
153
|
lines.join("\n") + "\n"
|
data/lib/vxlan/cli/review.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
5
|
-
require "
|
|
3
|
+
require "ctx_cli/command"
|
|
4
|
+
require "ctx_cli/review"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "ipaddr"
|
|
7
|
+
require "ctx_runner/evidence"
|
|
6
8
|
require_relative "report"
|
|
7
9
|
|
|
8
10
|
module Vxlan
|
|
@@ -16,107 +18,74 @@ module Vxlan
|
|
|
16
18
|
|
|
17
19
|
# 返回零只代表建议生成完成,不代表问题已修复或候选配置已获批准。
|
|
18
20
|
def run(argv)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
p.
|
|
22
|
-
p.on("--
|
|
23
|
-
p.on("--
|
|
24
|
-
p.on("
|
|
25
|
-
p.on("--output DIR", "私有输出根目录,默认批次内 reviews") { |value| options[:output] = value }
|
|
26
|
-
p.on("-h", "--help", "查看帮助") { options[:help] = true }
|
|
21
|
+
command = CtxCli::Command.new(banner: "Usage: vxlan review --batch DIR [--output DIR] (generate recommendations offline without executing configuration)", out: @out) do |p, options|
|
|
22
|
+
p.on("--fabric-policy FILE", "Explicit Fabric/Leaf/Border/Spine inventory YAML") { |value| options[:fabric_policy] = value }
|
|
23
|
+
p.on("--batch DIR", "Collected batch containing evidence/*.json") { |value| options[:batch] = value }
|
|
24
|
+
p.on("--[no-]scripts", "Optionally export configuration suggestions; diagnostics only by default") { |value| options[:scripts] = value }
|
|
25
|
+
p.on("--output DIR", "Private output root; defaults to reviews inside the batch") { |value| options[:output] = value }
|
|
26
|
+
p.on("-h", "--help", "Show help") { options[:help] = true }
|
|
27
27
|
end
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
body = File.read(path)
|
|
37
|
-
document = JSON.parse(body)
|
|
38
|
-
validate(document)
|
|
39
|
-
[ path, body, document, Recommendations.new(document).call ]
|
|
28
|
+
CtxCli::Review.new(command: command, directory: "evidence", adapter: self, out: @out, error: @error).run(argv)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 领域结构、建议和身份冲突必须在创建输出目录前全部校验。
|
|
32
|
+
def prepare(documents, options)
|
|
33
|
+
documents.each do |entry|
|
|
34
|
+
validate(entry[2])
|
|
35
|
+
entry << Recommendations.new(entry[2]).call
|
|
40
36
|
end
|
|
41
37
|
hosts = documents.map { |entry| entry[2]["device"]["host"] }
|
|
42
38
|
names = documents.map { |entry| CLI::Report.device_basename(entry[2].fetch("device")) }
|
|
43
|
-
raise ArgumentError, "
|
|
44
|
-
policy = options[:fabric_policy] && YAML.safe_load(
|
|
39
|
+
raise ArgumentError, "Duplicate device identity or output name" unless hosts.uniq == hosts && names.uniq == names
|
|
40
|
+
policy = options[:fabric_policy] && YAML.safe_load(CtxCli::Input.read(path: options[:fabric_policy]), aliases: false)
|
|
45
41
|
Vxlan::Fabric::Scope.new(policy) if policy
|
|
42
|
+
policy
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 公共入口完成预检后才调用发布;领域报告格式保持独立。
|
|
46
|
+
def publish(documents, options, batch:, context:)
|
|
46
47
|
report = CLI::Report.new(options[:output] || File.join(batch, "reviews"), scripts: options[:scripts] == true)
|
|
47
|
-
report.build_review(documents, policy:
|
|
48
|
-
|
|
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
|
|
48
|
+
report.build_review(documents, policy: context)
|
|
49
|
+
"Recommendations generated: #{report.directory}/summary.txt; #{documents.size} devices; no device changes executed."
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
private
|
|
56
53
|
|
|
57
|
-
#
|
|
54
|
+
# 先校验公共证据外壳,再验证领域记录。
|
|
58
55
|
def validate(document)
|
|
59
|
-
|
|
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
|
|
56
|
+
CtxRunner::Evidence.validate(document) { |record, dimension| validate_record(record, dimension) }
|
|
88
57
|
end
|
|
89
58
|
|
|
90
59
|
# 在输出目录创建前验证所有会参与建议生成的嵌套字段。
|
|
91
60
|
def validate_record(record, dimension)
|
|
92
61
|
return unless dimension == "configuration"
|
|
93
|
-
|
|
62
|
+
CtxRunner::Evidence.array(record.fetch("review_findings", []))
|
|
94
63
|
nve = record.fetch("nve", {})
|
|
95
|
-
raise ArgumentError, "NVE/VLAN
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
64
|
+
raise ArgumentError, "Invalid NVE/VLAN structure" unless nve.is_a?(Hash) && record.fetch("vlan_vnis", {}).is_a?(Hash)
|
|
65
|
+
CtxRunner::Evidence.map(nve.fetch("members", {}))
|
|
66
|
+
CtxRunner::Evidence.map(record.fetch("vrfs", {}))
|
|
67
|
+
CtxRunner::Evidence.map(record.fetch("evpn", {})).each_value { |entry| CtxRunner::Evidence.array(entry.fetch("route_targets", [])) }
|
|
68
|
+
CtxRunner::Evidence.map(record.fetch("interfaces", {})).each_value do |interface|
|
|
100
69
|
addresses, bindings = interface.fetch("addresses", []), interface.fetch("acl_bindings", {})
|
|
101
70
|
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, "
|
|
71
|
+
raise ArgumentError, "Invalid interface structure"
|
|
103
72
|
end
|
|
104
73
|
addresses.each { |address| IPAddr.new(address) }
|
|
105
74
|
end
|
|
106
75
|
acls = record.fetch("acls", {})
|
|
107
|
-
raise ArgumentError, "ACL
|
|
76
|
+
raise ArgumentError, "Invalid ACL structure" unless acls.is_a?(Hash)
|
|
108
77
|
acls.each_value do |entries|
|
|
109
|
-
|
|
78
|
+
CtxRunner::Evidence.array(entries).each do |entry|
|
|
110
79
|
unless entry["sequence"].is_a?(Integer) && %w[permit deny remark].include?(entry["action"]) && entry["expression"].is_a?(String)
|
|
111
|
-
raise ArgumentError, "ACL
|
|
80
|
+
raise ArgumentError, "Invalid ACL rule structure"
|
|
112
81
|
end
|
|
113
82
|
end
|
|
114
83
|
end
|
|
115
84
|
groups = record.fetch("address_groups", {})
|
|
116
|
-
raise ArgumentError, "
|
|
85
|
+
raise ArgumentError, "Invalid address group structure" unless groups.is_a?(Hash)
|
|
117
86
|
groups.each_value do |members|
|
|
118
87
|
unless members.is_a?(Array) && members.all? { |member| member.is_a?(String) && member.include?("/") && IPAddr.new(member).ipv4? }
|
|
119
|
-
raise ArgumentError, "
|
|
88
|
+
raise ArgumentError, "Invalid address group members"
|
|
120
89
|
end
|
|
121
90
|
end
|
|
122
91
|
end
|
data/lib/vxlan/cli.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "vxlan/version"
|
|
4
|
-
require "ctx_cli/
|
|
4
|
+
require "ctx_cli/router"
|
|
5
5
|
|
|
6
6
|
module Vxlan
|
|
7
7
|
# 命令入口按需加载采集或解析实现;核心库无需加载 CLI。
|
|
@@ -13,7 +13,7 @@ module Vxlan
|
|
|
13
13
|
begin
|
|
14
14
|
require_relative "cli/collect"
|
|
15
15
|
rescue LoadError
|
|
16
|
-
error.puts "collect
|
|
16
|
+
error.puts "collect requires a current net-connector with shared inventory filters; install the project dependencies."
|
|
17
17
|
next 2
|
|
18
18
|
end
|
|
19
19
|
Collect.new(env: env, out: out, error: error, input: input, **collect_options).run(arguments)
|
|
@@ -31,12 +31,12 @@ module Vxlan
|
|
|
31
31
|
Parse.new(out: out, error: error, input: input, command: collect_options.fetch(:command, "vxlan parse")).run(arguments)
|
|
32
32
|
end
|
|
33
33
|
}
|
|
34
|
-
CtxCli::
|
|
34
|
+
CtxCli::Router.new(name: "vxlan", version: Vxlan::VERSION, usage: USAGE,
|
|
35
35
|
commands: commands, out: out, error: error).run(argv)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
# 全局帮助不读取配置、环境文件或设备清单。
|
|
39
|
-
USAGE = "
|
|
39
|
+
USAGE = "Usage: vxlan collect [options] Read-only collection and reports\n vxlan parse [options] Parse offline files or stdin\n vxlan review --batch DIR Offline remediation recommendations\n vxlan apply --plan FILE --item ID Apply after interactive confirmation\n vxlan --version\nUse vxlan collect --help, vxlan parse --help or vxlan review --help for options; --help-all for full conventions, --examples for examples.".freeze
|
|
40
40
|
private_constant :USAGE
|
|
41
41
|
end
|
|
42
42
|
end
|