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.
@@ -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 共同业务一致性", "范围:按显式 Fabric 和角色比较共同 VNI;Leaf、Border 分开比较,Spine 检查业务承载配置。", "部署事实联合 VLAN/NVE/EVPN 识别;不完整部署不等于未部署。保留部署范围差异。", "ACL 比较包含有序规则与依赖对象;不以多数无 ACL 为删除依据。", "" ]
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 << "覆盖:已纳入 #{inventory[:assigned].size} 台;未纳入 #{inventory[:unassigned].size} 台;缺证据 #{inventory[:missing].size} 台。"
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}:Leaf #{roles['leaves'].size} / Border #{roles['borders'].size} / Spine #{roles['spines'].size}"
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}:已部署业务 #{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
+ 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] || '全 Fabric'} / #{finding[:vni] ? "VNI #{finding[:vni]}" : '公共策略'}:#{finding[:title]}"
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] || '全 Fabric'} / #{finding[:vni] || '公共策略'} — #{finding[:title]}", "规则:#{finding[:code]}" ])
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 << " 已定义版本的共同成员(不代表批准基线):#{common.empty? ? '无' : common.join('、')}"
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} 个成员;共同成员之外:#{(members - common).empty? ? '无' : (members - common).join('、')}" : "未提供定义或未采集对象证据"
42
- lines.concat([ " #{names.join('、')}", " #{value}" ])
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] ? "规则与依赖版本 #{ChangePlan.fingerprint(row[:policy])[0, 12]}" : "依赖对象未验证"
55
- value = "#{row[:entries].size} 条规则,#{policy}"
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] || "公共", display_fabric_value(value) ]
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/接口 #{vlan}:#{value}", " #{names.join('、')}" ])
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 << " 规则版本 #{variant + 1}:#{members.map { |row| by_host.dig(row[:host], 'device', 'name') || row[:host] }.join('、')}"
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 "未挂载 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]}"
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? ? "未配置地址" : value.join("、")
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 = File.read(path)
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, "非法报告名称" unless basename.match?(/\A[A-Za-z0-9_.-]+\z/)
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"], ChangePlan.fingerprint(change)[0, 12] ].compact.join("--")
43
- config_sha256 ||= ChangePlan.fingerprint(config)
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 = [ "! 候选配置,未执行;先审阅同名 devices/*.txt;禁止整批直接粘贴。", "! apply 只读取 plan.json 中明确选择的单项,不执行本 cfg。" ]
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]}", "! 计划项:#{item[:plan_id] || '仅审阅草稿,不支持交互执行'}" ])
100
- lines.concat(item[:prerequisites].map { |condition| "! 条件:#{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 << "! 核查:#{item[:verify].join(' ; ')}"
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 = [ "设备:#{device['name']}(#{device['host']})", "模式:诊断与计划,未执行设备配置", "证据时间:#{advice[:observed_at] || '未知'}", "来源:#{entry[:source]}", "源文件 SHA256:#{entry[:source_sha256]}",
113
- "建议:#{entry[:counts]}", "命令文件:#{entry[:script] || '未生成'}", "命令 SHA256:#{entry[:script_sha256] || '无'}", "计划文件:plan.json;交互计划项 #{entry[:plans].size} 个", "" ]
114
- lines << "采集状态:#{document['status']}" if document["status"]
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 << "配置证据时间:#{configuration_time}" if configuration_time
117
- lines << "当前规则未命中;不代表网络健康。" if advice[:items].empty?
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]}", "状态:#{item[:status]};规则:#{item[:code]}", "影响:#{item[:impact]}" ])
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([ "本机依据:", JSON.pretty_generate(evidence) ])
122
- lines << "跨设备对比:summary.txt;完整原始对比:plan.json 的 audits / fabric。" if item[:category] == "fabric"
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 << "交互计划项:#{item[:plan_id]}"
125
- lines << "在批次目录运行:vxlan apply --plan plan.json --item #{item[:plan_id]}"
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: "适用条件", blockers: "待决信息", commands: "候选配置(未执行)", rollback: "条件回退", verify: "只读核查", template: "待补模板(不可执行)" }.each do |key, title|
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}:", *values.map { |value| " #{value}" } ]) if values.any?
130
+ lines.concat([ "#{title}: ", *values.map { |value| " #{value}" } ]) if values.any?
130
131
  end
131
- lines << "验收:#{item[:expected]}" if item[:expected]
132
- lines << "命令参考:#{item[:references].join(' ')}" if item[:references]&.any?
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 诊断与配置建议;模式:review;未执行设备变更。", "报告完成状态以 summary.json 为准。", "设备:#{entries.size} 台;有建议 #{active} 台;未命中规则 #{entries.size - active} 台(不代表健康)。",
142
- "建议:待决 #{counts['blocked']},候选 #{counts['candidate']},排查 #{counts['investigate']};Fabric 聚合差异 #{summary[:fabric_findings]} 项。",
143
- "计划:#{entries.sum { |entry| entry[:plans].size }} 项可交互执行增量;#{entries.count { |entry| entry[:script] }} 份候选脚本。候选数与可执行计划数不同。",
144
- "阅读顺序:summary.txt → devices/<设备>.txt → scripts/<设备>.cfg → plan.json 中的指定计划项。",
145
- "evidence/ 保存程序使用的采集证据,日常阅读无需打开;devices/ 只包含可读诊断。",
146
- "plan.json.sha256 校验聚合计划;events.jsonl 记录本次离线审查,不代表设备会话或下发回执。", "下发必须 --item 选择单项、交互确认、重读配置并核对指纹;无自动批量执行。", "" ]
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']} 待决=#{entry[:counts].fetch('blocked', 0)} 候选=#{entry[:counts].fetch('candidate', 0)} 排查=#{entry[:counts].fetch('investigate', 0)}",
149
- " 设备报告:#{entry[:report]}", " 命令文件:#{entry[:script] || '未生成'};可执行计划:#{entry[:plans].size} 项" ])
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"
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
4
- require "json"
5
- require "digest"
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
- 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 }
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
- 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 ]
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, "设备身份或输出名称重复" unless hosts.uniq == hosts && names.uniq == names
44
- policy = options[:fabric_policy] && YAML.safe_load(File.read(options[:fabric_policy]), aliases: false)
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: 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
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
- 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
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
- array_of_hashes(record.fetch("review_findings", []))
62
+ CtxRunner::Evidence.array(record.fetch("review_findings", []))
94
63
  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|
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 结构错误" unless acls.is_a?(Hash)
76
+ raise ArgumentError, "Invalid ACL structure" unless acls.is_a?(Hash)
108
77
  acls.each_value do |entries|
109
- array_of_hashes(entries).each do |entry|
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, "地址组结构错误" unless groups.is_a?(Hash)
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/application"
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 需要含共享清单筛选的当前 net-connector;请安装项目依赖。"
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::Application.new(name: "vxlan", version: Vxlan::VERSION, usage: USAGE,
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 = "用法: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 查看范例。".freeze
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "observation"
3
+ require "ctx_parser/result"
4
4
 
5
5
  module Vxlan
6
6
  # 配置证据约束运行查询:明确未启用的功能才允许空回显或省略查询。