vxlan 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aff4b25e45b9a59652b1b6556fd8c5ef5a8e830346ba1e093bdf4757171eaf33
4
- data.tar.gz: e931c8870133c04bca13daacc7e89d5b157fe839183afc1f76e917ce74cfabfd
3
+ metadata.gz: 1b045bbd7eb98d8771aac733587cf171bdf5c8c0da0f7ce3a4226317454da139
4
+ data.tar.gz: 06d4bddb07fa64f80ca0b2cb283c02321ad0bf54f56c94ec9c1538ebe052b447
5
5
  SHA512:
6
- metadata.gz: f6cb2fa459e295bdda4f292a8001dad97333718089267b992d58973c912328a64ee91e7328992e05175b508046673191f6e5b35657af4b58d2488f4409bfb70c
7
- data.tar.gz: 8240579365d8816fc661832475435c38248c47be07a459bb29dc0301fc8a9ca2978364bd031290f696e9c56f9fc601fe0e260d7a2e9608b4ad95d9caafb67735
6
+ metadata.gz: 6b5636dc50b48a4f0eed0c5c6b8babb2d650eae98f8a6ee78b1969b940327ba44e8f5b5bb16e12dd7b706cc0044756568aefae2fb9523e3034e72adbf132c2bc
7
+ data.tar.gz: 73d115544a200fab0ae647981720af491b05dca15f17b3f7a8ed021559221b101012842b2c7856dca0fa28edc7d9a4d2bb1d06ba439f78595b45aec3c5965c60
data/CHANGELOG.md CHANGED
@@ -2,8 +2,25 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## 0.4.0 (2026-10-22)
6
+
7
+ - Delegate batch completion and cancellation/failure precedence to CtxRunner::Batch, keeping domain review and CLI presentation explicit.
8
+
9
+ - Centralize inventory selection and CLI filters in Net::Connector::Inventory; add vendor/model inclusion, exclusion and matching modes.
10
+
11
+
12
+ - Delegate shared collection, evidence and execution contracts to ctx_runner.
13
+ - Preserve domain parsing, change policies, CLI layouts and connection ownership.
14
+
5
15
  ## Unreleased
6
16
 
17
+ - Delegate batch completion and cancellation/failure precedence to CtxRunner::Batch, keeping domain review and CLI presentation explicit.
18
+
19
+ ## 0.3.1 (2026-10-21)
20
+
21
+ - Index Fabric address-group evidence by referenced object, preserving host order and missing dependencies without repeatedly searching every host's reference list.
22
+ - Add repository-specific AGENTS.md and a full Fabric review benchmark.
23
+
7
24
  ## 0.3.0 (2026-10-20)
8
25
 
9
26
  - Require ctx_driver 0.2.4+ for the current net-connector session protocol; CLI configuration and progress use net-connector 0.16.0.
data/bin/README.md CHANGED
@@ -201,3 +201,15 @@ Fabric 的 ACL 比较包含有序规则和引用地址组的成员。对象缺
201
201
  `fabric-policy.yml` 显式声明各 Fabric 的 `leaves`、`borders`、`spines`。Leaf 与 Border 按各自已部署的共同 VNI 比较;Spine 检查角色冲突。业务目录联合 VLAN/NVE/EVPN 识别不完整部署,总览显示覆盖范围、未纳入设备和缺证据成员。只有完整一致的对端与本机唯一业务接口相互印证、且全 Fabric 无跨 VNI 网段冲突时,才建议补齐已有 VLAN 的 VNI 关联;不会推导全量铺开 VLAN,也不按命名猜测角色。
202
202
 
203
203
  每次采集独立合并进程环境与指定 `.env`,不改写调用者环境;凭据、清单与参数使用同一份本次有效环境。Fabric 策略在连接前加载、校验并固定,采集中途修改策略文件只影响下一批。下发只接受带校验文件的 `plan.json` 与 `--item ID`,不接受旧版独立单项计划。
204
+
205
+ ## 统一设备筛选
206
+
207
+ collect 的筛选与备份/拓扑共用 net-connector 的 Inventory::Selection。需要安装包含 `net/connector/inventory/filters` 的当前候选连接器;collect 帮助也从该组件读取参数定义,离线 parse/review 不加载它。
208
+
209
+ ```sh
210
+ vxlan collect --hosts 192.0.2.0/24 --name edge --exclude-name spare --model 'S68*' --model-match glob --plan
211
+ ```
212
+
213
+ IP 支持单地址、CIDR、范围;名称及型号支持 contains(默认)、prefix、glob,不区分大小写。`--vendor`/`--exclude-vendor` 使用连接器平台标识(如 cisco_nxos、h3c、h3c_wireless、huawei),不是原始厂商标签。同字段条件为 OR,跨字段为 AND,排除优先。缺失型号不命中包含规则。`--model`/`--exclude-model`、`--name-match`/`--model-match` 可组合;筛选只选择已有清单,不扫描地址、不扩大平台支持范围。
214
+
215
+ YAML 顶层对应 hosts/exclude_hosts、names/exclude_names、vendors/exclude_vendors、models/exclude_models、name_match/model_match。Netdisco inventory 段使用 include_hosts/include_names/include_vendors/include_models 及对应 exclude 字段。
@@ -0,0 +1,19 @@
1
+ # VXLAN 关联审查优化(2026-10-21)
2
+
3
+ 本轮从 `9226737` 开始,检查 Config/RuntimeParser、Collector、配置来源与关联、SubnetIndex、ACL 策略、Fabric 比较及计划输出边界。厂商命令解析器的名称和目录保持原契约;新增根目录 AGENTS.md,明确业务范围、会话所有权、证据和验证要求。
4
+
5
+ 落实的性能改动位于 `Fabric::Review#review_address_groups`。旧实现为每个地址组重新遍历设备,再在每台设备的引用数组中执行 include?;每台设备引用 G 个不同对象时会产生平方级成员查找。现在逐设备去重引用并直接把证据归入对象索引,最后按对象名排序。设备证据顺序、重复引用去重、缺失定义的 nil 及只比较实际引用设备的行为保持不变。索引只活在本次审查中,不缓存过期配置。
6
+
7
+ `benchmark/fabric.rb` 使用两台设备、同名 ACL 引用不同成员定义的地址组,执行完整 Fabric 审查,并在计时外核对每个对象的差异、设备顺序、ACL 差异及没有生成变更。输入构造、网络和文件 IO 不计时。macOS arm64 / Ruby 4.0.6,独立基线库与候选库各运行两次,每次三个样本取中位数;区间为两个进程结果范围。
8
+
9
+ | 每台地址组数 | 原耗时 ms | 新耗时 ms | 原分配对象 | 新分配对象 |
10
+ | --- | ---: | ---: | ---: | ---: |
11
+ | 100 | 1.008–1.020 | 0.971–1.089 | 10200 | 9995 |
12
+ | 1000 | 16.272–16.308 | 11.008–12.233 | 99300 | 97295 |
13
+ | 3000 | 74.776–76.452 | 32.741–37.336 | 297300 | 291295 |
14
+
15
+ 小输入变化落在噪声范围,主要收益来自大量不同引用时避免重复扫描。该证据不代表设备采集、网络吞吐或所有 Fabric 业务场景。
16
+
17
+ 复测:`bundle exec ruby -Ilib benchmark/fabric.rb`;基线替换为 `-I/path/to/baseline/lib`。脚本打印实际源码路径及每个样本,基线应保持相同 Ruby、依赖、输入和脚本,不覆盖工作区切换版本。
18
+
19
+ 完整 `bundle exec rake` 已通过:453 个示例无失败,105 文件 lint 无违规,离线示例与 CLI 帮助通过。新增语义回归覆盖重复引用、未引用设备、缺失定义与证据顺序。未执行设备变更、远程 CI 或发布。
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require "ctx_runner/fingerprint"
4
4
  require "json"
5
5
  require "ipaddr"
6
6
  require "ctx_parser/immutable"
@@ -34,16 +34,10 @@ module Vxlan
34
34
  end
35
35
 
36
36
  # 字符串键规范化后计算语义指纹;Hash 顺序不影响结果,数组保留业务顺序。
37
- def self.fingerprint(value) = Digest::SHA256.hexdigest(JSON.generate(canonical(value)))
38
-
39
- # 规范化同时提供独立容器,用于屏蔽唯一允许变化的字段。
40
- def self.canonical(value)
41
- case value
42
- when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |entry| canonical(entry) }
43
- when Array then value.map { |entry| canonical(entry) }
44
- else value
45
- end
46
- end
37
+ def self.fingerprint(value) = CtxRunner::Fingerprint.call(value)
38
+
39
+ # 兼容既有领域入口,配置身份协议仅由 Runner 实现。
40
+ def self.canonical(value) = CtxRunner::Fingerprint.canonical(value)
47
41
 
48
42
  # 构造时一次校验和渲染,随后调用者不能改变已确认的参数或命令。
49
43
  def initialize(change)
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
  require "json"
5
5
  require "digest"
6
+ require "ctx_runner/execution"
6
7
  require "ipaddr"
7
8
  require "vxlan"
8
9
  require_relative "report"
@@ -45,7 +46,7 @@ module Vxlan
45
46
 
46
47
  # 没有 --yes 或自动回退;零退出码只确认配置回读,不声称业务已验收。
47
48
  def run(argv)
48
- @store, @state, @started = nil, nil, false
49
+ @store, @execution = nil, nil
49
50
  options = { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }
50
51
  parser = OptionParser.new do |p|
51
52
  p.banner = "用法:vxlan apply --plan FILE --item ID [--env-file FILE](必须真实终端明确确认)"
@@ -75,43 +76,17 @@ module Vxlan
75
76
  return @out.puts("已取消,未连接设备。") || 1 unless @input.gets&.strip == token
76
77
  settings = credentials(options)
77
78
  @store = Report.new(options[:directory])
78
- @state = { "status" => "confirmed", "device" => plan["device"], "plan_sha256" => hash, "plan_item" => options[:item], "expected_config_sha256" => plan["expected_config_sha256"], "commands" => plan["commands"], "steps" => [] }
79
- @store.write_execution(@state)
80
- session_opened, verified = false, false
81
- with_session(plan["device"], settings) do |session|
82
- raise IOError, "会话回调重复,禁止重放" if session_opened
83
- session_opened = true
84
- before = CtxParser::Immutable.copy(session.read_configuration)
85
- unless ChangePlan.fingerprint(before) == plan["expected_config_sha256"] && change.eligible?(before)
86
- raise ArgumentError, "配置已变化或增量前提不满足"
87
- end
88
- change.commands.each_with_index do |command, index|
89
- @state["status"] = "executing"
90
- @state["pending_command"] = index
91
- @store.write_execution(@state)
92
- @started = true
93
- raise IOError, "设备命令未确认" unless session.write(command) == true
94
- @state["steps"] << { "index" => index, "status" => "acknowledged" }
95
- @state.delete("pending_command")
96
- @store.write_execution(@state)
97
- end
98
- after = CtxParser::Immutable.copy(session.read_configuration)
99
- raise IOError, "配置验收未通过" unless change.satisfied?(after, before: before)
100
- @state["observed_config_sha256"] = ChangePlan.fingerprint(after)
101
- verified = true
102
- end
103
- raise IOError, "会话未完成配置验证" unless verified
104
- @state["status"] = "configuration_verified"
105
- @store.write_execution(@state)
79
+ @execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
80
+ @execution.run { |operation| with_session(plan["device"], settings, &operation) }
106
81
  @out.puts "配置回读已确认;仍需完成业务验收,未执行保存配置。日志:#{@store.directory}/execution.json"
107
82
  0
108
83
  rescue Interrupt
109
- record_failure("interrupted")
110
- @error.puts "操作中断;#{@started ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
84
+ report_journal_failure
85
+ @error.puts "操作中断;#{@execution&.started? ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
111
86
  130
112
87
  rescue StandardError, LoadError => error
113
- record_failure(error.class.name)
114
- @error.puts "下发停止(#{error.class.name});#{@started ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
88
+ report_journal_failure
89
+ @error.puts "下发停止(#{error.class.name});#{@execution&.started? ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
115
90
  2
116
91
  end
117
92
 
@@ -158,14 +133,12 @@ module Vxlan
158
133
  end
159
134
  end
160
135
 
161
- # 失败记录尽力持久化;记录失败也不允许继续发送任何命令。
162
- def record_failure(code)
163
- return unless @store && @state
164
- @state["status"] = @started ? "uncertain" : "failed"
165
- @state["error"] = code
166
- @store.write_execution(@state)
167
- rescue StandardError, Interrupt
136
+ # 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
137
+ def report_journal_failure
138
+ return unless @execution&.journal_failed?
168
139
  @error.puts "执行日志收尾未确认;保留已有检查点,禁止据此重放配置。"
140
+ rescue StandardError, Interrupt
141
+ nil
169
142
  end
170
143
  end
171
144
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "vxlan"
4
+ require "ctx_runner/batch"
4
5
  require_relative "options"
5
6
  require_relative "report"
6
7
  require_relative "connector"
@@ -14,13 +15,12 @@ module Vxlan
14
15
  @env, @out, @error, @input = env, out, error, input
15
16
  @connector_factory, @executor = connector_factory, executor
16
17
  @defaults = options
17
- @lock = Mutex.new
18
18
  end
19
19
 
20
20
  # 先预检全部输入和凭据,再创建报告和连接;失败分类不打印原始异常正文。
21
21
  def run(argv)
22
22
  # 每次运行独立持有批次状态;预检失败不能改写上一次报告。
23
- @options, @store, @progress, @devices, @results = nil, nil, nil, [], []
23
+ @options, @store, @progress, @devices, @batch = nil, nil, nil, [], nil
24
24
  @options = Options.new(argv, env: @env, out: @out, **@defaults)
25
25
  return 0 if @options.informational?
26
26
  @options.validate_interaction!(input: @input, error: @error)
@@ -52,32 +52,25 @@ module Vxlan
52
52
  Net::Connector::Resources.prepare!(parallel: [ devices.size, @options.values.fetch("parallel") ].min)
53
53
  end
54
54
  @store = Report.new(@options.values.fetch("directory"), scripts: @options.flags[:scripts] == true)
55
- @progress.plan(devices: devices, total: @inventory_total, parallel: @options.values.fetch("parallel"), label: "VXLAN 只读采集")
56
- @progress.with_updates do
57
- execute(devices) { |device| collect(device, credentials) }
55
+ @batch = CtxRunner::Batch.new(report: @store, progress: @progress, requested: devices)
56
+ outcome = @batch.run(present: ->(summary) { @out.puts JSON.pretty_generate(summary) if @options.flags[:json] }) do
57
+ @progress.plan(devices: devices, total: @inventory_total, parallel: @options.values.fetch("parallel"), label: "VXLAN 只读采集")
58
+ @progress.with_updates do
59
+ execute(devices) { |device| collect(device, credentials) }
60
+ end
61
+ @store.review_collected_fabric(@options.fabric_policy)
58
62
  end
59
- @store.review_collected_fabric(@options.fabric_policy)
60
- summary = finish
61
- return 2 unless summary
62
- summary[:status] == :complete ? 0 : 1
63
+ completion(outcome)
63
64
  rescue Interrupt
64
- finish(interrupted: true) if @store
65
65
  diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
66
66
  130
67
67
  rescue SystemCallError, IOError => error
68
- finish(report_error: error) if @store
69
68
  diagnostic "VXLAN 参数或报告错误:#{report_diagnostic(error)}"
70
69
  2
71
70
  rescue OptionParser::ParseError, ArgumentError, LoadError => error
72
- if @store
73
- finish(execution_error: error)
74
- diagnostic "VXLAN 执行失败:#{error.class.name};已采集结果见批次报告"
75
- else
76
- diagnostic "VXLAN 参数、依赖或报告错误:#{error.message};使用 --help 查看参数"
77
- end
71
+ diagnostic "VXLAN 参数、依赖或报告错误:#{error.message};使用 --help 查看参数"
78
72
  2
79
73
  rescue StandardError => error
80
- finish(execution_error: error) if @store
81
74
  diagnostic "VXLAN 清单或执行失败:#{CtxDriver::Failure.code(error, :collection_failed)}(#{error.class.name})"
82
75
  2
83
76
  end
@@ -88,7 +81,7 @@ module Vxlan
88
81
  def load_network
89
82
  require "net/connector"
90
83
  rescue LoadError
91
- raise LoadError, "联网采集需要 net-connector >= 0.16.0;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
84
+ raise LoadError, "联网采集需要 net-connector >= 0.17.0;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
92
85
  end
93
86
 
94
87
  # YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
@@ -106,14 +99,9 @@ module Vxlan
106
99
  end
107
100
  targets = @options.normalize_devices(rows)
108
101
  @inventory_total = targets.size
109
- includes = values.fetch("hosts").map { |v| IPAddr.new(v) }
110
- excludes = values.fetch("exclude_hosts").map { |v| IPAddr.new(v) }
102
+ selection = Net::Connector::Inventory::Filters.selection(values)
111
103
  targets.select do |device|
112
- host, name = device.values_at("host", "name")
113
- (includes.empty? || includes.any? { |network| network.include?(host) }) &&
114
- excludes.none? { |network| network.include?(host) } &&
115
- (values.fetch("names").empty? || values.fetch("names").any? { |v| name.downcase.include?(v.downcase) }) &&
116
- values.fetch("exclude_names").none? { |v| name.downcase.include?(v.downcase) }
104
+ selection.selected?(device.fetch("host"), "cisco_nxos", name: device["name"], model: device["model"])
117
105
  end
118
106
  end
119
107
 
@@ -140,46 +128,33 @@ module Vxlan
140
128
 
141
129
  # 单台失败保留其他设备和已获得维度;报告持久化失败交由批次终止处理。
142
130
  def collect(device, credentials)
143
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
- snapshot = nil
145
- @progress.start(device)
146
131
  credentials = credentials.merge(on_event: @progress.method(:event))
147
- begin
132
+ @batch.collect(device) do
148
133
  connector = @connector_factory ? @connector_factory.call(device, credentials) : Connector.new(device, credentials)
149
134
  snapshot = Vxlan::Collector.new.collect(connector: connector, target: device, fabric_key: @options.values.fetch("fabric"),
150
135
  model: device["model"], dimensions: @options.values.fetch("dimensions").map(&:to_sym))
151
- dimensions = snapshot.dimensions.transform_values { |v| { status: v.status, records: v.records.size, issues: v.issues.map { |issue| issue[:code] }.uniq } }
152
- status = snapshot.dimensions.fetch(:collection).status
153
- result = { device: device, status: status, dimensions: dimensions }
154
- rescue StandardError => error
155
- result = { device: device, status: :failed, error: CtxDriver::Failure.code(error, :collection_failed), dimensions: {} }
136
+ snapshot
156
137
  end
157
- result[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
158
- result[:report_saved] = false
159
- @lock.synchronize { @results << result }
160
- @store.write(result, snapshot: snapshot)
161
- @lock.synchronize { result[:report_saved] = true }
162
- @progress.result(result)
163
- result
164
138
  end
165
139
 
166
- # 调度器已回收线程;摘要失败只交付安全诊断,不覆盖先前的取消事实。
167
- def finish(interrupted: false, report_error: nil, execution_error: nil)
168
- results = @results.sort_by { |result| result[:device]["host"] }
169
- options = { interrupted: interrupted, requested: @devices,
170
- report_error: report_error && report_diagnostic(report_error), execution_error: execution_error&.class&.name }
171
- summary = @store.finish(results, **options, output_error: @progress.output_error)
172
- @progress.finish(summary)
173
- # 末尾提示也可能首次失败;只补写输出诊断,不重复设备任务。
174
- if @progress.output_error && !summary[:output_error]
175
- summary = @store.finish(results, **options, output_error: @progress.output_error)
140
+ # Runner 决定批次结论;CLI 仅呈现安全诊断并映射退出码。
141
+ def completion(outcome)
142
+ if outcome.finalization_error
143
+ diagnostic "VXLAN 摘要报告错误:#{outcome.finalization_error}"
176
144
  end
177
- @out.puts JSON.pretty_generate(summary) if @options.flags[:json]
178
- summary
179
- rescue SystemCallError, IOError => error
180
- diagnostic "VXLAN 摘要报告错误:#{report_diagnostic(error)}"
181
- diagnostic "摘要恢复未确认;请依据退出码及报告目录检查,不能将旧摘要视为本次成功。" if @store.recovery_failed?
182
- nil
145
+ if outcome.recovery_failed
146
+ diagnostic "摘要恢复未确认;请依据退出码及报告目录检查,不能将旧摘要视为本次成功。"
147
+ end
148
+ if outcome.status == :interrupted
149
+ diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
150
+ elsif (failure = outcome.failure)
151
+ if failure[:kind] == :report
152
+ diagnostic "VXLAN 参数或报告错误:#{failure[:detail]}"
153
+ else
154
+ diagnostic "VXLAN 清单或执行失败:#{failure[:error_type]};已采集结果见批次报告"
155
+ end
156
+ end
157
+ { complete: 0, incomplete: 1, failed: 2, interrupted: 130 }.fetch(outcome.status)
183
158
  end
184
159
 
185
160
  # 错误提示失败不能覆盖取消或执行退出码;已失效的进度流不再重试。
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "net/connector/inventory/filters"
4
5
  require "yaml"
5
6
  require "ipaddr"
6
7
  require "fileutils"
@@ -10,7 +11,7 @@ module Vxlan
10
11
  module CLI
11
12
  # 采集入口的公开参数;凭据始终留在环境中,不传入解析库或 YAML。
12
13
  class Options
13
- FIELDS = %w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh hosts exclude_hosts names exclude_names].freeze
14
+ FIELDS = (%w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh] + Net::Connector::Inventory::Filters::FIELDS).freeze
14
15
  SSH_FIELDS = %w[port command_timeout login_timeout host_key_policy known_hosts].freeze
15
16
  SSH_DEFAULTS = { "host_key_policy" => "strict", "command_timeout" => 60, "login_timeout" => 15 }.freeze
16
17
  attr_reader :values, :config, :env_file, :flags, :fabric_policy, :environment
@@ -62,7 +63,7 @@ module Vxlan
62
63
  @environment = @files.environment
63
64
  @config, @env_file = @files.config, @files.env_file
64
65
  rescue LoadError
65
- raise LoadError, "采集配置需要 net-connector >= 0.16.0;读取 .env 还需要 dotenv", cause: nil
66
+ raise LoadError, "采集配置需要 net-connector >= 0.17.0;读取 .env 还需要 dotenv", cause: nil
66
67
  end
67
68
 
68
69
  # 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
@@ -238,9 +239,12 @@ module Vxlan
238
239
  p.on("--fabric-policy FILE", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
239
240
  p.on("--fabric NAME", "采集所属 Fabric 身份") { |v| @overrides["fabric"] = v }
240
241
  p.on("--dimensions LIST", "逗号分隔维度;--help 查看支持项") { |v| @overrides["dimensions"] = list(v) }
241
- { "--host IP" => "hosts", "--hosts LIST" => "hosts", "--exclude-hosts LIST" => "exclude_hosts",
242
- "--name LIST" => "names", "--exclude-name LIST" => "exclude_names" }.each do |option, key|
243
- p.on(option, "筛选已有清单,同类条件取并集,排除优先") { |v| (@overrides[key] ||= []).concat(list(v)) }
242
+ Net::Connector::Inventory::Filters.add_options(p) do |key, value|
243
+ if value.is_a?(Array)
244
+ (@overrides[key] ||= []).concat(value)
245
+ else
246
+ @overrides[key] = value
247
+ end
244
248
  end
245
249
  p.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { flags[:init] = true }
246
250
  p.on("--interactive", "缺少配置时询问是否创建;要求真实终端") { flags[:interactive] = true }
@@ -286,11 +290,8 @@ module Vxlan
286
290
  raise ArgumentError, "netdisco_config 须为配置路径,且不能同时声明 devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
287
291
  values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
288
292
  end
289
- %w[hosts exclude_hosts names exclude_names].each do |key|
290
- items = values[key] = values.fetch(key, [])
291
- raise ArgumentError, "#{key} 须为非空字符串数组" unless items.is_a?(Array) && items.all? { |v| v.is_a?(String) && !v.strip.empty? }
292
- end
293
- %w[hosts exclude_hosts].each { |key| values[key].each { |v| IPAddr.new(v) } }
293
+ Net::Connector::Inventory::Filters::LISTS.each_key { |key| values[key] = values.fetch(key, []) }
294
+ Net::Connector::Inventory::Filters.selection(values)
294
295
  ssh = values["ssh"]
295
296
  raise ArgumentError, "ssh 包含未知或敏感字段" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
296
297
  ssh = SSH_DEFAULTS.merge(ssh)
@@ -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
@@ -15,7 +15,12 @@ module Vxlan
15
15
  when "--version", "-v"
16
16
  return out.puts(Vxlan::VERSION) || 0 if arguments.empty?
17
17
  when "collect"
18
- require_relative "cli/collect"
18
+ begin
19
+ require_relative "cli/collect"
20
+ rescue LoadError
21
+ error.puts "collect 需要含共享清单筛选的当前 net-connector;请安装项目依赖。"
22
+ return 2
23
+ end
19
24
  return Collect.new(env: env, out: out, error: error, input: input, **collect_options).run(arguments)
20
25
  when "apply"
21
26
  require_relative "cli/apply"
@@ -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
  # 构建失败观测,不丢弃已成功的同级维度。
@@ -247,10 +247,13 @@ module Vxlan
247
247
 
248
248
  # 展开引用对象的成员差异,避免只展示相同的 ACL 文本而隐藏实际匹配范围。
249
249
  def review_address_groups(fabric, configs)
250
- references = configs.transform_values { |config| config.fetch("acls", {}).values.flatten.flat_map { |entry| entry["expression"].scan(/\baddrgroup\s+(\S+)/).flatten }.uniq }
251
- names = references.values.flatten.uniq.sort
252
- names.each do |name|
253
- rows = configs.filter_map { |host, config| { host: host, value: config.fetch("address_groups", {})[name] } if references[host].include?(name) }
250
+ # 每台设备的引用去重后直接归入对象索引,避免每个对象再扫描所有引用。
251
+ references = {}
252
+ configs.each do |host, config|
253
+ names = config.fetch("acls", {}).values.flatten.flat_map { |entry| entry["expression"].scan(/\baddrgroup\s+(\S+)/).flatten }.uniq
254
+ names.each { |name| (references[name] ||= []) << { host: host, value: config.fetch("address_groups", {})[name] } }
255
+ end
256
+ references.sort.each do |name, rows|
254
257
  next unless rows.map { |row| row[:value] }.uniq.size > 1
255
258
  finding(fabric, nil, "fabric_address_group", "ACL 地址组 #{name} 成员不同或缺失;仅比较引用此对象的业务,不自动复制", rows)
256
259
  end
@@ -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.0"
5
+ VERSION = "0.4.0"
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.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -9,6 +9,20 @@ 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_runner
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
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: ctx_parser
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -173,6 +187,7 @@ files:
173
187
  - docs/release.md
174
188
  - docs/remediation.md
175
189
  - docs/review-2026-10-19.md
190
+ - docs/review-2026-10-21.md
176
191
  - docs/superpowers/plans/2026-10-19-command-line.md
177
192
  - docs/superpowers/plans/2026-10-19-connector-integration.md
178
193
  - docs/superpowers/plans/2026-10-19-evidence-contracts.md
@@ -225,6 +240,7 @@ files:
225
240
  - lib/vxlan/cli/review/report.rb
226
241
  - lib/vxlan/cli/templates/env.example
227
242
  - lib/vxlan/cli/templates/vxlan.yml
243
+ - lib/vxlan/collection_plan.rb
228
244
  - lib/vxlan/collector.rb
229
245
  - lib/vxlan/config.rb
230
246
  - lib/vxlan/config/evidence.rb