vxlan 0.4.2 → 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 +6 -0
- data/README-zh.md +1 -1
- data/README.md +1 -1
- data/bin/README.md +2 -2
- data/lib/vxlan/change_plan/svi_acl.rb +7 -7
- data/lib/vxlan/change_plan.rb +7 -7
- data/lib/vxlan/cli/apply.rb +28 -28
- data/lib/vxlan/cli/collect.rb +4 -4
- data/lib/vxlan/cli/options.rb +42 -41
- data/lib/vxlan/cli/parse.rb +12 -12
- data/lib/vxlan/cli/report.rb +11 -11
- data/lib/vxlan/cli/review/fabric.rb +21 -21
- data/lib/vxlan/cli/review/report.rb +28 -28
- data/lib/vxlan/cli/review.rb +14 -14
- data/lib/vxlan/cli.rb +2 -2
- data/lib/vxlan/fabric/review.rb +42 -42
- data/lib/vxlan/fabric/scope.rb +6 -6
- data/lib/vxlan/recommendations.rb +26 -26
- data/lib/vxlan/version.rb +1 -1
- metadata +15 -21
data/lib/vxlan/cli/report.rb
CHANGED
|
@@ -50,19 +50,19 @@ module Vxlan
|
|
|
50
50
|
def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
|
|
51
51
|
@report.finish(results, interrupted: interrupted, requested: requested, report_error: report_error,
|
|
52
52
|
execution_error: execution_error, output_error: output_error, review: @review_summary) do |summary|
|
|
53
|
-
pending = summary.fetch(
|
|
54
|
-
text = [ "VXLAN
|
|
55
|
-
text << "
|
|
53
|
+
pending = summary.fetch("pending")
|
|
54
|
+
text = [ "VXLAN evidence: #{summary.fetch('status')}; completed #{summary.fetch('completed')} devices", "Categories: #{summary.fetch('counts')}", "Complete evidence does not establish Fabric health." ]
|
|
55
|
+
text << "Per-device diagnostics: devices/; candidate scripts: scripts/; no candidate configuration was executed."
|
|
56
56
|
text << @review_text if @review_text
|
|
57
|
-
text << "
|
|
58
|
-
text << "
|
|
59
|
-
text << "
|
|
60
|
-
text << "
|
|
61
|
-
|
|
62
|
-
saved = r[
|
|
63
|
-
text << "#{r[
|
|
57
|
+
text << "Batch report status: see summary.json; evidence status in text does not confirm report publication."
|
|
58
|
+
text << "Report error: #{summary['report_error']}" if summary["report_error"]
|
|
59
|
+
text << "Execution error: #{summary['execution_error']}" if summary["execution_error"]
|
|
60
|
+
text << "Progress output error: #{summary['output_error']['error_type']}; device results retained" if summary["output_error"]
|
|
61
|
+
summary.fetch("devices").each do |r|
|
|
62
|
+
saved = r["report_saved"] == true ? "saved" : "persistence unconfirmed"
|
|
63
|
+
text << "#{r['device']['host']}\t#{r['device']['name']}\t#{r['status']}\t#{saved}\t#{r['error']}"
|
|
64
64
|
end
|
|
65
|
-
pending.each { |device| text << "#{device['host']}\t#{device['name']}\
|
|
65
|
+
pending.each { |device| text << "#{device['host']}\t#{device['name']}\tnot collected" }
|
|
66
66
|
text.join("\n") + "\n"
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -13,35 +13,35 @@ module Vxlan
|
|
|
13
13
|
# 相同事实合并展示;规则版本保持匹配顺序,完整证据由聚合计划保存。
|
|
14
14
|
def fabric_text(audit, documents)
|
|
15
15
|
by_host = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
|
|
16
|
-
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.", "" ]
|
|
17
17
|
inventory = audit[:inventory]
|
|
18
18
|
if inventory
|
|
19
|
-
lines << "
|
|
19
|
+
lines << "Coverage: included #{inventory[:assigned].size} devices; excluded #{inventory[:unassigned].size} devices; missing evidence #{inventory[:missing].size} devices."
|
|
20
20
|
inventory[:groups].each do |fabric, roles|
|
|
21
|
-
lines << "#{fabric}
|
|
21
|
+
lines << "#{fabric}: Leaf #{roles['leaves'].size} / Border #{roles['borders'].size} / Spine #{roles['spines'].size}"
|
|
22
22
|
%w[leaves borders].each do |role|
|
|
23
23
|
services = audit[:coverage].select { |entry| entry[:fabric] == fabric && entry[:role] == role }
|
|
24
24
|
partial = services.select { |entry| entry[:incomplete].any? }
|
|
25
|
-
lines << " #{role}
|
|
26
|
-
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(', ')}" }
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
lines << ""
|
|
30
30
|
end
|
|
31
31
|
audit[:findings].each_with_index do |finding, index|
|
|
32
|
-
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]}"
|
|
33
33
|
end
|
|
34
34
|
audit[:findings].each_with_index do |finding, index|
|
|
35
|
-
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]}" ])
|
|
36
36
|
evidence = finding[:evidence]
|
|
37
37
|
if finding[:code] == "fabric_address_group"
|
|
38
38
|
defined = evidence.filter_map { |row| row[:value] }
|
|
39
39
|
common = defined.reduce { |all, members| all & members } || []
|
|
40
|
-
lines << "
|
|
40
|
+
lines << " Members common to defined versions (not an approved baseline): #{common.empty? ? 'none' : common.join(', ')}"
|
|
41
41
|
evidence.group_by { |row| row[:value] }.each do |members, rows|
|
|
42
42
|
names = rows.map { |row| by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
43
|
-
value = members ? "#{members.size}
|
|
44
|
-
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}" ])
|
|
45
45
|
end
|
|
46
46
|
next
|
|
47
47
|
end
|
|
@@ -53,18 +53,18 @@ module Vxlan
|
|
|
53
53
|
value = row.key?(:value) ? row[:value] : row.reject { |key, _| %i[host name vlan entries semantics].include?(key) }
|
|
54
54
|
value = value.reject { |key, _| key == :entries } if value.is_a?(Hash)
|
|
55
55
|
if row[:semantics]
|
|
56
|
-
policy = row[:policy] ? "
|
|
57
|
-
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}"
|
|
58
58
|
end
|
|
59
|
-
[ row[:vlan] || row[:interface] || "
|
|
59
|
+
[ row[:vlan] || row[:interface] || "shared", display_fabric_value(value) ]
|
|
60
60
|
end
|
|
61
61
|
grouped.each do |(vlan, value), members|
|
|
62
62
|
names = members.map { |row| row[:name] || by_host.dig(row[:host], "device", "name") || row[:host] }
|
|
63
|
-
lines.concat([ " VLAN
|
|
63
|
+
lines.concat([ " VLAN/interface #{vlan}: #{value}", " #{names.join(', ')}" ])
|
|
64
64
|
end
|
|
65
65
|
if evidence.any? { |row| row[:semantics] }
|
|
66
66
|
evidence.group_by { |row| row[:semantics] }.each_with_index do |(rules, members), variant|
|
|
67
|
-
lines << "
|
|
67
|
+
lines << " Rule version #{variant + 1}: #{members.map { |row| by_host.dig(row[:host], 'device', 'name') || row[:host] }.join(', ')}"
|
|
68
68
|
lines.concat(rules.map { |rule| " #{rule['action']} #{rule['expression']}" })
|
|
69
69
|
end
|
|
70
70
|
end
|
|
@@ -75,14 +75,14 @@ module Vxlan
|
|
|
75
75
|
# 机器字段转换为运维读法,显式保留缺失和未知。
|
|
76
76
|
def display_fabric_value(value)
|
|
77
77
|
if value.is_a?(Hash) && value.key?(:acl)
|
|
78
|
-
return "
|
|
79
|
-
return "#{value[:acl]}
|
|
80
|
-
return "#{value[:acl]}
|
|
81
|
-
"#{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]}"
|
|
82
82
|
elsif value.is_a?(Array)
|
|
83
|
-
value.empty? ? "
|
|
83
|
+
value.empty? ? "No address configured" : value.join(", ")
|
|
84
84
|
elsif value.nil?
|
|
85
|
-
"
|
|
85
|
+
"not provided"
|
|
86
86
|
elsif value.is_a?(Hash)
|
|
87
87
|
JSON.generate(value)
|
|
88
88
|
else
|
|
@@ -28,7 +28,7 @@ module Vxlan
|
|
|
28
28
|
|
|
29
29
|
# 配置和回退集中于每设备一份 cfg;有限类型动作汇总到批次计划,不接受 cfg 直接执行。
|
|
30
30
|
def write_review(document, basename:, source:, source_sha256:, advice: nil)
|
|
31
|
-
raise ArgumentError, "
|
|
31
|
+
raise ArgumentError, "Invalid report name" unless basename.match?(/\A[A-Za-z0-9_.-]+\z/)
|
|
32
32
|
advice = report_advice(advice || Recommendations.new(document).call).merge(source: source, source_sha256: source_sha256)
|
|
33
33
|
advice[:items] = advice[:items].map(&:dup)
|
|
34
34
|
entry = { device: advice[:device], counts: advice[:counts], basename: basename, report: "devices/#{basename}.txt", source: source,
|
|
@@ -95,14 +95,14 @@ module Vxlan
|
|
|
95
95
|
|
|
96
96
|
# 每项包含作用域、条件、命令及注释化回退,不将多项当成原子变更。
|
|
97
97
|
def script_text(items)
|
|
98
|
-
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." ]
|
|
99
99
|
items.each do |item|
|
|
100
|
-
lines.concat([ "", "! #{item[:scope]} / #{item[:title]}", "!
|
|
101
|
-
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}" })
|
|
102
102
|
lines.concat(item[:commands])
|
|
103
|
-
lines << "!
|
|
103
|
+
lines << "! Conditional rollback (not executed automatically): "
|
|
104
104
|
lines.concat(item[:rollback].map { |command| "! #{command}" })
|
|
105
|
-
lines << "!
|
|
105
|
+
lines << "! Verification: #{item[:verify].join(' ; ')}"
|
|
106
106
|
end
|
|
107
107
|
lines.join("\n") + "\n"
|
|
108
108
|
end
|
|
@@ -110,27 +110,27 @@ module Vxlan
|
|
|
110
110
|
# 文本按设备、证据、问题、动作顺序组织,完整跨设备证据位于聚合计划。
|
|
111
111
|
def device_text(entry, document)
|
|
112
112
|
advice, device = entry.values_at(:advice, :device)
|
|
113
|
-
lines = [ "
|
|
114
|
-
"
|
|
115
|
-
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"]
|
|
116
116
|
configuration_time = document.dig("snapshot", "dimensions", "configuration", "evidence", 0, "observed_at")
|
|
117
|
-
lines << "
|
|
118
|
-
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?
|
|
119
119
|
advice[:items].each_with_index do |item, index|
|
|
120
|
-
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]}" ])
|
|
121
121
|
evidence = item[:category] == "fabric" ? Array(item[:evidence]).select { |row| row[:host] == device["host"] } : item[:evidence]
|
|
122
|
-
lines.concat([ "
|
|
123
|
-
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"
|
|
124
124
|
if item[:plan_id]
|
|
125
|
-
lines << "
|
|
126
|
-
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]}"
|
|
127
127
|
end
|
|
128
|
-
{ 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|
|
|
129
129
|
values = item[key] || []
|
|
130
|
-
lines.concat([ "#{title}
|
|
130
|
+
lines.concat([ "#{title}: ", *values.map { |value| " #{value}" } ]) if values.any?
|
|
131
131
|
end
|
|
132
|
-
lines << "
|
|
133
|
-
lines << "
|
|
132
|
+
lines << "Acceptance: #{item[:expected]}" if item[:expected]
|
|
133
|
+
lines << "Command reference: #{item[:references].join(' ')}" if item[:references]&.any?
|
|
134
134
|
end
|
|
135
135
|
lines.join("\n") + "\n"
|
|
136
136
|
end
|
|
@@ -139,15 +139,15 @@ module Vxlan
|
|
|
139
139
|
def summary_text(summary)
|
|
140
140
|
entries, counts = summary.values_at(:reports, :counts)
|
|
141
141
|
active = entries.count { |entry| entry[:counts].values.sum.positive? }
|
|
142
|
-
lines = [ "VXLAN
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"evidence/
|
|
147
|
-
"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.", "" ]
|
|
148
148
|
entries.sort_by { |entry| [ entry[:counts].values.sum.positive? ? 0 : 1, entry[:device]["name"].to_s ] }.each do |entry|
|
|
149
|
-
lines.concat([ "#{entry[:device]['host']} #{entry[:device]['name']}
|
|
150
|
-
"
|
|
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" ])
|
|
151
151
|
end
|
|
152
152
|
lines.concat([ "", @fabric_text ]) if @fabric_text
|
|
153
153
|
lines.join("\n") + "\n"
|
data/lib/vxlan/cli/review.rb
CHANGED
|
@@ -18,12 +18,12 @@ module Vxlan
|
|
|
18
18
|
|
|
19
19
|
# 返回零只代表建议生成完成,不代表问题已修复或候选配置已获批准。
|
|
20
20
|
def run(argv)
|
|
21
|
-
command = CtxCli::Command.new(banner: "
|
|
22
|
-
p.on("--fabric-policy FILE", "
|
|
23
|
-
p.on("--batch DIR", "
|
|
24
|
-
p.on("--[no-]scripts", "
|
|
25
|
-
p.on("--output DIR", "
|
|
26
|
-
p.on("-h", "--help", "
|
|
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
28
|
CtxCli::Review.new(command: command, directory: "evidence", adapter: self, out: @out, error: @error).run(argv)
|
|
29
29
|
end
|
|
@@ -36,7 +36,7 @@ module Vxlan
|
|
|
36
36
|
end
|
|
37
37
|
hosts = documents.map { |entry| entry[2]["device"]["host"] }
|
|
38
38
|
names = documents.map { |entry| CLI::Report.device_basename(entry[2].fetch("device")) }
|
|
39
|
-
raise ArgumentError, "
|
|
39
|
+
raise ArgumentError, "Duplicate device identity or output name" unless hosts.uniq == hosts && names.uniq == names
|
|
40
40
|
policy = options[:fabric_policy] && YAML.safe_load(CtxCli::Input.read(path: options[:fabric_policy]), aliases: false)
|
|
41
41
|
Vxlan::Fabric::Scope.new(policy) if policy
|
|
42
42
|
policy
|
|
@@ -46,7 +46,7 @@ module Vxlan
|
|
|
46
46
|
def publish(documents, options, batch:, context:)
|
|
47
47
|
report = CLI::Report.new(options[:output] || File.join(batch, "reviews"), scripts: options[:scripts] == true)
|
|
48
48
|
report.build_review(documents, policy: context)
|
|
49
|
-
"
|
|
49
|
+
"Recommendations generated: #{report.directory}/summary.txt; #{documents.size} devices; no device changes executed."
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
|
@@ -61,31 +61,31 @@ module Vxlan
|
|
|
61
61
|
return unless dimension == "configuration"
|
|
62
62
|
CtxRunner::Evidence.array(record.fetch("review_findings", []))
|
|
63
63
|
nve = record.fetch("nve", {})
|
|
64
|
-
raise ArgumentError, "NVE/VLAN
|
|
64
|
+
raise ArgumentError, "Invalid NVE/VLAN structure" unless nve.is_a?(Hash) && record.fetch("vlan_vnis", {}).is_a?(Hash)
|
|
65
65
|
CtxRunner::Evidence.map(nve.fetch("members", {}))
|
|
66
66
|
CtxRunner::Evidence.map(record.fetch("vrfs", {}))
|
|
67
67
|
CtxRunner::Evidence.map(record.fetch("evpn", {})).each_value { |entry| CtxRunner::Evidence.array(entry.fetch("route_targets", [])) }
|
|
68
68
|
CtxRunner::Evidence.map(record.fetch("interfaces", {})).each_value do |interface|
|
|
69
69
|
addresses, bindings = interface.fetch("addresses", []), interface.fetch("acl_bindings", {})
|
|
70
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) }
|
|
71
|
-
raise ArgumentError, "
|
|
71
|
+
raise ArgumentError, "Invalid interface structure"
|
|
72
72
|
end
|
|
73
73
|
addresses.each { |address| IPAddr.new(address) }
|
|
74
74
|
end
|
|
75
75
|
acls = record.fetch("acls", {})
|
|
76
|
-
raise ArgumentError, "ACL
|
|
76
|
+
raise ArgumentError, "Invalid ACL structure" unless acls.is_a?(Hash)
|
|
77
77
|
acls.each_value do |entries|
|
|
78
78
|
CtxRunner::Evidence.array(entries).each do |entry|
|
|
79
79
|
unless entry["sequence"].is_a?(Integer) && %w[permit deny remark].include?(entry["action"]) && entry["expression"].is_a?(String)
|
|
80
|
-
raise ArgumentError, "ACL
|
|
80
|
+
raise ArgumentError, "Invalid ACL rule structure"
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
groups = record.fetch("address_groups", {})
|
|
85
|
-
raise ArgumentError, "
|
|
85
|
+
raise ArgumentError, "Invalid address group structure" unless groups.is_a?(Hash)
|
|
86
86
|
groups.each_value do |members|
|
|
87
87
|
unless members.is_a?(Array) && members.all? { |member| member.is_a?(String) && member.include?("/") && IPAddr.new(member).ipv4? }
|
|
88
|
-
raise ArgumentError, "
|
|
88
|
+
raise ArgumentError, "Invalid address group members"
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
end
|
data/lib/vxlan/cli.rb
CHANGED
|
@@ -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)
|
|
@@ -36,7 +36,7 @@ module Vxlan
|
|
|
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
|
data/lib/vxlan/fabric/review.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Vxlan
|
|
|
18
18
|
# 比较范围由业务策略统一验证,审查器只负责组织规则与建议。
|
|
19
19
|
def initialize(documents, policy)
|
|
20
20
|
@documents = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
|
|
21
|
-
raise ArgumentError, "
|
|
21
|
+
raise ArgumentError, "Duplicate device identity" unless @documents.size == documents.size
|
|
22
22
|
@scope = Scope.new(policy)
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -26,12 +26,12 @@ module Vxlan
|
|
|
26
26
|
def call
|
|
27
27
|
@findings, @items, @coverage, @subnet_indexes = [], Hash.new { |hash, host| hash[host] = [] }, [], {}
|
|
28
28
|
inventory = @scope.coverage(@documents.keys)
|
|
29
|
-
finding("
|
|
29
|
+
finding("unassigned", nil, "fabric_scope_unassigned", "Collected devices are outside the Fabric policy and are not covered by cross-device review", inventory[:unassigned]) if inventory[:unassigned].any?
|
|
30
30
|
@scope.groups.each do |fabric, group|
|
|
31
31
|
configs = group.values.flatten.to_h { |host| [ host, configuration(host) ] }
|
|
32
32
|
unknown = configs.select { |_host, config| config.nil? }.keys
|
|
33
33
|
unless unknown.empty?
|
|
34
|
-
finding(fabric, nil, "fabric_evidence_incomplete", "
|
|
34
|
+
finding(fabric, nil, "fabric_evidence_incomplete", "Member configuration evidence is incomplete; change inference stopped for this Fabric", unknown)
|
|
35
35
|
next
|
|
36
36
|
end
|
|
37
37
|
%w[leaves borders].each do |role|
|
|
@@ -63,11 +63,11 @@ module Vxlan
|
|
|
63
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
64
|
incomplete = rows.select { |row| row[:missing].any? }
|
|
65
65
|
if incomplete.any?
|
|
66
|
-
finding(fabric, vni, "fabric_service_incomplete", "
|
|
66
|
+
finding(fabric, vni, "fabric_service_incomplete", "Service deployment has started, but VLAN/NVE/EVPN associations are incomplete", observations(incomplete) { |row| { missing: row[:missing] } })
|
|
67
67
|
review_missing_mappings(fabric, vni, rows, all_configs)
|
|
68
68
|
end
|
|
69
69
|
if rows.map { |row| row[:host] }.uniq.size != rows.size
|
|
70
|
-
finding(fabric, vni, "fabric_vlan_mapping_ambiguous", "
|
|
70
|
+
finding(fabric, vni, "fabric_vlan_mapping_ambiguous", "One Leaf maps multiple VLANs to the same VNI", rows.map { |row| row.slice(:host, :vlan) })
|
|
71
71
|
next
|
|
72
72
|
end
|
|
73
73
|
next unless rows.size >= 2
|
|
@@ -76,15 +76,15 @@ module Vxlan
|
|
|
76
76
|
# 缺映射行没有可定位 SVI,不能再扩散为网关、MTU 等一串伪差异。
|
|
77
77
|
rows = rows.select { |row| row[:vlan] }
|
|
78
78
|
next unless rows.size >= 2
|
|
79
|
-
compare(fabric, vni, "fabric_vlan_mapping", "
|
|
79
|
+
compare(fabric, vni, "fabric_vlan_mapping", "Local VLAN IDs differ for the same VNI", rows) { |row| row[:vlan] }
|
|
80
80
|
# L2 业务没有网关可以是正常设计,但仍须检查承载与控制平面。
|
|
81
81
|
next unless rows.any? { |row| row[:svi] && (row[:svi]["anycast"] || row[:svi].fetch("addresses", []).any?) }
|
|
82
82
|
review_gateway(fabric, vni, rows, all_configs, service_blocked: service_blocked)
|
|
83
83
|
%w[in out].each { |direction| review_acl(fabric, vni, rows, direction, policies: policies, service_blocked: service_blocked) }
|
|
84
84
|
%w[vrf anycast mtu shutdown].each do |field|
|
|
85
|
-
compare(fabric, vni, "fabric_#{field}", "
|
|
85
|
+
compare(fabric, vni, "fabric_#{field}", "Service SVI #{field} differs", rows) { |row| row[:svi]&.fetch(field, nil) }
|
|
86
86
|
end
|
|
87
|
-
compare(fabric, vni, "fabric_anycast_mac", "Anycast
|
|
87
|
+
compare(fabric, vni, "fabric_anycast_mac", "Anycast gateway MACs differ", rows) { |row| row[:config]["anycast_mac"] }
|
|
88
88
|
end
|
|
89
89
|
review_acl_catalog(fabric, configs, policies)
|
|
90
90
|
review_address_groups(fabric, configs)
|
|
@@ -94,27 +94,27 @@ module Vxlan
|
|
|
94
94
|
def review_missing_mappings(fabric, vni, rows, configs)
|
|
95
95
|
rows.select { |row| row[:missing] == [ "VLAN" ] }.each do |row|
|
|
96
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", "
|
|
97
|
+
item = recommendation(fabric, vni, "fabric_vlan_mapping_missing", "Complete the VLAN/VNI association for an existing service", evidence)
|
|
98
98
|
plan = Service::Mapping.infer(vni, row, rows)
|
|
99
99
|
change = plan&.to_h
|
|
100
100
|
conflicts = change ? gateway_conflicts(configs, change, except: [ row[:host], "Vlan#{change['vlan']}" ]) : []
|
|
101
101
|
if conflicts.any?
|
|
102
102
|
item[:evidence].concat(conflicts)
|
|
103
|
-
item[:blockers] << "
|
|
103
|
+
item[:blockers] << "Subnets overlap across SVIs/VNIs within the same VRF; adding a mapping must not extend the conflict: #{conflicts.map { |entry| "#{entry[:name]} #{entry[:interface]}" }.join(', ')}"
|
|
104
104
|
plan = nil
|
|
105
105
|
end
|
|
106
106
|
if plan
|
|
107
107
|
local_svi = row[:config].fetch("interfaces").fetch("Vlan#{change['vlan']}")
|
|
108
108
|
item[:evidence] = evidence.map do |entry|
|
|
109
|
-
entry[:host] == row[:host] ? entry.merge(vlan: change["vlan"].to_s, value: { missing: [ "VLAN/VNI
|
|
109
|
+
entry[:host] == row[:host] ? entry.merge(vlan: change["vlan"].to_s, value: { missing: [ "VLAN/VNI mapping" ], gateway: local_svi["addresses"], vrf: local_svi["vrf"] }) : entry
|
|
110
110
|
end
|
|
111
|
-
item[:impact] = "
|
|
112
|
-
item[:prerequisites] << "
|
|
113
|
-
item[:expected] = "
|
|
111
|
+
item[:impact] = "Attaching an existing VLAN to an existing VXLAN service may immediately extend the broadcast domain; no VLAN is created and gateways and ACLs are unchanged."
|
|
112
|
+
item[:prerequisites] << "The unmapped local VLAN's gateway, VRF, Anycast identity and ACL match a complete peer, and NVE/EVPN already match; confirm that this service should be attached locally."
|
|
113
|
+
item[:expected] = "Local VLAN/VNI association is complete, VNI state meets expectations, existing gateway and ACL remain unchanged, and service acceptance passes."
|
|
114
114
|
item[:resolves] = [ { code: "nve_l2_without_vlan", vni: vni } ]
|
|
115
115
|
propose(item, plan, service_blocked: false)
|
|
116
116
|
else
|
|
117
|
-
item[:blockers] << "
|
|
117
|
+
item[:blockers] << "No unique and complete service correspondence exists; compare the local unmapped VLAN, gateway, VRF and ACL with peer configuration. Do not infer VLAN from VNI numbers."
|
|
118
118
|
end
|
|
119
119
|
@items[row[:host]] << item
|
|
120
120
|
end
|
|
@@ -123,13 +123,13 @@ module Vxlan
|
|
|
123
123
|
# 一致性约束先于增量生成;承载/路由声明冲突时停止叠加网关和 ACL 变更。
|
|
124
124
|
def review_transport(fabric, vni, rows)
|
|
125
125
|
before = @findings.size
|
|
126
|
-
compare(fabric, vni, "fabric_replication", "NVE
|
|
127
|
-
compare(fabric, vni, "fabric_arp_suppression", "ARP
|
|
128
|
-
compare(fabric, vni, "fabric_route_targets", "EVPN
|
|
126
|
+
compare(fabric, vni, "fabric_replication", "NVE replication modes or multicast group declarations differ", rows) { |row| Service::Catalog.nve_member(row[:config], row[:nve])&.slice("replication", "multicast_group") }
|
|
127
|
+
compare(fabric, vni, "fabric_arp_suppression", "Explicit ARP suppression settings differ", rows) { |row| row[:nve]&.fetch("suppress_arp", nil) }
|
|
128
|
+
compare(fabric, vni, "fabric_route_targets", "EVPN import/export RT declarations differ; verify routing reachability and isolation", rows) do |row|
|
|
129
129
|
row[:evpn] && Service::Catalog.route_targets(row[:evpn]["route_targets"], asn: row[:config].dig("bgp", "asn"), vni: vni)
|
|
130
130
|
end
|
|
131
131
|
tenant_rows = rows.select { |row| row[:svi] && row[:svi]["vrf"] }
|
|
132
|
-
compare(fabric, vni, "fabric_tenant_routing", "
|
|
132
|
+
compare(fabric, vni, "fabric_tenant_routing", "Tenant VNIs or route import/export declarations differ for a shared service", tenant_rows) do |row|
|
|
133
133
|
Service::Catalog.tenant(row[:config], row[:svi]["vrf"])
|
|
134
134
|
end if tenant_rows.size >= 2
|
|
135
135
|
@findings.size > before
|
|
@@ -140,9 +140,9 @@ module Vxlan
|
|
|
140
140
|
services = Service::Catalog.new(host => config).services
|
|
141
141
|
return if services.empty?
|
|
142
142
|
evidence = [ { host: host, value: { role: "spines", l2_vnis: services.keys.sort } } ]
|
|
143
|
-
finding(fabric, nil, "fabric_role_conflict", "
|
|
144
|
-
item = recommendation(fabric, nil, "fabric_role_conflict", "
|
|
145
|
-
item[:blockers] << "
|
|
143
|
+
finding(fabric, nil, "fabric_role_conflict", "Declared as Spine but contains L2 service-bearing configuration", evidence)
|
|
144
|
+
item = recommendation(fabric, nil, "fabric_role_conflict", "Verify whether the Spine should act as a service VTEP", evidence)
|
|
145
|
+
item[:blockers] << "Confirm the role or migration remnants first; NVE presence does not automatically make a Spine a Leaf. Do not automatically add service configuration or delete NVE."
|
|
146
146
|
@items[host] << item
|
|
147
147
|
end
|
|
148
148
|
|
|
@@ -150,11 +150,11 @@ module Vxlan
|
|
|
150
150
|
def review_gateway(fabric, vni, rows, configs, service_blocked:)
|
|
151
151
|
values = rows.map { |row| row[:svi]&.fetch("addresses", [])&.sort }
|
|
152
152
|
return if values.uniq.one?
|
|
153
|
-
finding(fabric, vni, "fabric_gateway", "
|
|
153
|
+
finding(fabric, vni, "fabric_gateway", "Gateway addresses differ for the same service", observations(rows) { |row| row[:svi]&.fetch("addresses", []) })
|
|
154
154
|
expected = values.compact.reject(&:empty?).uniq
|
|
155
155
|
rows.each do |row|
|
|
156
156
|
next if expected.one? && row[:svi]&.fetch("addresses", [])&.sort == expected.first
|
|
157
|
-
item = recommendation(fabric, vni, "fabric_gateway", "
|
|
157
|
+
item = recommendation(fabric, vni, "fabric_gateway", "Gateway addresses must be consistent", observations(rows) { |entry| entry[:svi]&.fetch("addresses", []) })
|
|
158
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
159
|
context = rows.map { |entry| [ entry[:svi]&.dig("vrf"), entry[:svi]&.dig("anycast"), entry[:config]["anycast_mac"] ] }.uniq
|
|
160
160
|
conflicts = change["address"] ? gateway_conflicts(configs, change) : []
|
|
@@ -163,8 +163,8 @@ module Vxlan
|
|
|
163
163
|
(plan = ChangePlan.candidate(change, config: row[:config]))
|
|
164
164
|
propose(item, plan, service_blocked: service_blocked)
|
|
165
165
|
else
|
|
166
|
-
item[:blockers] << "
|
|
167
|
-
item[:blockers] << "Fabric
|
|
166
|
+
item[:blockers] << "An existing address, cross-SVI/VNI subnet conflict, inconsistent VRF/Anycast mode/MAC, or lack of a unique gateway prevents this change; confirm the migration design first. Do not add duplicate gateways."
|
|
167
|
+
item[:blockers] << "Conflicting interfaces in the Fabric: #{conflicts.map { |conflict| "#{conflict[:name]} #{conflict[:interface]}" }.join(', ')}" if conflicts.any?
|
|
168
168
|
end
|
|
169
169
|
@items[row[:host]] << item
|
|
170
170
|
end
|
|
@@ -195,11 +195,11 @@ module Vxlan
|
|
|
195
195
|
{ acl: name, defined: !entries.nil?, entries: entries, policy_verified: !policy.nil?, address_groups: policy&.address_groups, digest: policy && CtxRunner::Fingerprint.call(policy.to_h) }
|
|
196
196
|
end
|
|
197
197
|
code = different_binding ? "fabric_acl_binding" : "fabric_acl_content"
|
|
198
|
-
finding(fabric, vni, code, "#{direction}
|
|
198
|
+
finding(fabric, vni, code, "#{direction} ACL attachments or ordered rules differ", evidence)
|
|
199
199
|
undefined = evidence.select { |row| row[:value][:acl] && !row[:value][:defined] }
|
|
200
|
-
finding(fabric, vni, "fabric_acl_undefined", "#{direction}
|
|
200
|
+
finding(fabric, vni, "fabric_acl_undefined", "#{direction} ACL is attached but its local definition is missing", undefined) if undefined.any?
|
|
201
201
|
unresolved = evidence.select { |row| row[:value][:defined] && !row[:value][:policy_verified] }
|
|
202
|
-
finding(fabric, vni, "fabric_acl_dependency", "#{direction}
|
|
202
|
+
finding(fabric, vni, "fabric_acl_dependency", "#{direction} ACL dependencies are missing, empty or unparsed; policy consistency cannot be established", unresolved) if unresolved.any?
|
|
203
203
|
names = bindings.compact.uniq
|
|
204
204
|
sources = rows.select { |row| row[:config].fetch("acls", {})[names.first] }
|
|
205
205
|
originals = sources.map { |row| row[:config]["acls"][names.first] }
|
|
@@ -208,21 +208,21 @@ module Vxlan
|
|
|
208
208
|
current_name = row[:svi]&.dig("acl_bindings", direction)
|
|
209
209
|
current_entries = names.one? && row[:config].fetch("acls", {})[names.first]
|
|
210
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, "
|
|
211
|
+
item = recommendation(fabric, vni, code, "Align #{direction} access control", evidence)
|
|
212
212
|
change = { "kind" => "svi_acl", "vlan" => row[:vlan].to_i, "vni" => vni, "direction" => direction,
|
|
213
213
|
"acl" => names.first, "entries" => originals.first, "create_acl" => current_entries.nil?, "previous_binding" => current_name }
|
|
214
214
|
change["address_groups"] = unique.first.address_groups if unique.one? && unique.first
|
|
215
215
|
context_matches = rows.map { |entry| entry[:svi]&.dig("vrf") }.uniq.one?
|
|
216
216
|
if names.one? && unique.one? && unique.first && context_matches &&
|
|
217
217
|
(plan = ChangePlan.candidate(change, config: row[:config]))
|
|
218
|
-
item[:prerequisites] << "
|
|
218
|
+
item[:prerequisites] << "The basis is an ACL attached to this service with matching ordered contents; even if fewer nodes attach it, a majority without an ACL is not grounds for removal. Confirm that the service should uniformly use this ACL."
|
|
219
219
|
propose(item, plan, service_blocked: service_blocked)
|
|
220
220
|
else
|
|
221
|
-
item[:blockers] << "ACL
|
|
221
|
+
item[:blockers] << "ACL names, ordered rules, address group members, VRF or current bindings do not establish consistency; choose authoritative rules and complete dependencies. Do not automatically overwrite or remove access control."
|
|
222
222
|
shared = change["create_acl"] ? AclPolicy.bindings(row[:config], names.first) - [ { "interface" => "Vlan#{row[:vlan]}", "direction" => direction } ] : []
|
|
223
223
|
if shared.any?
|
|
224
|
-
references = shared.map { |binding| "#{binding['interface']} #{binding['direction']}" }.join("
|
|
225
|
-
item[:blockers] << "
|
|
224
|
+
references = shared.map { |binding| "#{binding['interface']} #{binding['direction']}" }.join(", ")
|
|
225
|
+
item[:blockers] << "Creating #{names.first} would also activate existing references: #{references}. Review policy and rollback for all affected interfaces together; do not execute this as completion of a single SVI."
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
228
|
@items[row[:host]] << item
|
|
@@ -238,10 +238,10 @@ module Vxlan
|
|
|
238
238
|
{ host: host, entries: entries, semantics: policies.dig(host, name)&.rules || AclPolicy.rules(entries), policy: policies.dig(host, name)&.to_h } if entries
|
|
239
239
|
end
|
|
240
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", "
|
|
241
|
+
finding(fabric, nil, "fabric_acl_content", "Same-name ACL #{name} has different ordered rules or dependencies (including unattached definitions)", rows)
|
|
242
242
|
end
|
|
243
243
|
unknown = rows.select { |row| row[:policy].nil? }
|
|
244
|
-
finding(fabric, nil, "fabric_acl_dependency", "ACL #{name}
|
|
244
|
+
finding(fabric, nil, "fabric_acl_dependency", "ACL #{name} has unverified dependencies; policy consistency cannot be established (including unattached definitions)", unknown) if unknown.any?
|
|
245
245
|
end
|
|
246
246
|
end
|
|
247
247
|
|
|
@@ -255,7 +255,7 @@ module Vxlan
|
|
|
255
255
|
end
|
|
256
256
|
references.sort.each do |name, rows|
|
|
257
257
|
next unless rows.map { |row| row[:value] }.uniq.size > 1
|
|
258
|
-
finding(fabric, nil, "fabric_address_group", "ACL
|
|
258
|
+
finding(fabric, nil, "fabric_address_group", "ACL address group #{name} has different or missing members; compare only services referencing this object and do not copy automatically", rows)
|
|
259
259
|
end
|
|
260
260
|
end
|
|
261
261
|
|
|
@@ -283,7 +283,7 @@ module Vxlan
|
|
|
283
283
|
end
|
|
284
284
|
conflicts << rows if rows.map { |row| row[:vni] }.uniq.size > 1
|
|
285
285
|
conflicts.each do |evidence|
|
|
286
|
-
finding(fabric, nil, "fabric_subnet_vni_conflict", "VRF #{vrf}
|
|
286
|
+
finding(fabric, nil, "fabric_subnet_vni_conflict", "VRF #{vrf} has overlapping subnets across different VNIs; verify the migration or isolation design", evidence)
|
|
287
287
|
end
|
|
288
288
|
active << [ key, rows ]
|
|
289
289
|
end
|
|
@@ -308,16 +308,16 @@ module Vxlan
|
|
|
308
308
|
|
|
309
309
|
# 与单设备报告共享建议字段,跨设备来源完整保留。
|
|
310
310
|
def recommendation(fabric, vni, code, title, evidence)
|
|
311
|
-
{ code: code, category: "fabric", scope: "#{fabric} / #{@role} / #{vni ? "VNI #{vni}" : '
|
|
312
|
-
prerequisites: [ "
|
|
311
|
+
{ code: code, category: "fabric", scope: "#{fabric} / #{@role} / #{vni ? "VNI #{vni}" : 'role review'}", title: title, status: "blocked", evidence: evidence,
|
|
312
|
+
prerequisites: [ "Applicable only within this Fabric and the same deployed service scope; verify the service access policy and maintenance window.", "Read configuration again before applying; any change makes the plan stale. Confirm each item on each device; do not replay in bulk." ], blockers: [],
|
|
313
313
|
commands: [], rollback: [], verify: [ "show running-config", "show nve vni", "show ip interface brief vrf all" ],
|
|
314
|
-
impact: "
|
|
314
|
+
impact: "Changing a gateway or ACL may affect reachability and service isolation; service acceptance is still required after configuration readback passes.", expected: "Gateways and access control are consistent for the shared service; permitted and denied traffic match the design.", references: [] }
|
|
315
315
|
end
|
|
316
316
|
|
|
317
317
|
# 类型化增量才可导出可交互执行的计划,冲突项只有证据和待确认信息。
|
|
318
318
|
def propose(item, plan, service_blocked:)
|
|
319
319
|
if service_blocked
|
|
320
|
-
item[:blockers] << "
|
|
320
|
+
item[:blockers] << "Service-bearing associations or replication/routing declarations conflict for a shared service; resolve these prerequisites before generating gateway or ACL changes."
|
|
321
321
|
return
|
|
322
322
|
end
|
|
323
323
|
item.merge!(status: "candidate", change: plan.to_h, commands: plan.commands, rollback: plan.rollback)
|