vxlan 0.7.0 → 0.8.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: c96781edce4b5dcc8990ced65d1287cfb86ee2c431ee3c0b0714365513955d75
4
- data.tar.gz: '0187135b1f3cff8988732b9fb3ed58633e780b3538b569b1ea611f87d09af08a'
3
+ metadata.gz: 3a0904488ebdedf6f5983003bf51724e3e0c5442b2347fb6433041da8f858394
4
+ data.tar.gz: 8c1b2e0ba07891754cb6074e5da60a2174b6a96508309836bdc4db333ce7414e
5
5
  SHA512:
6
- metadata.gz: a5386c55fa4f48e6e121ac63dbca3c8148754857df0fa68841d345301aadf6f1678dc682d86713030110203de4fc8f7f22ed2d2fbb44135bebb9e39157df6386
7
- data.tar.gz: 5c1434eb7eac8ea5649e136523020579c1abf6d0ae57309ee20335989c164922789e70ca62322d55de1775752eb5902b805c50ef80d7cf48151e1968a449d3fe
6
+ metadata.gz: a51c259e1059d12babf19d1789e6ae9e3dba5fdaae8b612aad552aca488665b44f2ca2d25e7cec825b781b39ad8b887ee5c71b648a12da03fd6dc4b5f3d7561a
7
+ data.tar.gz: 6d37d517bcc3d7c8e8f143917449bbe450d2f79c54ca64c9032ace956f8db265ed86fbb4ee6e76f4fdf88ea68f74155408cecbf0f14aee20f5e297818b82f07e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ Changes to the public contract are recorded here. Versions follow Semantic Versi
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.8.0 (2026-10-30)
8
+
9
+ - Adopt schema 4 Target/Source contracts and shared configuration read/write sessions. Validate target platform before opening a connector. Normalize BGP to `peer_address`, `afi_safi`, `received_prefixes` and symbolic states; CDP uses `local_interface`, `remote_interface`, `remote_name`. Require connector 0.21 for collection.
10
+
7
11
  ## 0.7.0 (2026-10-29)
8
12
 
9
13
  - Compose the shared snapshot contract and remove the synthetic collection observation. Whitelist public target identities and validate timestamps and dimensions.
data/README.md CHANGED
@@ -146,8 +146,12 @@ Offline review requires `snapshot.target.host` to match the canonical device hos
146
146
 
147
147
  ## API migration
148
148
 
149
- `RuntimeParser.new(platform:, model: nil)` fixes the platform for `parse(name, text, complete: false, command: nil)` and `parse_many(names, text, complete: false, command: nil)`. Snapshots use `observations:` and expose `observations` / `observation(name)`; the old Ruby `dimensions` member and Wireless `dimension` accessor are removed. Known missing dimensions return `not_requested`; unknown names raise `ArgumentError`. Persisted schema 3 JSON uses `observations` and an independent `collection_status`; regenerate older reports.
149
+ `RuntimeParser.new(platform:, model: nil)` fixes the platform for `parse(name, text, complete: false, command: nil)` and `parse_many(names, text, complete: false, command: nil)`. Snapshots use `observations:` and expose `observations` / `observation(name)`; the old Ruby `dimensions` member and Wireless `dimension` accessor are removed. Known missing dimensions return `not_requested`; unknown names raise `ArgumentError`. Persisted schema 4 JSON uses `observations` and an independent `collection_status`; regenerate older reports.
150
150
 
151
151
  Dynamic inventory uses `Netdisco::Catalog`. Any duplicate row for a selected address, including another platform, prevents connection; excluded conflicts retain safe diagnostics. `NC_<PLATFORM>_ENABLE_PASSWORD` takes precedence over `NC_ENABLE_PASSWORD`.
152
152
 
153
153
  本次契约升级要求重新生成旧报告和计划;Ruby 快照使用 `observations`、`collection_status` 和 `Time`,JSON 通过 `CtxRunner::Document` 输出 UTC ISO8601(解析基础库仅提供 `Result`)。跨模块约定见 [ctx_runner 契约迁移](https://github.com/gatework/ctx_runner/blob/main/docs/contracts-2026-10-29.md)。
154
+
155
+ ## Domain contract migration (2026-10-30)
156
+
157
+ Adopt schema 4 Target/Source contracts and shared configuration read/write sessions. Validate target platform before opening a connector. Normalize BGP to `peer_address`, `afi_safi`, `received_prefixes` and symbolic states; CDP uses `local_interface`, `remote_interface`, `remote_name`. Require connector 0.21 for collection. See the [schema 4 migration and module contracts](https://github.com/gatework/ctx_runner/blob/main/docs/contracts-2026-10-30.md). Regenerate old collection reports and change plans; changing only their version number is not supported.
@@ -8,13 +8,13 @@ module Vxlan
8
8
  # 从 BGP 汇总解析 EVPN 邻居状态及可选路由类型统计。
9
9
  class ShowBgpL2vpnEvpnSummary < Parser
10
10
  EMPTY = "(?:BGP )?neighbors"
11
- IDENTITY = %i[vrf address_family peer_ip].freeze
11
+ IDENTITY = %i[vrf afi_safi peer_address].freeze
12
12
  BOUNDARY = /^\s*BGP summary information for VRF /
13
13
  SCOPE = /\ABGP summary information for VRF (\S+), address family (.+)\z/
14
14
  ROUTER = /\ABGP router identifier (\S+), local AS number (\S+)\z/
15
15
  HEADER = /\ANeighbor\s+V\s+AS\s+MsgRcvd\s+MsgSent\s+TblVer\s+InQ\s+OutQ\s+Up\/Down\s+State\/PfxRcd\z/i
16
16
  ROW = /\A([\da-f:.]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/i
17
- FIELDS = %i[peer_ip version remote_as messages_received messages_sent table_version input_queue output_queue uptime state].freeze
17
+ FIELDS = %i[peer_address version remote_as messages_received messages_sent table_version input_queue output_queue uptime state].freeze
18
18
  INTEGERS = %i[version remote_as messages_received messages_sent table_version input_queue output_queue].freeze
19
19
  PREFIX_TYPES = %i[type1 type2 type3 type4 type5 type6 type7 type8 type12].freeze
20
20
  TABLE = /\ABGP table version is \d+(?:, L2VPN EVPN config peers (\d+), capable peers (\d+))?\z/i
@@ -29,7 +29,7 @@ module Vxlan
29
29
  scope, identities, invalid = nil, {}, {}
30
30
  text.each_line("\n") do |line|
31
31
  if (match = SCOPE.match(line.strip))
32
- scope = match.captures
32
+ scope = [ match[1], match[2] == "L2VPN EVPN" ? "l2vpn_evpn" : match[2] ]
33
33
  elsif (match = ROUTER.match(line.strip))
34
34
  identity = router_identity(match)
35
35
  if !identity
@@ -42,10 +42,10 @@ module Vxlan
42
42
  end
43
43
  end
44
44
  invalid.each do |key, code|
45
- issue = { code: code, vrf: key&.first, address_family: key&.last }
45
+ issue = { code: code, vrf: key&.first, afi_safi: key&.last }
46
46
  @issues << issue unless @issues.include?(issue)
47
47
  end
48
- @rows.reject! { |row| invalid.key?(row.values_at(:vrf, :address_family)) }
48
+ @rows.reject! { |row| invalid.key?(row.values_at(:vrf, :afi_safi)) }
49
49
  end
50
50
 
51
51
  # ASDOT 与 ASPLAIN 用同一数值比较;无效身份不能附着到完整邻居事实。
@@ -67,8 +67,8 @@ module Vxlan
67
67
  @header = true
68
68
  prefix_columns = nil
69
69
  elsif (match = SCOPE.match(line))
70
- scope = { vrf: match[1], address_family: match[2] }
71
- unparsed_records(number) unless scope[:address_family] == "L2VPN EVPN"
70
+ scope = { vrf: match[1], afi_safi: match[2] == "L2VPN EVPN" ? "l2vpn_evpn" : match[2] }
71
+ unparsed_records(number) unless scope[:afi_safi] == "l2vpn_evpn"
72
72
  prefix_columns = nil
73
73
  elsif (match = ROUTER.match(line))
74
74
  scope.merge!(router_id: match[1], local_as: match[2])
@@ -89,7 +89,7 @@ module Vxlan
89
89
  if statistics && !prefix_seen.key?(statistics.first.object_id)
90
90
  peer, counts = statistics
91
91
  # 路由类型是总前缀的子集;未展示的类型允许存在,已展示之和不能超过总数。
92
- if peer[:prefixes_received] && counts.sum > peer[:prefixes_received]
92
+ if peer[:received_prefixes] && counts.sum > peer[:received_prefixes]
93
93
  @issues << { code: :inconsistent_prefix_statistics, line: number }
94
94
  else
95
95
  peer[:prefixes_by_type] = prefix_columns.zip(counts).to_h
@@ -106,7 +106,7 @@ module Vxlan
106
106
  end
107
107
  @rows << row
108
108
  # 统计表按完整作用域查找;重复邻居仍由最终身份检查整体隔离。
109
- peers[row.values_at(:vrf, :address_family, :peer_ip)] ||= row
109
+ peers[row.values_at(:vrf, :afi_safi, :peer_address)] ||= row
110
110
  else
111
111
  unparsed_records(number)
112
112
  end
@@ -125,25 +125,30 @@ module Vxlan
125
125
  count_mismatch(configured, @rows.size)
126
126
  @issues << { code: :invalid_peer_counts, configured: configured, capable: capable } if capable > configured
127
127
  end
128
- @empty = true if values == [ [ 0, 0 ] ] && @rows.empty? && scope[:address_family] == "L2VPN EVPN"
128
+ @empty = true if values == [ [ 0, 0 ] ] && @rows.empty? && scope[:afi_safi] == "l2vpn_evpn"
129
129
  end
130
130
 
131
131
  # AS 0 只表示尚未建立邻居的未知身份;数值前缀总数始终表示已建立会话。
132
132
  def neighbor_record(match, scope)
133
133
  row = FIELDS.zip(match.captures).to_h.merge(scope)
134
134
  INTEGERS.each { |key| row[key] = row[key].to_i }
135
- return unless row[:vrf] && row[:address_family] == "L2VPN EVPN" && ip_address?(row[:peer_ip]) && row[:version] == 4 &&
135
+ return unless row[:vrf] && row[:afi_safi] == "l2vpn_evpn" && ip_address?(row[:peer_address]) && row[:version] == 4 &&
136
136
  (0..4_294_967_295).cover?(row[:remote_as]) && STATE.match?(row[:state])
137
137
 
138
- row[:prefixes_received] = row[:state].match?(/\A\d+\z/) ? row[:state].to_i : nil
138
+ row[:received_prefixes] = row[:state].match?(/\A\d+\z/) ? row[:state].to_i : nil
139
139
  row[:remote_as] = row[:remote_as].nonzero?
140
- return if row[:prefixes_received] && row[:remote_as].nil?
140
+ return if row[:received_prefixes] && row[:remote_as].nil?
141
141
 
142
- row[:peer_ip] = CtxParser::Values.ip_address(row[:peer_ip], allow_zone: true)
143
- row[:state] = row[:prefixes_received] ? "established" : row[:state].downcase.tr(" ", "_")
142
+ row[:peer_address] = CtxParser::Values.ip_address(row[:peer_address], allow_zone: true)
143
+ row[:state] = row[:received_prefixes] ? :established : state_value(row[:state])
144
144
  row
145
145
  end
146
146
 
147
+ def state_value(value)
148
+ return :shutdown if /admin|shutdown/i.match?(value)
149
+ value.downcase.to_sym
150
+ end
151
+
147
152
  # 从末尾读取类型计数,保留多词状态,并按主表身份与总数关联统计。
148
153
  def prefix_record(line, scope:, peers:, columns:, total:)
149
154
  words = line.split(" ")
@@ -154,14 +159,14 @@ module Vxlan
154
159
  return unless ip_address?(address) && %w[I E].include?(kind) && asn.match?(/\A\d+\z/) && counts.all? { |value| value.match?(/\A\d+\z/) }
155
160
 
156
161
  address = CtxParser::Values.ip_address(address, allow_zone: true)
157
- peer = peers[[ scope[:vrf], scope[:address_family], address ]]
162
+ peer = peers[[ scope[:vrf], scope[:afi_safi], address ]]
158
163
  return unless peer && peer[:remote_as] == asn.to_i.nonzero?
159
164
 
160
165
  if total
161
166
  value = words.join(" ")
162
167
  return unless STATE.match?(value)
163
- observed = value.match?(/\A\d+\z/) ? value.to_i : value.downcase.tr(" ", "_")
164
- return unless observed == (peer[:prefixes_received] || peer[:state])
168
+ observed = value.match?(/\A\d+\z/) ? value.to_i : state_value(value)
169
+ return unless observed == (peer[:received_prefixes] || peer[:state])
165
170
  else
166
171
  return unless words.empty?
167
172
  end
@@ -8,7 +8,7 @@ module Vxlan
8
8
  # 在所属邻居记录内解释 CDP 详细字段。
9
9
  class ShowCdpNeighborsDetail < Parser
10
10
  EMPTY = "(?:CDP neighbors|CDP entries|neighbors|entries)".freeze
11
- IDENTITY = %i[device_id interface neighbor_interface].freeze
11
+ IDENTITY = %i[device_id local_interface remote_interface].freeze
12
12
 
13
13
  private
14
14
 
@@ -39,7 +39,7 @@ module Vxlan
39
39
  end
40
40
  case line
41
41
  when /\A--More--/i then unparsed_records(number)
42
- when /\ASystem Name:\s*(\S.*)\z/i then assign_field(row, :neighbor_name, $1, number)
42
+ when /\ASystem Name:\s*(\S.*)\z/i then assign_field(row, :remote_name, $1, number)
43
43
  when /\AInterface address\(es\):(?:\s+\d+)?\z/i then scope = :interface_addresses
44
44
  when /\AMgmt address\(es\):(?:\s+\d+)?\z/i then scope = :management_addresses
45
45
  when /\AIP(?:v[46])? Address:\s*(\S+)\z/i
@@ -55,9 +55,9 @@ module Vxlan
55
55
  assign_field(row, :capabilities, capabilities, number)
56
56
  scope = nil
57
57
  when /\AInterface:\s*(\S+),\s*Port ID \(outgoing port\):\s*(\S.*)\z/i
58
- interface, neighbor_interface = $1, $2
59
- assign_field(row, :interface, interface, number)
60
- assign_field(row, :neighbor_interface, neighbor_interface, number)
58
+ interface, remote_interface = $1, $2
59
+ assign_field(row, :local_interface, interface, number)
60
+ assign_field(row, :remote_interface, remote_interface, number)
61
61
  scope = nil
62
62
  when /\AHoldtime:\s*(\d+) sec\z/i then assign_field(row, :holdtime, $1.to_i, number)
63
63
  when /\AVersion:\z/i
@@ -81,10 +81,10 @@ module Vxlan
81
81
  totals.each { |total| count_mismatch(total, @rows.size) }
82
82
  @empty = true if totals == [ 0 ] && @rows.empty?
83
83
  @rows.reject! do |entry|
84
- next true if @field_conflicts.fetch(entry, {}).keys.intersect?(%i[neighbor_name interface neighbor_interface])
84
+ next true if @field_conflicts.fetch(entry, {}).keys.intersect?(%i[remote_name local_interface remote_interface])
85
85
 
86
- entry[:neighbor_name] ||= entry[:device_id].sub(/\([^)]*\)\z/, "").split(".").first
87
- if entry.values_at(:neighbor_name, :interface, :neighbor_interface).any? { |value| value.nil? || value.empty? }
86
+ entry[:remote_name] ||= entry[:device_id].sub(/\([^)]*\)\z/, "").split(".").first
87
+ if entry.values_at(:remote_name, :local_interface, :remote_interface).any? { |value| value.nil? || value.empty? }
88
88
  @issues << { code: :incomplete_cdp_neighbor }
89
89
  true
90
90
  end
@@ -8,10 +8,10 @@ module Vxlan
8
8
  # 解析 NVE 端点及路由器 MAC,支持终端换行记录。
9
9
  class ShowNvePeers < Parser
10
10
  EMPTY = "peers"
11
- IDENTITY = %i[interface peer_ip].freeze
11
+ IDENTITY = %i[interface peer_address].freeze
12
12
  HEADER = /\AInterface\s+Peer-IP\s+State\s+LearnType\s+Uptime\s+Router-Mac\z/i
13
13
  ROW = /\A(nve\d+)\s+(\d+(?:\.\d+){3})\s+(Up|Down)\s+(\S+)\s+(\S+)\s+([\da-f]{4}(?:\.[\da-f]{4}){2}|n\/a)\z/i
14
- FIELDS = %i[interface peer_ip state learn_type uptime router_mac].freeze
14
+ FIELDS = %i[interface peer_address state learn_type uptime router_mac].freeze
15
15
 
16
16
  private
17
17
 
@@ -4,34 +4,18 @@ require "ctx_cli/apply"
4
4
  require "ctx_runner/document"
5
5
  require "ctx_runner/evidence"
6
6
  require "ctx_runner/execution"
7
+ require "ctx_runner/configuration_session"
7
8
  require "vxlan"
8
9
  require_relative "report"
9
10
  require_relative "../change_plan"
10
11
 
11
12
  module Vxlan
12
13
  module CLI
13
- # 原生会话适配:读取使用固定查询提示符,写入由连接器识别配置模式提示符。
14
- class ApplySession
15
- # 借用一次已连接且独占的会话,不重连、不重放。
14
+ # 领域仅声明准备命令及配置语法,共享会话维护回执和读取边界。
15
+ class ApplySession < CtxRunner::ConfigurationSession
16
16
  def initialize(native)
17
- @native = native
18
- end
19
-
20
- # 读回完整配置并交给现有解析器;部分证据不能参与前置检查或验收。
21
- def read_configuration
22
- [ "terminal length 0", "terminal width 511" ].each { |command| write(command) }
23
- capture = CtxDriver::Reader.new(session: @native).read("show running-config")
24
- raise IOError, "Configuration readback failed" unless capture.success?
25
- observation = Vxlan::Config.new.parse(capture.body, complete: true)
26
- raise ArgumentError, "Configuration evidence is incomplete" unless observation.complete? && observation.records.one?
27
- CtxRunner::Document.call(observation.records.first)
28
- end
29
-
30
- # 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
31
- def write(command)
32
- result = @native.execute_command(command.strip, output_sensitive: true)
33
- raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one? && result.steps.first.command.text == command.strip
34
- true
17
+ super(session: CtxDriver::Session.new(native), preparation_commands: [ "terminal length 0", "terminal width 511" ],
18
+ configuration_command: "show running-config", parser: Vxlan::Config.new)
35
19
  end
36
20
  end
37
21
 
@@ -34,7 +34,7 @@ module Vxlan
34
34
  def load_network
35
35
  require "net/connector"
36
36
  rescue LoadError
37
- raise LoadError, "Online collection requires net-connector >= 0.20.0; add it to the host Gemfile or run gem install net-connector", cause: nil
37
+ raise LoadError, "Online collection requires net-connector >= 0.21.0; add it to the host Gemfile or run gem install net-connector", cause: nil
38
38
  end
39
39
 
40
40
  # YAML 静态设备或显式 Netdisco 来源二选一;选中地址的所有冲突记录在连接前拒绝。
@@ -6,7 +6,7 @@ require "optparse"
6
6
  require "ctx_cli/options"
7
7
  require "ctx_cli/terminal"
8
8
  require "ctx_cli/initializer"
9
- gem "net-connector", "~> 0.20.0"
9
+ gem "net-connector", "~> 0.21.0"
10
10
  require "net/connector/inventory/filters"
11
11
  require "yaml"
12
12
  require "ipaddr"
@@ -67,7 +67,7 @@ module Vxlan
67
67
  @environment = @files.environment
68
68
  @config, @env_file = @files.config, @files.env_file
69
69
  rescue LoadError
70
- raise LoadError, "Collection configuration requires net-connector >= 0.20.0; reading .env also requires dotenv", cause: nil
70
+ raise LoadError, "Collection configuration requires net-connector >= 0.21.0; reading .env also requires dotenv", cause: nil
71
71
  end
72
72
 
73
73
  # 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
@@ -211,12 +211,12 @@ module Vxlan
211
211
 
212
212
  # 设备身份只接受公开字段,拒绝隐藏在 YAML 中的密码或连接参数。
213
213
  def normalize_device(row)
214
- unless row.is_a?(Hash) && (row.keys - %w[host name model]).empty? && row["host"].is_a?(String) && !row["host"].include?("/")
214
+ unless row.is_a?(Hash) && (row.keys - %w[host name model platform]).empty? && (!row.key?("platform") || row["platform"] == "cisco_nxos") && row["host"].is_a?(String) && !row["host"].include?("/")
215
215
  raise ArgumentError, "devices accepts only host, name and model; host must be a single IP address"
216
216
  end
217
217
  host = IPAddr.new(row.fetch("host")).to_s
218
218
  %w[name model].each { |key| raise ArgumentError, "Device #{key} must be a nonempty string" if row.key?(key) && !row[key].nil? && (!row[key].is_a?(String) || row[key].strip.empty?) }
219
- { "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
219
+ { "platform" => "cisco_nxos", "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
220
220
  rescue IPAddr::Error
221
221
  raise ArgumentError, "Invalid device host", cause: nil
222
222
  end
@@ -28,20 +28,20 @@ module Vxlan
28
28
  def write_execution(state) = @report.write_execution(state)
29
29
 
30
30
  # 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
31
- def self.device_basename(device)
31
+ def self.target_basename(device)
32
32
  "#{device.fetch('name').gsub(/[^A-Za-z0-9_.-]/, '_')[0, 100]}-#{device.fetch('host').gsub(/[^A-Za-z0-9_.-]/, '_')}"
33
33
  end
34
34
 
35
35
  # 每台完成后立即原子保存,即使批次随后取消仍可读取已完成证据。
36
36
  def write(result, snapshot: nil)
37
- device = result.fetch(:device)
38
- name = self.class.device_basename(device)
37
+ device = result.fetch(:target)
38
+ name = self.class.target_basename(device)
39
39
  # 保存状态属于批次宿主;不能让正在发布的文件预先声明自身已保存。
40
40
  body = result.reject { |key, _| key == :report_saved }.merge(snapshot: snapshot && snapshot.to_h)
41
41
  document = CtxRunner::Evidence.wrap(body, kind: "collection")
42
42
  advice = report_advice(Recommendations.new(document).call)
43
43
  document = document.merge("recommendations" => advice)
44
- json = JSON.pretty_generate(document) + "\n"
44
+ json = JSON.pretty_generate(document, max_nesting: 128) + "\n"
45
45
  prepare_directory(File.join(directory, "evidence"))
46
46
  publish("evidence/#{name}.json", json)
47
47
  write_review(document, basename: name, source: File.join(directory, "evidence", "#{name}.json"), source_sha256: Digest::SHA256.hexdigest(json), advice: advice)
@@ -61,7 +61,7 @@ module Vxlan
61
61
  text << "Progress output error: #{summary['output_error']['error_type']}; device results retained" if summary["output_error"]
62
62
  summary.fetch("devices").each do |r|
63
63
  saved = r["report_saved"] == true ? "saved" : "persistence unconfirmed"
64
- text << "#{r['device']['host']}\t#{r['device']['name']}\t#{r['status']}\t#{saved}\t#{r['error']}"
64
+ text << "#{r['target']['host']}\t#{r['target']['name']}\t#{r['status']}\t#{saved}\t#{r['error']}"
65
65
  end
66
66
  pending.each { |device| text << "#{device['host']}\t#{device['name']}\tnot collected" }
67
67
  text.join("\n") + "\n"
@@ -12,7 +12,7 @@ module Vxlan
12
12
  module Fabric
13
13
  # 相同事实合并展示;规则版本保持匹配顺序,完整证据由聚合计划保存。
14
14
  def fabric_text(audit, documents)
15
- by_host = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
15
+ by_host = documents.to_h { |document| [ document.fetch("target").fetch("host"), document ] }
16
16
  lines = [ "Fabric shared-service consistency", "Scope: compare shared VNIs within explicit Fabrics and roles; compare Leaf and Border separately, and check Spine for service-bearing configuration.", "Deployment facts combine VLAN/NVE/EVPN evidence; incomplete deployment does not mean absence. Deployment scope differences are retained.", "ACL comparison includes ordered rules and dependencies; a majority without an ACL is not grounds for removal.", "" ]
17
17
  inventory = audit[:inventory]
18
18
  if inventory
@@ -23,7 +23,7 @@ module Vxlan
23
23
  services = audit[:coverage].select { |entry| entry[:fabric] == fabric && entry[:role] == role }
24
24
  partial = services.select { |entry| entry[:incomplete].any? }
25
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(', ')}" }
26
+ partial.each { |entry| lines << " VNI #{entry[:vni]} missing: #{entry[:incomplete].map { |host| by_host.dig(host, 'target', 'name') || host }.join(', ')}" }
27
27
  end
28
28
  end
29
29
  lines << ""
@@ -39,14 +39,14 @@ module Vxlan
39
39
  common = defined.reduce { |all, members| all & members } || []
40
40
  lines << " Members common to defined versions (not an approved baseline): #{common.empty? ? 'none' : common.join(', ')}"
41
41
  evidence.group_by { |row| row[:value] }.each do |members, rows|
42
- names = rows.map { |row| by_host.dig(row[:host], "device", "name") || row[:host] }
42
+ names = rows.map { |row| by_host.dig(row[:host], "target", "name") || row[:host] }
43
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
44
  lines.concat([ " #{names.join(', ')}", " #{value}" ])
45
45
  end
46
46
  next
47
47
  end
48
48
  unless evidence.is_a?(Array) && evidence.all? { |row| row.is_a?(Hash) }
49
- lines << JSON.pretty_generate(evidence)
49
+ lines << JSON.pretty_generate(evidence, max_nesting: 128)
50
50
  next
51
51
  end
52
52
  grouped = evidence.group_by do |row|
@@ -59,12 +59,12 @@ module Vxlan
59
59
  [ row[:vlan] || row[:interface] || "shared", display_fabric_value(value) ]
60
60
  end
61
61
  grouped.each do |(vlan, value), members|
62
- names = members.map { |row| row[:name] || by_host.dig(row[:host], "device", "name") || row[:host] }
62
+ names = members.map { |row| row[:name] || by_host.dig(row[:host], "target", "name") || row[:host] }
63
63
  lines.concat([ " VLAN/interface #{vlan}: #{value}", " #{names.join(', ')}" ])
64
64
  end
65
65
  if evidence.any? { |row| row[:semantics] }
66
66
  evidence.group_by { |row| row[:semantics] }.each_with_index do |(rules, members), variant|
67
- lines << " Rule version #{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], 'target', 'name') || row[:host] }.join(', ')}"
68
68
  lines.concat(rules.map { |rule| " #{rule['action']} #{rule['expression']}" })
69
69
  end
70
70
  end
@@ -94,7 +94,7 @@ module Vxlan
94
94
  def review_collected_fabric(policy)
95
95
  sources = Dir[File.join(directory, "evidence", "*.json")].sort.map do |path|
96
96
  body = CtxCli::Input.read(path: path)
97
- [ File.expand_path(path), body, JSON.parse(body) ]
97
+ [ File.expand_path(path), body, JSON.parse(body, max_nesting: 128) ]
98
98
  end
99
99
  build_review(sources, policy: policy, standalone: false) unless sources.empty?
100
100
  end
@@ -18,10 +18,10 @@ module Vxlan
18
18
  @fabric_text = @fabric_audit && fabric_text(@fabric_audit, documents)
19
19
  entries = sources.map do |path, body, document, initial|
20
20
  advice = initial || Recommendations.new(document).call
21
- additions = @fabric_audit && @fabric_audit[:device_items][document["device"]["host"]]
21
+ additions = @fabric_audit && @fabric_audit[:device_items][document["target"]["host"]]
22
22
  advice = Recommendations.combine(advice, additions)
23
- device = document.fetch("device")
24
- basename = CLI::Report.device_basename(device)
23
+ device = document.fetch("target")
24
+ basename = CLI::Report.target_basename(device)
25
25
  write_review(document, basename: basename, source: path, source_sha256: Digest::SHA256.hexdigest(body), advice: advice)
26
26
  end
27
27
  finish_review(entries, source_batch: File.dirname(File.dirname(sources.first[0])), standalone: standalone)
@@ -32,7 +32,7 @@ module Vxlan
32
32
  raise ArgumentError, "Invalid report name" unless basename.match?(/\A[A-Za-z0-9_.-]+\z/)
33
33
  advice = report_advice(advice || Recommendations.new(document).call).merge(source: source, source_sha256: source_sha256)
34
34
  advice[:items] = advice[:items].map(&:dup)
35
- entry = { device: advice[:device], counts: advice[:counts], basename: basename, report: "devices/#{basename}.txt", source: source,
35
+ entry = { target: advice[:target], counts: advice[:counts], basename: basename, report: "devices/#{basename}.txt", source: source,
36
36
  source_sha256: source_sha256, script: nil, script_sha256: nil, plans: [], changes: {}, advice: advice }
37
37
  config = Config::Evidence.complete_record(document)
38
38
  config_sha256 = nil
@@ -43,7 +43,7 @@ module Vxlan
43
43
  change = plan.to_h
44
44
  id = [ basename, change["kind"], "vlan#{change['vlan']}", change["direction"], CtxRunner::Fingerprint.call(change)[0, 12] ].compact.join("--")
45
45
  config_sha256 ||= CtxRunner::Fingerprint.call(config)
46
- entry[:changes][id] = { schema_version: CtxRunner::Evidence::VERSION, kind: "change", platform: "cisco_nxos", device: document.fetch("device"), expected_config_sha256: config_sha256,
46
+ entry[:changes][id] = { schema_version: CtxRunner::Evidence::VERSION, kind: "change", platform: "cisco_nxos", target: document.fetch("target"), expected_config_sha256: config_sha256,
47
47
  source: source, source_sha256: source_sha256, change: change, commands: plan.commands, rollback: plan.rollback,
48
48
  scope: item[:scope], title: item[:title], prerequisites: item[:prerequisites], impact: item[:impact], verify: item[:verify] }
49
49
  item[:plan_id] = id
@@ -71,11 +71,11 @@ module Vxlan
71
71
  checkpoint("summary.json", summary) if standalone
72
72
  plan = { schema_version: CtxRunner::Evidence::VERSION, kind: "change_plan", platform: "cisco_nxos", mode: "review", devices: public_entries, audits: entries.map { |entry| entry[:advice] },
73
73
  changes: entries.each_with_object({}) { |entry, all| all.merge!(entry[:changes]) }, fabric: @fabric_audit && @fabric_audit.reject { |key, _| key == :device_items } }
74
- body = JSON.pretty_generate(plan) + "\n"
74
+ body = JSON.pretty_generate(plan, max_nesting: 128) + "\n"
75
75
  summary[:plan_sha256] = Digest::SHA256.hexdigest(body)
76
76
  publish("plan.json", body)
77
77
  publish("plan.json.sha256", summary[:plan_sha256] + "\n")
78
- events = public_entries.map { |entry| JSON.generate(event: "device_reviewed", reviewed_at: Time.now.utc.iso8601(6), host: entry[:device]["host"], source_sha256: entry[:source_sha256], counts: entry[:counts], report: entry[:report], script: entry[:script], executed: false) }
78
+ events = public_entries.map { |entry| JSON.generate(event: "device_reviewed", reviewed_at: Time.now.utc.iso8601(6), host: entry[:target]["host"], source_sha256: entry[:source_sha256], counts: entry[:counts], report: entry[:report], script: entry[:script], executed: false) }
79
79
  publish("events.jsonl", events.join("\n") + (events.empty? ? "" : "\n"))
80
80
  text = summary_text(summary)
81
81
  if standalone
@@ -110,7 +110,7 @@ module Vxlan
110
110
 
111
111
  # 文本按设备、证据、问题、动作顺序组织,完整跨设备证据位于聚合计划。
112
112
  def device_text(entry, document)
113
- advice, device = entry.values_at(:advice, :device)
113
+ advice, device = entry.values_at(:advice, :target)
114
114
  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]}",
115
115
  "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", "" ]
116
116
  lines << "Collection status: #{document['status']}" if document["status"]
@@ -120,7 +120,7 @@ module Vxlan
120
120
  advice[:items].each_with_index do |item, index|
121
121
  lines.concat([ "", "#{index + 1}. #{item[:scope]} — #{item[:title]}", "Status: #{item[:status]}; rule: #{item[:code]}", "Impact: #{item[:impact]}" ])
122
122
  evidence = item[:category] == "fabric" ? Array(item[:evidence]).select { |row| row[:host] == device["host"] } : item[:evidence]
123
- lines.concat([ "Local evidence: ", JSON.pretty_generate(evidence) ])
123
+ lines.concat([ "Local evidence: ", JSON.pretty_generate(evidence, max_nesting: 128) ])
124
124
  lines << "Cross-device comparison: summary.txt; full raw comparison: audits / fabric in plan.json." if item[:category] == "fabric"
125
125
  if item[:plan_id]
126
126
  lines << "Interactive plan item: #{item[:plan_id]}"
@@ -146,8 +146,8 @@ module Vxlan
146
146
  "Read in order: summary.txt -> devices/<device>.txt -> scripts/<device>.cfg -> the selected plan item in plan.json.",
147
147
  "evidence/ stores collection evidence used by the program; routine review does not require opening it. devices/ contains readable diagnostics only.",
148
148
  "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.", "" ]
149
- entries.sort_by { |entry| [ entry[:counts].values.sum.positive? ? 0 : 1, entry[:device]["name"].to_s ] }.each do |entry|
150
- 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)}",
149
+ entries.sort_by { |entry| [ entry[:counts].values.sum.positive? ? 0 : 1, entry[:target]["name"].to_s ] }.each do |entry|
150
+ lines.concat([ "#{entry[:target]['host']} #{entry[:target]['name']} unresolved=#{entry[:counts].fetch('blocked', 0)} candidates=#{entry[:counts].fetch('candidate', 0)} investigations=#{entry[:counts].fetch('investigate', 0)}",
151
151
  " Device report: #{entry[:report]}", " Command file: #{entry[:script] || 'not generated'}; executable plans: #{entry[:plans].size} items" ])
152
152
  end
153
153
  lines.concat([ "", @fabric_text ]) if @fabric_text
@@ -34,8 +34,8 @@ module Vxlan
34
34
  validate(entry[2])
35
35
  entry << Recommendations.new(entry[2]).call
36
36
  end
37
- hosts = documents.map { |entry| entry[2]["device"]["host"] }
38
- names = documents.map { |entry| CLI::Report.device_basename(entry[2].fetch("device")) }
37
+ hosts = documents.map { |entry| entry[2]["target"]["host"] }
38
+ names = documents.map { |entry| CLI::Report.target_basename(entry[2].fetch("target")) }
39
39
  raise ArgumentError, "Duplicate device identity or output name" unless hosts.uniq == hosts && names.uniq == names
40
40
  policy = options[:fabric_policy] && YAML.safe_load(CtxCli::Input.read(path: options[:fabric_policy]), aliases: false)
41
41
  Vxlan::Fabric::Scope.new(policy) if policy
@@ -21,7 +21,7 @@ module Vxlan
21
21
 
22
22
  # 通过调用方持有的连接器顺序采集请求维度。
23
23
  def collect(connector:, target:, fabric_key:, model: nil, dimensions: COMMANDS.keys)
24
- target = CtxRunner::Target.snapshot(target)
24
+ target = CtxRunner::Target.coerce(target).to_h
25
25
  target, fabric_key, dimensions, profile = request(target, fabric_key, dimensions, connector.platform, model)
26
26
 
27
27
  observations = {}
@@ -44,14 +44,18 @@ module Vxlan
44
44
 
45
45
  # 连接前固定请求数据,生命周期入口和借用会话入口共用相同校验。
46
46
  def request(target, fabric_key, dimensions, platform, model)
47
- target = CtxRunner::Target.snapshot(target)
47
+ target = CtxRunner::Target.coerce(target).to_h
48
48
  unless fabric_key.is_a?(String) && !fabric_key.strip.empty? && !/[\x00-\x1f\x7f]/.match?(fabric_key)
49
49
  raise ArgumentError, "Fabric identity must be nonempty text"
50
50
  end
51
51
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions && (dimensions - COMMANDS.keys).empty?
52
52
  raise ArgumentError, "Unknown or duplicate VXLAN dimensions"
53
53
  end
54
- raise ArgumentError, "VXLAN requires Cisco NX-OS" unless platform.to_s == "cisco_nxos"
54
+ platform = ParserRegistry.canonical(platform).to_s
55
+ if target[:platform] && ParserRegistry.canonical(target[:platform]).to_s != platform
56
+ raise ArgumentError, "Target platform differs from the connector"
57
+ end
58
+ target = target.merge(platform: platform)
55
59
 
56
60
  [ CtxParser::Immutable.copy(target), CtxParser::Immutable.copy(fabric_key), dimensions.dup.freeze,
57
61
  ParserRegistry.fetch(platform, model: model) ]
@@ -18,7 +18,7 @@ module Vxlan
18
18
  class Review
19
19
  # 比较范围由业务策略统一验证,审查器只负责组织规则与建议。
20
20
  def initialize(documents, policy)
21
- @documents = documents.to_h { |document| [ document.fetch("device").fetch("host"), document ] }
21
+ @documents = documents.to_h { |document| [ document.fetch("target").fetch("host"), document ] }
22
22
  raise ArgumentError, "Duplicate device identity" unless @documents.size == documents.size
23
23
  @scope = Scope.new(policy)
24
24
  end
@@ -177,7 +177,7 @@ module Vxlan
177
177
  index = (@subnet_indexes[host] ||= SubnetIndex.new(config))
178
178
  excluded = except&.first == host ? except.last : nil
179
179
  index.conflicts(address: change["address"], vrf: change["vrf"], vni: change["vni"], except_interface: excluded).map do |entry|
180
- { host: host, name: @documents[host]["device"]["name"], **entry }
180
+ { host: host, name: @documents[host]["target"]["name"], **entry }
181
181
  end
182
182
  end
183
183
  end
@@ -299,7 +299,7 @@ module Vxlan
299
299
 
300
300
  # 观察附带设备名、地址及本地 VLAN,报告可直接定位配置入口。
301
301
  def observations(rows)
302
- rows.map { |row| { host: row[:host], name: @documents[row[:host]]["device"]["name"], vlan: row[:vlan], value: yield(row) } }
302
+ rows.map { |row| { host: row[:host], name: @documents[row[:host]]["target"]["name"], vlan: row[:vlan], value: yield(row) } }
303
303
  end
304
304
 
305
305
  # 按业务问题计数,不把证据行数当作问题数量。
@@ -48,7 +48,7 @@ module Vxlan
48
48
  items.concat(undefined_acl_items(config))
49
49
  end
50
50
  items.concat(runtime_tasks)
51
- { schema_version: CtxRunner::Evidence::VERSION, kind: "recommendations", review_only: true, health_assessment: "not_performed", device: @document["device"],
51
+ { schema_version: CtxRunner::Evidence::VERSION, kind: "recommendations", review_only: true, health_assessment: "not_performed", target: @document["target"],
52
52
  observed_at: @document.dig("snapshot", "observed_at"), items: items, counts: items.map { |item| item[:status] }.tally }
53
53
  end
54
54
 
@@ -11,7 +11,7 @@ module Vxlan
11
11
  include CtxRunner::Document::Value
12
12
  extend Forwardable
13
13
  def_delegators :@snapshot, :target, :observations, :observed_at, :issues, :collection_status,
14
- :observation, :complete?, :same_target?
14
+ :observation, :records, :complete?, :same_target?
15
15
  attr_reader :fabric_key
16
16
 
17
17
  def initialize(target:, fabric_key:, observations:, observed_at:, issues: [])
data/lib/vxlan/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Vxlan
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -29,40 +29,40 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.4.0
32
+ version: 0.5.0
33
33
  - - "<"
34
34
  - !ruby/object:Gem::Version
35
- version: '0.5'
35
+ version: '0.6'
36
36
  type: :runtime
37
37
  prerelease: false
38
38
  version_requirements: !ruby/object:Gem::Requirement
39
39
  requirements:
40
40
  - - ">="
41
41
  - !ruby/object:Gem::Version
42
- version: 0.4.0
42
+ version: 0.5.0
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
- version: '0.5'
45
+ version: '0.6'
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: ctx_runner
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
50
  - - ">="
51
51
  - !ruby/object:Gem::Version
52
- version: 0.4.0
52
+ version: 0.5.0
53
53
  - - "<"
54
54
  - !ruby/object:Gem::Version
55
- version: '0.5'
55
+ version: '0.6'
56
56
  type: :runtime
57
57
  prerelease: false
58
58
  version_requirements: !ruby/object:Gem::Requirement
59
59
  requirements:
60
60
  - - ">="
61
61
  - !ruby/object:Gem::Version
62
- version: 0.4.0
62
+ version: 0.5.0
63
63
  - - "<"
64
64
  - !ruby/object:Gem::Version
65
- version: '0.5'
65
+ version: '0.6'
66
66
  - !ruby/object:Gem::Dependency
67
67
  name: ctx_parser
68
68
  requirement: !ruby/object:Gem::Requirement
@@ -89,20 +89,20 @@ dependencies:
89
89
  requirements:
90
90
  - - ">="
91
91
  - !ruby/object:Gem::Version
92
- version: 0.4.0
92
+ version: 0.5.0
93
93
  - - "<"
94
94
  - !ruby/object:Gem::Version
95
- version: '0.5'
95
+ version: '0.6'
96
96
  type: :runtime
97
97
  prerelease: false
98
98
  version_requirements: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: 0.4.0
102
+ version: 0.5.0
103
103
  - - "<"
104
104
  - !ruby/object:Gem::Version
105
- version: '0.5'
105
+ version: '0.6'
106
106
  - !ruby/object:Gem::Dependency
107
107
  name: digest
108
108
  requirement: !ruby/object:Gem::Requirement