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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +5 -1
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +23 -18
- data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +8 -8
- data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +2 -2
- data/lib/vxlan/cli/apply.rb +5 -21
- data/lib/vxlan/cli/collect.rb +1 -1
- data/lib/vxlan/cli/options.rb +4 -4
- data/lib/vxlan/cli/report.rb +5 -5
- data/lib/vxlan/cli/review/fabric.rb +7 -7
- data/lib/vxlan/cli/review/report.rb +11 -11
- data/lib/vxlan/cli/review.rb +2 -2
- data/lib/vxlan/collector.rb +7 -3
- data/lib/vxlan/fabric/review.rb +3 -3
- data/lib/vxlan/recommendations.rb +1 -1
- data/lib/vxlan/snapshot.rb +1 -1
- data/lib/vxlan/version.rb +1 -1
- metadata +13 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a0904488ebdedf6f5983003bf51724e3e0c5442b2347fb6433041da8f858394
|
|
4
|
+
data.tar.gz: 8c1b2e0ba07891754cb6074e5da60a2174b6a96508309836bdc4db333ce7414e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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[
|
|
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
|
|
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,
|
|
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, :
|
|
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],
|
|
71
|
-
unparsed_records(number) unless scope[:
|
|
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[:
|
|
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, :
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
140
|
+
return if row[:received_prefixes] && row[:remote_as].nil?
|
|
141
141
|
|
|
142
|
-
row[:
|
|
143
|
-
row[:state] = row[:
|
|
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[:
|
|
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
|
|
164
|
-
return unless observed == (peer[:
|
|
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
|
|
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, :
|
|
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,
|
|
59
|
-
assign_field(row, :
|
|
60
|
-
assign_field(row, :
|
|
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[
|
|
84
|
+
next true if @field_conflicts.fetch(entry, {}).keys.intersect?(%i[remote_name local_interface remote_interface])
|
|
85
85
|
|
|
86
|
-
entry[:
|
|
87
|
-
if entry.values_at(:
|
|
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
|
|
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
|
|
14
|
+
FIELDS = %i[interface peer_address state learn_type uptime router_mac].freeze
|
|
15
15
|
|
|
16
16
|
private
|
|
17
17
|
|
data/lib/vxlan/cli/apply.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
|
data/lib/vxlan/cli/collect.rb
CHANGED
|
@@ -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.
|
|
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 来源二选一;选中地址的所有冲突记录在连接前拒绝。
|
data/lib/vxlan/cli/options.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
data/lib/vxlan/cli/report.rb
CHANGED
|
@@ -28,20 +28,20 @@ module Vxlan
|
|
|
28
28
|
def write_execution(state) = @report.write_execution(state)
|
|
29
29
|
|
|
30
30
|
# 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
|
|
31
|
-
def self.
|
|
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(:
|
|
38
|
-
name = self.class.
|
|
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['
|
|
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("
|
|
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, '
|
|
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], "
|
|
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], "
|
|
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], '
|
|
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["
|
|
21
|
+
additions = @fabric_audit && @fabric_audit[:device_items][document["target"]["host"]]
|
|
22
22
|
advice = Recommendations.combine(advice, additions)
|
|
23
|
-
device = document.fetch("
|
|
24
|
-
basename = CLI::Report.
|
|
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 = {
|
|
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",
|
|
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[:
|
|
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, :
|
|
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[:
|
|
150
|
-
lines.concat([ "#{entry[:
|
|
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
|
data/lib/vxlan/cli/review.rb
CHANGED
|
@@ -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]["
|
|
38
|
-
names = documents.map { |entry| CLI::Report.
|
|
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
|
data/lib/vxlan/collector.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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) ]
|
data/lib/vxlan/fabric/review.rb
CHANGED
|
@@ -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("
|
|
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]["
|
|
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]]["
|
|
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",
|
|
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
|
|
data/lib/vxlan/snapshot.rb
CHANGED
|
@@ -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
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.
|
|
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.
|
|
32
|
+
version: 0.5.0
|
|
33
33
|
- - "<"
|
|
34
34
|
- !ruby/object:Gem::Version
|
|
35
|
-
version: '0.
|
|
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.
|
|
42
|
+
version: 0.5.0
|
|
43
43
|
- - "<"
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
|
-
version: '0.
|
|
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.
|
|
52
|
+
version: 0.5.0
|
|
53
53
|
- - "<"
|
|
54
54
|
- !ruby/object:Gem::Version
|
|
55
|
-
version: '0.
|
|
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.
|
|
62
|
+
version: 0.5.0
|
|
63
63
|
- - "<"
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
|
-
version: '0.
|
|
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.
|
|
92
|
+
version: 0.5.0
|
|
93
93
|
- - "<"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: '0.
|
|
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.
|
|
102
|
+
version: 0.5.0
|
|
103
103
|
- - "<"
|
|
104
104
|
- !ruby/object:Gem::Version
|
|
105
|
-
version: '0.
|
|
105
|
+
version: '0.6'
|
|
106
106
|
- !ruby/object:Gem::Dependency
|
|
107
107
|
name: digest
|
|
108
108
|
requirement: !ruby/object:Gem::Requirement
|