vxlan 0.3.1 → 0.4.1

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,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
3
+ require "ctx_cli/command"
4
+ require "ctx_cli/options"
5
+ require "ctx_cli/input"
6
+ require "ctx_cli/output"
4
7
  require "json"
5
8
  require "time"
6
9
  require "vxlan"
@@ -12,45 +15,40 @@ module Vxlan
12
15
  # 注入标准流,便于在宿主测试中验证无需网络的行为。
13
16
  def initialize(out: $stdout, error: $stderr, input: $stdin, command: "vxlan parse")
14
17
  @out, @error, @input, @command = out, error, input, command
18
+ @output = CtxCli::Output.new(out: out, error: error)
15
19
  end
16
20
 
17
21
  # 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
18
22
  def run(argv)
19
- options = { dimension: :configuration, complete: false }
20
- parser = OptionParser.new do |p|
21
- p.banner = "用法:#{@command} [选项](离线文件或 stdin)"
23
+ command = CtxCli::Command.new(banner: "用法:#{@command} [选项](离线文件或 stdin)", defaults: { dimension: :configuration, complete: false }, out: @out) do |p, options|
22
24
  p.on("--file FILE", "读取文件,默认 stdin") { |v| options[:file] = v }
23
25
  p.on("--dimension NAME", "configuration 或运行维度") { |v| options[:dimension] = v.to_sym }
24
26
  p.on("--model NAME", "设备型号") { |v| options[:model] = v }
25
27
  p.on("--complete", "声明输入已完整采集,仍保留解析缺失诊断") { options[:complete] = true }
26
28
  p.on("--json", "输出事实 JSON,可能包含配置敏感信息") { options[:json] = true }
27
- p.on("-h", "--help", "查看帮助") { options[:help] = true }
28
- p.on("--help-all", "查看全部选项") { options[:help] = true }
29
- p.on("--examples", "查看示例") { options[:examples] = true }
29
+ CtxCli::Options.information(p, options)
30
30
  p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
31
31
  end
32
- rest = parser.parse(argv.dup)
33
- raise ArgumentError, "不接受位置参数" unless rest.empty?
34
- return @out.puts(parser) || 0 if options[:help]
35
- return @out.puts("#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json") || 0 if options[:examples]
32
+ options = command.parse(argv)
33
+ return 0 if command.informational?(examples: "#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json")
36
34
  raise ArgumentError, "未知维度" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
37
35
 
38
- body = options[:file] ? File.read(options[:file]) : @input.read
36
+ body = CtxCli::Input.read(path: options[:file], input: @input)
39
37
  observation = if options[:dimension] == :configuration
40
38
  Vxlan::Config.new(model: options[:model]).parse(body, complete: options[:complete])
41
39
  else
42
40
  Vxlan::RuntimeParser.new(model: options[:model]).parse(options[:dimension], body, complete: options[:complete])
43
41
  end
44
42
  if options[:json]
45
- @out.puts JSON.pretty_generate(observation.to_h)
43
+ @output.json(observation.to_h)
46
44
  else
47
45
  @out.puts "维度:#{observation.name};证据:#{observation.status};记录:#{observation.records.size}"
48
46
  @out.puts "诊断:#{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
49
47
  @out.puts "证据完整性不代表 Fabric 健康。"
50
48
  end
51
49
  observation.complete? ? 0 : 1
52
- rescue OptionParser::ParseError, ArgumentError, SystemCallError => error
53
- @error.puts "离线解析参数或文件错误(#{error.class.name});使用 --help 查看参数"
50
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError, IOError => error
51
+ @output.diagnostic("离线解析参数或文件错误;使用 --help 查看参数", exception: error)
54
52
  2
55
53
  end
56
54
  end
@@ -2,8 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "time"
5
- require "tempfile"
6
- require "fileutils"
5
+ require "ctx_runner/report"
7
6
  require_relative "review/report"
8
7
  require_relative "review/fabric"
9
8
 
@@ -14,46 +13,21 @@ module Vxlan
14
13
  include Review::Report
15
14
  include Review::Fabric
16
15
 
17
- attr_reader :directory
16
+ # 私有证据发布由公共业务对象管理;本类只组织领域报告。
17
+ class Error < CtxRunner::Report::Error; end
18
18
 
19
- # 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
20
- def self.device_basename(device)
21
- "#{device.fetch('name').gsub(/[^A-Za-z0-9_.-]/, '_')[0, 100]}-#{device.fetch('host').gsub(/[^A-Za-z0-9_.-]/, '_')}"
19
+ def initialize(root, scripts: false)
20
+ @scripts = scripts
21
+ @report = CtxRunner::Report.new(root, error_class: Error)
22
22
  end
23
23
 
24
- # 仅交付发布阶段,不保留底层异常正文或敏感报告内容。
25
- class Error < IOError
26
- attr_reader :relative, :phase
24
+ def directory = @report.directory
25
+ def recovery_failed? = @report.recovery_failed?
26
+ def write_execution(state) = @report.write_execution(state)
27
27
 
28
- # 保留安全的发布阶段与提交状态,不携带底层异常正文。
29
- def initialize(relative:, phase:, committed:)
30
- @relative, @phase, @committed = relative.freeze, phase, committed
31
- super("报告写入失败:#{relative};阶段 #{phase};#{committed ? '已替换,收尾未确认' : '尚未替换'}")
32
- end
33
-
34
- # 区分原子替换已完成与目录同步尚未确认。
35
- def committed? = @committed
36
- end
37
-
38
- # 批次目录排他创建,重复时间戳不会覆盖上一批次。
39
- def initialize(root, scripts: false)
40
- @scripts = scripts
41
- root = File.expand_path(root)
42
- prepare_directory(root)
43
- stat = File.lstat(root)
44
- unless stat.directory? && (stat.mode & 0o077).zero?
45
- raise ArgumentError, "报告根目录须为真实私有目录,权限0700;请设置独立目录并检查 chmod 0700"
46
- end
47
- stem = Time.now.utc.strftime("%Y-%m-%d_%H-%M-%S")
48
- sequence = 0
49
- begin
50
- @directory = File.join(root, sequence.zero? ? stem : "#{stem}_#{sequence}")
51
- Dir.mkdir(directory, 0o700)
52
- rescue Errno::EEXIST
53
- sequence += 1
54
- retry
55
- end
56
- sync_directory(root)
28
+ # 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
29
+ def self.device_basename(device)
30
+ "#{device.fetch('name').gsub(/[^A-Za-z0-9_.-]/, '_')[0, 100]}-#{device.fetch('host').gsub(/[^A-Za-z0-9_.-]/, '_')}"
57
31
  end
58
32
 
59
33
  # 每台完成后立即原子保存,即使批次随后取消仍可读取已完成证据。
@@ -73,151 +47,33 @@ module Vxlan
73
47
 
74
48
  # 摘要不嵌入配置事实,只汇总状态与诊断,供终端和自动化共用。
75
49
  def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
76
- @recovery_failed = false
77
- # 按地址索引一次,避免大批次逐个扫描所有已完成结果。
78
- completed_hosts = results.to_h { |result| [ result.fetch(:device).fetch("host"), true ] }
79
- pending = requested.reject { |device| completed_hosts.key?(device.fetch("host")) }
80
- complete = !report_error && !execution_error && !output_error && pending.empty? && results.any? && results.all? { |r| r[:status] == :complete && r[:report_saved] != false }
81
- summary = { status: interrupted ? :interrupted : (complete ? :complete : :incomplete),
82
- total: [ requested.size, results.size ].max, completed: results.size, pending: pending, counts: results.map { |r| r[:status] }.tally, devices: results,
83
- directory: directory, evidence_only: true }
84
- summary[:review] = @review_summary if @review_summary
85
- summary[:report_error] = report_error if report_error
86
- summary[:execution_error] = execution_error if execution_error
87
- summary[:output_error] = output_error if output_error
88
- # 先保存未完成检查点;文本发布或最终提交失败都不能留下成功摘要。
89
- checkpoint = summary.merge(status: interrupted ? :interrupted : :incomplete,
90
- report_error: report_error || :report_finalization_unconfirmed)
91
- checkpoint_body = JSON.pretty_generate(serializable(checkpoint)) + "\n"
92
- begin
93
- publish("summary.json", checkpoint_body)
94
- rescue Error, Interrupt
95
- invalidate_completed_summary
96
- raise
97
- end
98
- text = [ "VXLAN 证据:#{summary.fetch(:status)};完成 #{results.size} 台", "分类:#{summary.fetch(:counts)}", "证据完整性不代表 Fabric 健康。" ]
99
- text << "逐设备诊断:devices/;候选脚本:scripts/;所有候选配置未执行。"
100
- text << @review_text if @review_text
101
- text << "批次报告状态:参见 summary.json;文本中的证据状态不确认报告发布完成。"
102
- text << "报告错误:#{report_error}" if report_error
103
- text << "执行错误:#{execution_error}" if execution_error
104
- text << "进度输出错误:#{output_error[:error_type]};设备结果已保留" if output_error
105
- results.each do |r|
106
- saved = r[:report_saved] == true ? "已保存" : "保存未确认"
107
- text << "#{r[:device]['host']}\t#{r[:device]['name']}\t#{r[:status]}\t#{saved}\t#{r[:error]}"
108
- end
109
- pending.each { |device| text << "#{device['host']}\t#{device['name']}\t未采集" }
110
- publish("summary.txt", text.join("\n") + "\n")
111
- begin
112
- publish("summary.json", JSON.pretty_generate(serializable(summary)) + "\n")
113
- rescue Error => error
114
- # 未替换时原检查点仍安全,不做可能破坏它的多余恢复写入。
115
- restore_checkpoint(checkpoint_body) if error.committed?
116
- raise
117
- rescue Interrupt
118
- checkpoint[:status] = :interrupted
119
- restore_checkpoint(JSON.pretty_generate(serializable(checkpoint)) + "\n")
120
- raise
121
- end
122
- summary
123
- end
124
-
125
- # 每次发送配置前后发布检查点;失败由执行器停止后续写入。
126
- def write_execution(state)
127
- publish("execution.json", JSON.pretty_generate(state) + "\n")
128
- end
129
-
130
- # 恢复写入或删除受阻时,调用方须把退出码及此状态作为未确认诊断。
131
- def recovery_failed? = @recovery_failed == true
132
-
133
- private
134
-
135
- # 新建目录的名字属于父目录;文件 fsync 不能替代这条持久化链。
136
- def prepare_directory(path)
137
- missing, parent = [], path
138
- until File.directory?(parent)
139
- missing << parent
140
- parent = File.dirname(parent)
141
- end
142
- FileUtils.mkdir_p(path, mode: 0o700)
143
- missing.reverse_each do |created|
144
- parent = File.dirname(created)
145
- sync_directory(File.symlink?(parent) ? File.realpath(parent) : parent)
146
- end
147
- end
148
-
149
- # 批次创建与文件发布共享目录同步边界,失败直接交还宿主停止执行。
150
- def sync_directory(path)
151
- File.open(path, File::RDONLY | File::NOFOLLOW) { |parent| parent.fsync }
152
- end
153
-
154
- # 新一次收尾失败时,旧成功摘要不再代表当前状态;安全检查点继续保留。
155
- def invalidate_completed_summary
156
- Thread.handle_interrupt(Exception => :never) do
157
- saved = JSON.parse(File.read(File.join(directory, "summary.json")))
158
- discard_summary if saved["status"] == "complete"
159
- end
160
- rescue Errno::ENOENT
161
- nil
162
- rescue SystemCallError, JSON::ParserError, Interrupt
163
- @recovery_failed = true
164
- end
165
-
166
- # 屏蔽恢复中的次级取消,始终把最先观察到的发布失败或取消交还调用方。
167
- def restore_checkpoint(body, relative: "summary.json")
168
- Thread.handle_interrupt(Exception => :never) do
169
- begin
170
- publish(relative, body)
171
- rescue Error, Interrupt => error
172
- @recovery_failed = true
173
- discard_summary(relative: relative) unless error.is_a?(Error) && error.committed?
50
+ @report.finish(results, interrupted: interrupted, requested: requested, report_error: report_error,
51
+ execution_error: execution_error, output_error: output_error, review: @review_summary) do |summary|
52
+ pending = summary.fetch(:pending)
53
+ text = [ "VXLAN 证据:#{summary.fetch(:status)};完成 #{results.size} 台", "分类:#{summary.fetch(:counts)}", "证据完整性不代表 Fabric 健康。" ]
54
+ text << "逐设备诊断:devices/;候选脚本:scripts/;所有候选配置未执行。"
55
+ text << @review_text if @review_text
56
+ text << "批次报告状态:参见 summary.json;文本中的证据状态不确认报告发布完成。"
57
+ text << "报告错误:#{report_error}" if report_error
58
+ text << "执行错误:#{execution_error}" if execution_error
59
+ text << "进度输出错误:#{output_error[:error_type]};设备结果已保留" if output_error
60
+ results.each do |r|
61
+ saved = r[:report_saved] == true ? "已保存" : "保存未确认"
62
+ text << "#{r[:device]['host']}\t#{r[:device]['name']}\t#{r[:status]}\t#{saved}\t#{r[:error]}"
174
63
  end
64
+ pending.each { |device| text << "#{device['host']}\t#{device['name']}\t未采集" }
65
+ text.join("\n") + "\n"
175
66
  end
176
- rescue Interrupt
177
- @recovery_failed = true
178
- end
179
-
180
- # 存储拒绝恢复时尽力移除不可信摘要,不删除已获得的设备事实。
181
- def discard_summary(relative: "summary.json")
182
- File.unlink(File.join(directory, relative))
183
- rescue Errno::ENOENT
184
- nil
185
- rescue SystemCallError
186
- @recovery_failed = true
187
67
  end
188
68
 
189
- # Time 使用 ISO-8601,递归转换 Data 只发生在显式持久化边界。
190
- def serializable(value)
191
- case value
192
- when Time then value.iso8601(6)
193
- when Data then serializable(value.to_h)
194
- when Hash then value.transform_values { |item| serializable(item) }
195
- when Array then value.map { |item| serializable(item) }
196
- else value
197
- end
198
- end
69
+ private
199
70
 
200
- # 临时文件与目标在同一目录,写入失败不发布半份报告。
201
- def publish(relative, body)
202
- target = File.join(directory, relative)
203
- phase, committed = :temporary_file, false
204
- Tempfile.create([ ".vxlan-", ".tmp" ], File.dirname(target)) do |file|
205
- file.chmod(0o600)
206
- phase = :write
207
- file.write(body)
208
- phase = :file_sync
209
- file.flush
210
- file.fsync
211
- phase = :rename
212
- File.rename(file.path, target)
213
- committed = true
214
- phase = :directory_sync
215
- sync_directory(File.dirname(target))
216
- phase = :cleanup
217
- end
218
- rescue SystemCallError, IOError, NotImplementedError
219
- raise Error.new(relative: relative, phase: phase, committed: committed), cause: nil
220
- end
71
+ # 保持领域呈现接口,事务与恢复状态仅在公共报告中实现。
72
+ def prepare_directory(path) = @report.prepare_directory(path)
73
+ def publish(relative, body) = @report.publish(relative, body)
74
+ def serializable(value) = @report.serializable(value)
75
+ def checkpoint(relative, value) = @report.checkpoint(relative, value)
76
+ def finalize(relative, summary, checkpoint:) = @report.finalize(relative, summary, checkpoint: checkpoint)
221
77
  end
222
78
  end
223
79
  end
@@ -66,7 +66,7 @@ module Vxlan
66
66
  public_entries = entries.map { |entry| entry.reject { |key, _| %i[advice changes].include?(key) } }
67
67
  summary = { layout_version: 3, mode: "review", status: "incomplete", review_only: true, devices: entries.size, counts: counts, source_batch: source_batch,
68
68
  reports: public_entries, plan: "plan.json", plan_sha256: nil, fabric_findings: @fabric_audit ? @fabric_audit[:findings].size : 0 }
69
- publish("summary.json", JSON.pretty_generate(summary) + "\n") if standalone
69
+ checkpoint("summary.json", summary) if standalone
70
70
  plan = { schema_version: 2, platform: "cisco_nxos", mode: "review", devices: public_entries, audits: entries.map { |entry| entry[:advice] },
71
71
  changes: entries.each_with_object({}) { |entry, all| all.merge!(entry[:changes]) }, fabric: @fabric_audit && @fabric_audit.reject { |key, _| key == :device_items } }
72
72
  body = JSON.pretty_generate(plan) + "\n"
@@ -78,15 +78,7 @@ module Vxlan
78
78
  text = summary_text(summary)
79
79
  if standalone
80
80
  publish("summary.txt", text)
81
- begin
82
- publish("summary.json", JSON.pretty_generate(summary.merge(status: "complete")) + "\n")
83
- rescue CLI::Report::Error => error
84
- restore_checkpoint(JSON.pretty_generate(summary) + "\n") if error.committed?
85
- raise
86
- rescue Interrupt
87
- restore_checkpoint(JSON.pretty_generate(summary.merge(status: "interrupted")) + "\n")
88
- raise
89
- end
81
+ finalize("summary.json", summary.merge(status: "complete"), checkpoint: summary)
90
82
  end
91
83
  @review_summary, @review_text = summary.merge(status: "complete"), text
92
84
  @review_summary
data/lib/vxlan/cli.rb CHANGED
@@ -1,41 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "vxlan/version"
4
+ require "ctx_cli/application"
4
5
 
5
6
  module Vxlan
6
7
  # 命令入口按需加载采集或解析实现;核心库无需加载 CLI。
7
8
  module CLI
8
9
  # 由调用方拥有退出行为,库入口只返回退出码。
9
10
  def self.run(argv, env: ENV, out: $stdout, error: $stderr, input: $stdin, **collect_options)
10
- arguments = argv.dup
11
- command = arguments.shift
12
- case command
13
- when nil, "--help", "--help-all", "-h"
14
- return usage(out) if arguments.empty?
15
- when "--version", "-v"
16
- return out.puts(Vxlan::VERSION) || 0 if arguments.empty?
17
- when "collect"
18
- require_relative "cli/collect"
19
- return Collect.new(env: env, out: out, error: error, input: input, **collect_options).run(arguments)
20
- when "apply"
21
- require_relative "cli/apply"
22
- return Apply.new(env: env, out: out, error: error, input: input, connector_factory: collect_options[:connector_factory], discovery: collect_options.fetch(:discovery, {})).run(arguments)
23
- when "review"
24
- require_relative "cli/review"
25
- return Review.new(out: out, error: error).run(arguments)
26
- when "parse"
27
- require_relative "cli/parse"
28
- return Parse.new(out: out, error: error, input: input, command: collect_options.fetch(:command, "vxlan parse")).run(arguments)
29
- end
30
- error.puts "未知命令或多余参数;使用 vxlan --help 查看用法"
31
- 2
11
+ commands = {
12
+ "collect" => ->(arguments) do
13
+ begin
14
+ require_relative "cli/collect"
15
+ rescue LoadError
16
+ error.puts "collect 需要含共享清单筛选的当前 net-connector;请安装项目依赖。"
17
+ next 2
18
+ end
19
+ Collect.new(env: env, out: out, error: error, input: input, **collect_options).run(arguments)
20
+ end,
21
+ "apply" => ->(arguments) do
22
+ require_relative "cli/apply"
23
+ Apply.new(env: env, out: out, error: error, input: input, connector_factory: collect_options[:connector_factory], discovery: collect_options.fetch(:discovery, {})).run(arguments)
24
+ end,
25
+ "review" => ->(arguments) do
26
+ require_relative "cli/review"
27
+ Review.new(out: out, error: error).run(arguments)
28
+ end,
29
+ "parse" => ->(arguments) do
30
+ require_relative "cli/parse"
31
+ Parse.new(out: out, error: error, input: input, command: collect_options.fetch(:command, "vxlan parse")).run(arguments)
32
+ end
33
+ }
34
+ CtxCli::Application.new(name: "vxlan", version: Vxlan::VERSION, usage: USAGE,
35
+ commands: commands, out: out, error: error).run(argv)
32
36
  end
33
37
 
34
38
  # 全局帮助不读取配置、环境文件或设备清单。
35
- def self.usage(out)
36
- out.puts "用法:vxlan collect [选项] 只读采集与报告\n vxlan parse [选项] 离线文件或 stdin 解析\n vxlan review --batch DIR 离线修复建议\n vxlan apply --plan FILE --item ID 交互确认后下发\n vxlan --version\n使用 vxlan collect --help 、vxlan parse --help 或 vxlan review --help 查看参数;--help-all 查看完整约定,--examples 查看范例。"
37
- 0
38
- end
39
- private_class_method :usage
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
40
+ private_constant :USAGE
40
41
  end
41
42
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "observation"
4
+
5
+ module Vxlan
6
+ # 配置证据约束运行查询:明确未启用的功能才允许空回显或省略查询。
7
+ class CollectionPlan
8
+ def initialize(profile:, configuration: nil)
9
+ @profile, @configuration = profile, configuration
10
+ end
11
+
12
+ def parse_many(names, body, complete:, command:)
13
+ if names.one? && body.strip.empty? && unconfigured?(names.first)
14
+ name = names.first
15
+ code = name == :port_channels ? :port_channels_not_configured : :nve_not_configured
16
+ return { name => CtxParser::Result.new(status: :complete, issues: [ { code: code } ]) }
17
+ end
18
+ @profile.parse_many(names, body, complete: complete, command: command)
19
+ end
20
+
21
+ # 仅当同一会话的完整配置证明功能未启用时,才接受空运行回显。
22
+ def unconfigured?(name)
23
+ return false unless @configuration&.complete? && @configuration.records.one?
24
+
25
+ settings = @configuration.records.first
26
+ case name
27
+ when :vpc then !settings[:vpc_domain] && !settings.fetch(:features).include?("vpc")
28
+ when :nve_peers, :nve_vnis then !settings.fetch(:nve)[:interface]
29
+ when :port_channels
30
+ settings.fetch(:interfaces).none? { |interface, values| /\Aport-channel\d+\z/i.match?(interface) || values[:channel_group] }
31
+ else false
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require_relative "parser_registry"
4
4
  require "ctx_driver"
5
+ require "ctx_runner/collection"
6
+ require "ctx_runner/query"
7
+ require_relative "collection_plan"
5
8
  require_relative "observation"
6
9
  require_relative "snapshot"
7
10
 
@@ -21,29 +24,12 @@ module Vxlan
21
24
  target, fabric_key, dimensions, profile = request(target, fabric_key, dimensions, connector.platform, model)
22
25
 
23
26
  observations = {}
24
- session_started = session_finished = false
25
- begin
26
- connector.open do |session|
27
- if session_started
28
- dimensions.each { |name| observations[name] ||= failure(name, :session_reentered) }
29
- observations[:collection] = failure(:collection, :session_reentered)
30
- next
31
- end
32
- session_started = true
33
- observations.merge!(read_dimensions(session, dimensions, profile))
34
- session_finished = true
35
- end
36
- rescue StandardError => error
37
- raise unless CtxDriver::Failure.connection_error?(error, connector)
38
-
39
- code = CtxDriver::Failure.code(error, :connection_failed)
27
+ collection = CtxRunner::Collection.new
28
+ collection.open(connector) { |session| observations.merge!(read_dimensions(session, dimensions, profile)) }
29
+ if collection.failure
30
+ code = collection.failure.fetch(:code)
40
31
  dimensions.each { |name| observations[name] ||= failure(name, code) }
41
- observations[:collection] = failure(:collection, session_finished ? :session_close_failed : code)
42
- end
43
- # 连接器正常返回也不等于已提供会话;缺少回调必须保留全部请求的失败事实。
44
- unless session_finished || observations.key?(:collection)
45
- dimensions.each { |name| observations[name] = failure(name, :session_not_opened) }
46
- observations[:collection] = failure(:collection, :session_not_opened)
32
+ observations[:collection] = failure(:collection, code)
47
33
  end
48
34
  snapshot(target, fabric_key, observations)
49
35
  end
@@ -79,11 +65,11 @@ module Vxlan
79
65
  end
80
66
  ordered = dimensions.include?(:configuration) ? [ :configuration, *dimensions.reject { |name| name == :configuration } ] : dimensions
81
67
  ordered.each do |name|
82
- config = observations[:configuration]
83
- observations[name] = if name == :vpc && !reader.failure && unconfigured?(name, config)
68
+ plan = CollectionPlan.new(profile: profile, configuration: observations[:configuration])
69
+ observations[name] = if name == :vpc && !reader.failure && plan.unconfigured?(name)
84
70
  Observation.new(name: name, status: :complete, completed_at: @clock.call, issues: [ { code: :vpc_not_configured } ])
85
71
  else
86
- collect_observation(reader, name, config, profile)
72
+ collect_observation(reader, name, plan, profile)
87
73
  end
88
74
  end
89
75
  dimensions.to_h { |name| [ name, observations.fetch(name) ] }
@@ -112,45 +98,18 @@ module Vxlan
112
98
  end
113
99
 
114
100
  # 解析失败限定在单条查询内;Reader 负责传输与回执校验。
115
- def collect_observation(reader, name, configuration, profile)
101
+ def collect_observation(reader, name, plan, profile)
116
102
  command = profile.command(name)
117
- capture = reader.read(command)
118
- metadata = { started_at: capture.started_at, completed_at: capture.completed_at, evidence: capture.evidence }
119
- unless capture.success?
120
- return failed_capture(name, capture, command).with(**metadata)
121
- end
122
- begin
123
- body = capture.body
124
- parsed = if body.strip.empty? && unconfigured?(name, configuration)
125
- code = name == :port_channels ? :port_channels_not_configured : :nve_not_configured
126
- Observation.new(name: name, status: :complete, issues: [ { code: code } ])
127
- else
128
- Observation.new(name: name, **profile.parse(name, body, complete: true).to_h)
129
- end
130
- parsed.with(**metadata)
131
- rescue StandardError => error
132
- details = CtxDriver::Failure.details(error, fallback: :command_processing_failed, stage: :parse)
133
- failure(name, details.fetch(:code), **details.except(:code), command: command).with(**metadata)
134
- end
135
- end
136
-
137
- # 保留传输首次不可用原因,并标记从未发送的命令。
138
- def failed_capture(name, capture, command)
139
- failure(name, capture.failure.fetch(:code), **capture.failure.except(:code), command: command, skipped: capture.skipped?)
140
- end
141
-
142
- # 仅当同一会话的完整配置证明功能未启用时,才接受空运行回显。
143
- def unconfigured?(name, configuration)
144
- return false unless configuration&.complete? && configuration.records.one?
145
-
146
- settings = configuration.records.first
147
- case name
148
- when :vpc then !settings[:vpc_domain] && !settings.fetch(:features).include?("vpc")
149
- when :nve_peers, :nve_vnis then !settings.fetch(:nve)[:interface]
150
- when :port_channels
151
- settings.fetch(:interfaces).none? { |interface, values| /\Aport-channel\d+\z/i.match?(interface) || values[:channel_group] }
152
- else false
103
+ unavailable = ->(view, capture, details) do
104
+ issue = details.merge(command: command)
105
+ issue[:skipped] = capture.skipped? unless capture.success?
106
+ failure(view, issue.fetch(:code), **issue.except(:code)).with(
107
+ started_at: capture.started_at, completed_at: capture.completed_at, evidence: capture.evidence)
153
108
  end
109
+ CtxRunner::Query.new(reader: reader, parser: plan).read(command, names: [ name ], unavailable: unavailable) do |view, result, capture|
110
+ Observation.new(name: view, **result.to_h, started_at: capture.started_at,
111
+ completed_at: capture.completed_at, evidence: capture.evidence)
112
+ end.fetch(name)
154
113
  end
155
114
 
156
115
  # 构建失败观测,不丢弃已成功的同级维度。
@@ -1,29 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ctx_parser/immutable"
3
+ require "ctx_runner/observation"
4
4
 
5
5
  module Vxlan
6
- # 保留命令事实与完整性;默认检查不输出原始配置或凭据。
7
- Observation = Data.define(:name, :status, :records, :issues, :evidence, :started_at, :completed_at) do
8
- # 冻结单个维度的记录、状态及诊断元数据。
9
- def initialize(name:, status:, records: [], issues: [], evidence: [], started_at: nil, completed_at: nil)
10
- raise ArgumentError, "Observation name must be a symbol" unless name.is_a?(Symbol)
11
- raise ArgumentError, "Invalid observation status" unless %i[complete partial failed unsupported not_requested].include?(status)
12
- raise ArgumentError, "Records must be hashes" unless records.is_a?(Array) && records.all? { |row| row.is_a?(Hash) }
13
-
14
- super(name: name, status: status, records: CtxParser::Immutable.copy(records),
15
- issues: CtxParser::Immutable.copy(issues), evidence: CtxParser::Immutable.copy(evidence),
16
- started_at: started_at&.dup&.freeze, completed_at: completed_at&.dup&.freeze)
17
- end
18
-
19
- # 判断所有已选事实是否都有完整证据支持。
20
- def complete? = status == :complete
21
-
22
- # 展示对象身份与状态,避免泄露原始回显或凭据。
23
- def inspect = "#<#{self.class.name} name=#{name} status=#{status} records=#{records.size}>"
24
- alias to_s inspect
25
-
26
- # PP 默认展开 Data 成员,显式沿用脱敏展示。
27
- def pretty_print(printer) = printer.text(inspect)
28
- end
6
+ # 复用公共事实契约;领域保留公开类型与后续的专属约束。
7
+ class Observation < CtxRunner::Observation; end
29
8
  end
data/lib/vxlan/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Vxlan
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vxlan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -9,6 +9,34 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ctx_cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: ctx_runner
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: ctx_parser
14
42
  requirement: !ruby/object:Gem::Requirement
@@ -226,6 +254,7 @@ files:
226
254
  - lib/vxlan/cli/review/report.rb
227
255
  - lib/vxlan/cli/templates/env.example
228
256
  - lib/vxlan/cli/templates/vxlan.yml
257
+ - lib/vxlan/collection_plan.rb
229
258
  - lib/vxlan/collector.rb
230
259
  - lib/vxlan/config.rb
231
260
  - lib/vxlan/config/evidence.rb