vxlan 0.5.1 → 0.7.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: 9cd74f1778008071770a76681129f64d7fc8d803ca27336fbac450147e33cd01
4
- data.tar.gz: d4ba8a592e430b4e8c2a81e2218fe65f70c617d5fce7f60744d893a734a80b53
3
+ metadata.gz: c96781edce4b5dcc8990ced65d1287cfb86ee2c431ee3c0b0714365513955d75
4
+ data.tar.gz: '0187135b1f3cff8988732b9fb3ed58633e780b3538b569b1ea611f87d09af08a'
5
5
  SHA512:
6
- metadata.gz: e45e95ed6294d0f1ae0014d60ef13b1f8fad1f60a5832815b46966e1159d35b63159700410afe0f1191cb05155806b0ee5ad73050f27019e76646dfc462a5d63
7
- data.tar.gz: 84d292f0dc18f67f949a73cd545293556d5e24722d995a9c161eb5a2889a89bc5cbf4afce379da9660c8bf803741a74b6b5e1ec63d47e9adac2d6214bfc65cfc
6
+ metadata.gz: a5386c55fa4f48e6e121ac63dbca3c8148754857df0fa68841d345301aadf6f1678dc682d86713030110203de4fc8f7f22ed2d2fbb44135bebb9e39157df6386
7
+ data.tar.gz: 5c1434eb7eac8ea5649e136523020579c1abf6d0ae57309ee20335989c164922789e70ca62322d55de1775752eb5902b805c50ef80d7cf48151e1968a449d3fe
data/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ Changes to the public contract are recorded here. Versions follow Semantic Versi
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.7.0 (2026-10-29)
8
+
9
+ - Compose the shared snapshot contract and remove the synthetic collection observation. Whitelist public target identities and validate timestamps and dimensions.
10
+ - Normalize OSPF process_id, router_id, neighbor_address, symbolic state and neighbor_role while retaining priority and uptime.
11
+ - Delegate interactive apply to ctx_cli, resolving credentials through net-connector, including empty overrides and platform enable passwords.
12
+ - Use schema 3 reports and named checksummed plans. Older files must be regenerated.
13
+
14
+ ## 0.6.0 (2026-10-29)
15
+
16
+ - Require RuntimeParser platform at construction and add parse_many. Rename Snapshot Ruby members to observations/observation while retaining dimensions in persisted reports. Use Catalog and shared Credentials in CLI; reject selected duplicate addresses across platforms before connecting.
17
+
7
18
  ## 0.5.1 (2026-10-24)
8
19
 
9
20
  - Validate EVPN configured-peer totals within each VRF and address-family block, retain administrative shutdown and unknown AS identities, and preserve state-valued prefix totals.
data/README-zh.md CHANGED
@@ -45,7 +45,7 @@ output = <<~CLI
45
45
  Neighbor ID Pri State Up Time Address Interface
46
46
  192.0.2.2 1 FULL/ - 1d 192.0.2.3 Eth1/1
47
47
  CLI
48
- observation = Vxlan::RuntimeParser.new.parse(:ospf_neighbors, output, complete: true)
48
+ observation = Vxlan::RuntimeParser.new(platform: :cisco_nxos).parse(:ospf_neighbors, output, complete: true)
49
49
  puts "OSPF parse status: #{observation.status}"
50
50
  puts "Neighbors: #{observation.records.size}"
51
51
  observation.records.each { |record| p record }
@@ -136,3 +136,10 @@ MIT,见 [LICENSE](LICENSE)。
136
136
  `vxlan collect` 默认输出逐设备诊断,使用 `--scripts` 可选导出配置建议;`vxlan review --batch DIR` 可离线补生成既有报告的候选配置、条件回退和只读验收命令。导出不会执行变更;独立 `apply --plan FILE --item ID` 必须在真实终端明确确认,配置漂移或失败即停止。详见[修复建议说明](docs/remediation.md)。 业务分层、会话所有权与执行边界见[架构说明](docs/architecture-zh.md)。
137
137
 
138
138
  报告阅读顺序与 net-connector topology 一致:`summary.txt` → `devices/*.txt` → `scripts/*.cfg`。不生成 Markdown 报告。所有可执行增量汇总于带 SHA256 校验的 `plan.json`,通过 `vxlan apply --plan plan.json --item ID` 选择一项并交互确认。
139
+
140
+
141
+ ## API 迁移
142
+
143
+ `RuntimeParser.new(platform:, model: nil)` 固定解析平台;`parse(name, text, complete: false, command: nil)` 与 `parse_many(names, text, complete: false, command: nil)` 返回领域 Observation。`Snapshot.new(observations: ...)`、`#observations`、`#observation(name)` 是统一 Ruby API,旧 `dimensions` 成员及 Wireless `dimension` 入口已移除。已知未请求维度返回 `not_requested`,未知名称抛出 `ArgumentError`;持久化 JSON 仍保留 `dimensions` 字段。
144
+
145
+ 动态清单使用 `Netdisco::Catalog`;选中地址的任何重复记录(包括另一平台)都会阻止连接。范围外冲突只输出安全诊断。平台 `NC_<PLATFORM>_ENABLE_PASSWORD` 优先于 `NC_ENABLE_PASSWORD`。
data/README.md CHANGED
@@ -45,7 +45,7 @@ output = <<~CLI
45
45
  Neighbor ID Pri State Up Time Address Interface
46
46
  192.0.2.2 1 FULL/ - 1d 192.0.2.3 Eth1/1
47
47
  CLI
48
- observation = Vxlan::RuntimeParser.new.parse(:ospf_neighbors, output, complete: true)
48
+ observation = Vxlan::RuntimeParser.new(platform: :cisco_nxos).parse(:ospf_neighbors, output, complete: true)
49
49
  puts "OSPF parse status: #{observation.status}"
50
50
  puts "Neighbors: #{observation.records.size}"
51
51
  observation.records.each { |record| p record }
@@ -142,3 +142,12 @@ Review output follows the net-connector topology layout: `summary.txt`, `summary
142
142
  Apply returns `2` on failure and `130` on interruption even if stderr cannot accept diagnostics. Existing execution journals retain uncertain device effects; commands are never replayed automatically. Unexpected positional arguments are rejected, including after `--help`.
143
143
 
144
144
  Offline review requires `snapshot.target.host` to match the canonical device host. Reports without target identity must be collected again; the unbound legacy format is no longer accepted.
145
+
146
+
147
+ ## API migration
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.
150
+
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
+
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)。
data/examples/offline.rb CHANGED
@@ -9,7 +9,7 @@ output = <<~CLI
9
9
  Neighbor ID Pri State Up Time Address Interface
10
10
  192.0.2.2 1 FULL/ - 1d 192.0.2.3 Eth1/1
11
11
  CLI
12
- observation = Vxlan::RuntimeParser.new.parse(:ospf_neighbors, output, complete: true)
12
+ observation = Vxlan::RuntimeParser.new(platform: :cisco_nxos).parse(:ospf_neighbors, output, complete: true)
13
13
  puts "OSPF parse status: #{observation.status}"
14
14
  puts "Neighbors: #{observation.records.size}"
15
15
  observation.records.each { |record| p record }
@@ -8,11 +8,11 @@ module Vxlan
8
8
  # 逐进程、VRF 独立核对 OSPF 邻居总数。
9
9
  class ShowIpOspfNeighbors < Parser
10
10
  EMPTY = "(?:OSPF )?neighbors"
11
- IDENTITY = %i[process vrf neighbor_id interface].freeze
11
+ IDENTITY = %i[process_id vrf router_id interface].freeze
12
12
  BOUNDARY = /^\s*OSPF Process ID /
13
13
  HEADER = /\ANeighbor ID\s+Pri\s+State\s+Up Time\s+Address\s+Interface\z/i
14
14
  ROW = /\A([\d.]+)\s+(\d+)\s+(\S+(?:\/\s*\S+)?)\s+(\S+)\s+([\d.]+)\s+(\S+)\z/
15
- FIELDS = %i[neighbor_id priority state uptime address interface].freeze
15
+ FIELDS = %i[router_id priority state uptime neighbor_address interface].freeze
16
16
 
17
17
  private
18
18
 
@@ -23,18 +23,20 @@ module Vxlan
23
23
  if HEADER.match?(line)
24
24
  @header = true
25
25
  elsif (match = /\AOSPF Process ID (\S+) VRF (\S+)\z/i.match(line))
26
- scope = { process: match[1], vrf: match[2] }
26
+ scope = { process_id: match[1], vrf: match[2] }
27
27
  elsif (match = /\ATotal number of neighbors:\s*(\d+)\z/i.match(line))
28
28
  totals << match[1].to_i
29
29
  elsif (match = ROW.match(line))
30
30
  row = FIELDS.zip(match.captures).to_h.merge(scope)
31
31
  row[:priority] = row[:priority].to_i
32
- unless ip_address?(row[:neighbor_id]) && ip_address?(row[:address]) && (0..255).cover?(row[:priority]) &&
32
+ unless ip_address?(row[:router_id]) && ip_address?(row[:neighbor_address]) && (0..255).cover?(row[:priority]) &&
33
33
  row[:state].match?(/\A(?:DOWN|ATTEMPT|INIT|2WAY|EXSTART|EXCHANGE|LOADING|FULL)\/(?:\s*(?:DR|BDR|DROTHER|-))\z/i)
34
34
  unparsed_records(number)
35
35
  next
36
36
  end
37
- @rows << row.merge(state: row[:state].downcase.delete(" "))
37
+ state, role = row[:state].downcase.delete(" ").split("/", 2)
38
+ @rows << row.merge(state: state == "2way" ? :two_way : state.to_sym,
39
+ neighbor_role: role == "-" ? :unknown : role.to_sym)
38
40
  else
39
41
  unparsed_records(number)
40
42
  end
@@ -1,14 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ctx_cli/command"
4
- require "ctx_cli/terminal"
5
- require "ctx_cli/input"
6
- require "ctx_cli/output"
7
- require "json"
3
+ require "ctx_cli/apply"
8
4
  require "ctx_runner/document"
9
- require "digest"
5
+ require "ctx_runner/evidence"
10
6
  require "ctx_runner/execution"
11
- require "ipaddr"
12
7
  require "vxlan"
13
8
  require_relative "report"
14
9
  require_relative "../change_plan"
@@ -35,100 +30,44 @@ module Vxlan
35
30
  # 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
36
31
  def write(command)
37
32
  result = @native.execute_command(command.strip, output_sensitive: true)
38
- raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one?
33
+ raise IOError, "Device command is unconfirmed" unless result.success? && result.steps.one? && result.steps.first.command.text == command.strip
39
34
  true
40
35
  end
41
36
  end
42
37
 
43
- # 交互式单设备下发:展示、确认、重新读取、记录意图、执行、回读,禁止无人值守确认。
38
+ # 领域适配器只维护计划类型、凭据来源与独占设备会话。
44
39
  class Apply
45
- # 注入连接工厂仅用于宿主集成和离线失败测试。
46
40
  def initialize(env: ENV, out: $stdout, error: $stderr, input: $stdin, connector_factory: nil, discovery: {})
47
- @env, @out, @error, @input, @factory = env, out, error, input, connector_factory
48
- @discovery = discovery
49
- @output = CtxCli::Output.new(out: out, error: error)
41
+ @env, @factory, @discovery = env, connector_factory, discovery
42
+ @entry = CtxCli::Apply.new(adapter: self, execution: CtxRunner::Execution, name: "vxlan", out: out, error: error, input: input)
50
43
  end
51
44
 
52
- # 没有 --yes 或自动回退;零退出码只确认配置回读,不声称业务已验收。
53
- def run(argv)
54
- @store, @execution = nil, nil
55
- command = CtxCli::Command.new(banner: "Usage: vxlan apply --plan FILE --item ID [--env-file FILE] (requires explicit confirmation in an interactive terminal)",
56
- defaults: { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }, out: @out) do |p, options|
57
- p.on("--plan FILE", "Batch plan.json; requires a SHA256 checksum file in the same directory") { |value| options[:plan] = value }
58
- p.on("--item ID", "Explicitly select one change from the batch plan; bulk execution is prohibited") { |value| options[:item] = value }
59
- p.on("--env-file FILE", "Explicit credentials file; process environment takes precedence") { |value| options[:env_file] = value }
60
- p.on("--directory DIR", "Private execution log directory") { |value| options[:directory] = value }
61
- p.on("--port N", Integer, "SSH port, default 22") { |value| options[:port] = value }
62
- p.on("-h", "--help", "Show help without connecting to devices") { options[:help] = true }
63
- end
64
- options = command.parse(argv)
65
- return 0 if command.informational?
66
- terminal = CtxCli::Terminal.new(input: @input, out: @out)
67
- terminal.require_tty!(@error)
68
- raise ArgumentError, "A single plan is required" unless options[:plan] && options[:port].between?(1, 65_535)
69
- body = CtxCli::Input.read(path: options[:plan])
70
- hash = Digest::SHA256.hexdigest(body)
71
- plan = CtxParser::Immutable.copy(select_plan(JSON.parse(body), options, hash))
72
- change = validate_plan(plan)
73
- host = plan.fetch("device").fetch("host")
74
- @out.puts "Target: #{plan['device']['name']}(#{host}); #{plan['scope']}; #{plan['title']}"
75
- @out.puts "Impact: #{plan['impact']}"
76
- @out.puts Array(plan["prerequisites"]).map { |value| "Prerequisite: #{value}" }
77
- @out.puts "Plan SHA256: #{hash}\nProposed commands:\n#{change.commands.join("\n")}\nConditional rollback (not executed automatically):\n#{change.rollback.join("\n")}"
78
- token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
79
- confirmed = terminal.confirm(token: token, prompt: "After confirming the service policy and maintenance window, enter #{token}; any other input cancels: ")
80
- return @out.puts("Cancelled; no device connection opened.") || 1 unless confirmed
81
- settings = credentials(options)
82
- @store = Report.new(options[:directory])
83
- @execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
84
- @execution.run { |operation| with_session(plan["device"], settings, &operation) }
85
- @out.puts "Configuration readback confirmed; service acceptance is still required. Configuration was not saved to startup configuration. Log: #{@store.directory}/execution.json"
86
- 0
87
- rescue Interrupt
88
- report_journal_failure
89
- @output.diagnostic "Operation interrupted; #{@execution&.started? ? 'Device effects may be incomplete; read back and verify before proceeding. Do not replay commands.' : 'No service configuration was sent.'}"
90
- 130
91
- rescue StandardError, LoadError => error
92
- report_journal_failure
93
- @output.diagnostic "Apply stopped (#{error.class.name}); #{@execution&.started? ? 'Effects are not fully confirmed; read back first. No automatic replay or rollback.' : 'No service configuration was sent; check the terminal, plan, credentials and current configuration for consistency.'}"
94
- 2
95
- end
45
+ def run(argv) = @entry.run(argv)
96
46
 
97
- private
98
-
99
- # 聚合文件必须校验哈希并选中唯一动作;绝不隐式选择第一项或循环执行。
100
- def select_plan(document, options, hash)
101
- id = options[:item]
102
- unless document.is_a?(Hash) && document["schema_version"] == 2 && document["platform"] == "cisco_nxos" && document["changes"].is_a?(Hash) &&
103
- id.is_a?(String) && id.match?(/\A[A-Za-z0-9_.-]+\z/) && document["changes"].key?(id)
104
- raise ArgumentError, "A batch plan and valid --item are required"
105
- end
106
- raise ArgumentError, "Plan checksum verification failed" unless CtxCli::Input.read(path: "#{options[:plan]}.sha256", max_bytes: 128).strip == hash
107
- document["changes"].fetch(id)
47
+ def select_plan(document, item:)
48
+ CtxRunner::Evidence.plan(document, item: item, platform: "cisco_nxos")
108
49
  end
109
50
 
110
- # 不信任文件中的命令,重新从有限动作渲染并逐字核对,拒绝额外命令。
51
+ # 从类型化增量重新生成命令,拒绝修改后的任意脚本。
111
52
  def validate_plan(plan)
112
- raise ArgumentError, "Plan type mismatch" unless plan.is_a?(Hash) && plan["schema_version"] == 1 && plan["platform"] == "cisco_nxos"
113
- host = plan.fetch("device").fetch("host")
114
- raise ArgumentError, "Invalid target address" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
115
- raise ArgumentError, "Invalid configuration fingerprint" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
116
53
  change = ChangePlan.build(plan.fetch("change"))
117
- raise ArgumentError, "Plan commands were modified" unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
54
+ unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
55
+ raise ArgumentError, "Plan commands were modified"
56
+ end
118
57
  change
119
58
  end
120
59
 
121
- # 共用分层环境加载器,显式文件最高、进程环境优先;凭据不进入计划或日志。
122
60
  def credentials(options)
123
61
  require "net/connector/config"
62
+ require "net/connector/credentials"
124
63
  env = Net::Connector::Config.new(name: "vxlan", env: @env, env_file: options[:env_file], **@discovery).load_environment.environment
125
- username = env["NC_CISCO_NXOS_USERNAME"] || env["NC_DEVICE_USERNAME"]
126
- raise ArgumentError, "Device username is missing" unless username.is_a?(String) && !username.strip.empty?
127
- { username: username, password: env["NC_CISCO_NXOS_PASSWORD"] || env["NC_DEVICE_PASSWORD"], enable_password: env["NC_ENABLE_PASSWORD"],
128
- port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15 }
64
+ credentials = Net::Connector::Credentials.new(env: env).resolve(platform: :cisco_nxos)
65
+ raise ArgumentError, "Device username is missing" unless credentials
66
+ credentials.merge(port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15)
129
67
  end
130
68
 
131
- # 原生会话只打开一次,读写处于同一操作租约中。
69
+ def report(options) = Report.new(options.fetch(:directory))
70
+
132
71
  def with_session(device, settings, &operation)
133
72
  return @factory.call(device, settings, &operation) if @factory
134
73
  require "net/connector"
@@ -137,13 +76,9 @@ module Vxlan
137
76
  end
138
77
  end
139
78
 
140
- # 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
141
- def report_journal_failure
142
- return unless @execution&.journal_failed?
143
- @output.diagnostic "Execution log finalization is unconfirmed; existing checkpoints retained. Do not replay configuration based on this state."
144
- rescue Interrupt
145
- nil
146
- end
79
+ def inspect = "#<#{self.class.name}>"
80
+ alias to_s inspect
81
+ def pretty_print(printer) = printer.text(inspect)
147
82
  end
148
83
  end
149
84
  end
@@ -34,17 +34,27 @@ 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.18.0; add it to the host Gemfile or run gem install net-connector", cause: nil
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
38
38
  end
39
39
 
40
- # YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
40
+ # YAML 静态设备或显式 Netdisco 来源二选一;选中地址的所有冲突记录在连接前拒绝。
41
41
  def inventory(options, progress:)
42
42
  values = options.values
43
+ selection = Net::Connector::Inventory::Filters.selection(values)
43
44
  rows = if options.dynamic_inventory?
44
45
  progress.reading_inventory
45
46
  settings = options.netdisco_settings
46
- Net::Connector::Netdisco::Fleet.new(settings: settings).devices.filter_map do |device|
47
- next unless device.ready? && device.vendor == :cisco_nxos
47
+ policy = settings.snapshot(mode: :inventory)
48
+ devices = Net::Connector::Netdisco::Catalog.new(settings: settings).devices(policy: policy)
49
+ devices.filter_map(&:issue).tally.each { |code, count| progress.notice("Inventory: #{code}=#{count}") }
50
+ devices.filter_map do |device|
51
+ next unless device.vendor == :cisco_nxos
52
+ platform = "cisco_nxos"
53
+ if device.issue == :duplicate_host && policy.rules.selected?(device.host, device.vendor, name: device.name, model: device.model) &&
54
+ selection.selected?(device.host, platform, name: device.name || device.host, model: device.model)
55
+ raise CtxCli::Collection::Error, "Duplicate management address in selected inventory"
56
+ end
57
+ next unless device.ready?
48
58
  { "host" => device.host, "name" => device.name || device.host, "model" => device.model }
49
59
  end
50
60
  else
@@ -52,7 +62,6 @@ module Vxlan
52
62
  end
53
63
  targets = options.normalize_devices(rows)
54
64
  total = targets.size
55
- selection = Net::Connector::Inventory::Filters.selection(values)
56
65
  selected = targets.select do |device|
57
66
  selection.selected?(device.fetch("host"), "cisco_nxos", name: device["name"], model: device["model"])
58
67
  end
@@ -86,16 +95,10 @@ module Vxlan
86
95
 
87
96
  # 缺失用户名时不尝试连接;密码允许省略以支持明确的 SSH key/agent。
88
97
  def credentials!(options)
89
- username = secret(options, "NC_CISCO_NXOS_USERNAME") || secret(options, "NC_DEVICE_USERNAME")
90
- raise CtxCli::Collection::Error, "Set NC_DEVICE_USERNAME or NC_CISCO_NXOS_USERNAME; provide a password through the environment or use an SSH key/agent" unless username
91
- options.values.fetch("ssh").transform_keys(&:to_sym).merge(username: username,
92
- password: secret(options, "NC_CISCO_NXOS_PASSWORD") || secret(options, "NC_DEVICE_PASSWORD"), enable_password: secret(options, "NC_ENABLE_PASSWORD"))
93
- end
94
-
95
- # 空白环境值视为缺失,不修改或回显凭据。
96
- def secret(options, name)
97
- value = options.environment[name]
98
- value unless value.nil? || value.strip.empty?
98
+ require "net/connector/credentials"
99
+ credentials = Net::Connector::Credentials.new(env: options.environment).resolve(platform: :cisco_nxos)
100
+ raise CtxCli::Collection::Error, "Set NC_DEVICE_USERNAME or platform NC_*_USERNAME; provide a password through the environment or use an SSH key/agent" unless credentials
101
+ options.values.fetch("ssh").transform_keys(&:to_sym).merge(credentials)
99
102
  end
100
103
 
101
104
  # 每个任务使用独立 Collector 和会话;公共执行器负责有界并发和线程回收。
@@ -114,7 +117,7 @@ module Vxlan
114
117
  snapshot
115
118
  end
116
119
 
117
- private :load_network, :credentials!, :secret
120
+ private :load_network, :credentials!
118
121
  end
119
122
  end
120
123
  end
@@ -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.18.0"
9
+ gem "net-connector", "~> 0.20.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.18.0; reading .env also requires dotenv", cause: nil
70
+ raise LoadError, "Collection configuration requires net-connector >= 0.20.0; reading .env also requires dotenv", cause: nil
71
71
  end
72
72
 
73
73
  # 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
@@ -37,7 +37,7 @@ module Vxlan
37
37
  observation = if options[:dimension] == :configuration
38
38
  Vxlan::Config.new(model: options[:model]).parse(body, complete: options[:complete])
39
39
  else
40
- Vxlan::RuntimeParser.new(model: options[:model]).parse(options[:dimension], body, complete: options[:complete])
40
+ Vxlan::RuntimeParser.new(platform: :cisco_nxos, model: options[:model]).parse(options[:dimension], body, complete: options[:complete])
41
41
  end
42
42
  if options[:json]
43
43
  @output.json(observation.to_h)
@@ -4,6 +4,7 @@ require "json"
4
4
  require "time"
5
5
  require "ctx_runner/report"
6
6
  require "ctx_runner/document"
7
+ require "ctx_runner/evidence"
7
8
  require_relative "review/report"
8
9
  require_relative "review/fabric"
9
10
 
@@ -37,7 +38,7 @@ module Vxlan
37
38
  name = self.class.device_basename(device)
38
39
  # 保存状态属于批次宿主;不能让正在发布的文件预先声明自身已保存。
39
40
  body = result.reject { |key, _| key == :report_saved }.merge(snapshot: snapshot && snapshot.to_h)
40
- document = CtxRunner::Document.call(body)
41
+ document = CtxRunner::Evidence.wrap(body, kind: "collection")
41
42
  advice = report_advice(Recommendations.new(document).call)
42
43
  document = document.merge("recommendations" => advice)
43
44
  json = JSON.pretty_generate(document) + "\n"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ctx_runner/evidence"
3
4
  require "digest"
4
5
  require "ctx_runner/fingerprint"
5
6
  require_relative "../../recommendations"
@@ -42,7 +43,7 @@ module Vxlan
42
43
  change = plan.to_h
43
44
  id = [ basename, change["kind"], "vlan#{change['vlan']}", change["direction"], CtxRunner::Fingerprint.call(change)[0, 12] ].compact.join("--")
44
45
  config_sha256 ||= CtxRunner::Fingerprint.call(config)
45
- entry[:changes][id] = { schema_version: 1, 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", device: document.fetch("device"), expected_config_sha256: config_sha256,
46
47
  source: source, source_sha256: source_sha256, change: change, commands: plan.commands, rollback: plan.rollback,
47
48
  scope: item[:scope], title: item[:title], prerequisites: item[:prerequisites], impact: item[:impact], verify: item[:verify] }
48
49
  item[:plan_id] = id
@@ -65,10 +66,10 @@ module Vxlan
65
66
  def finish_review(entries, source_batch:, standalone: true)
66
67
  counts = entries.each_with_object(Hash.new(0)) { |entry, total| entry[:counts].each { |key, count| total[key] += count } }
67
68
  public_entries = entries.map { |entry| entry.reject { |key, _| %i[advice changes].include?(key) } }
68
- summary = { layout_version: 3, mode: "review", status: "incomplete", review_only: true, devices: entries.size, counts: counts, source_batch: source_batch,
69
+ summary = { schema_version: CtxRunner::Evidence::VERSION, kind: "review", layout_version: 3, mode: "review", status: "incomplete", review_only: true, devices: entries.size, counts: counts, source_batch: source_batch,
69
70
  reports: public_entries, plan: "plan.json", plan_sha256: nil, fabric_findings: @fabric_audit ? @fabric_audit[:findings].size : 0 }
70
71
  checkpoint("summary.json", summary) if standalone
71
- plan = { schema_version: 2, platform: "cisco_nxos", mode: "review", devices: public_entries, audits: entries.map { |entry| entry[:advice] },
72
+ plan = { schema_version: CtxRunner::Evidence::VERSION, kind: "change_plan", platform: "cisco_nxos", mode: "review", devices: public_entries, audits: entries.map { |entry| entry[:advice] },
72
73
  changes: entries.each_with_object({}) { |entry, all| all.merge!(entry[:changes]) }, fabric: @fabric_audit && @fabric_audit.reject { |key, _| key == :device_items } }
73
74
  body = JSON.pretty_generate(plan) + "\n"
74
75
  summary[:plan_sha256] = Digest::SHA256.hexdigest(body)
@@ -113,7 +114,7 @@ module Vxlan
113
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]}",
114
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", "" ]
115
116
  lines << "Collection status: #{document['status']}" if document["status"]
116
- configuration_time = document.dig("snapshot", "dimensions", "configuration", "evidence", 0, "observed_at")
117
+ configuration_time = document.dig("snapshot", "observations", "configuration", "evidence", 0, "observed_at")
117
118
  lines << "Configuration evidence time: #{configuration_time}" if configuration_time
118
119
  lines << "No current rules matched; this does not establish network health." if advice[:items].empty?
119
120
  advice[:items].each_with_index do |item, index|
@@ -53,7 +53,7 @@ module Vxlan
53
53
 
54
54
  # 先校验公共证据外壳,再验证领域记录。
55
55
  def validate(document)
56
- CtxRunner::Evidence.validate(document) { |record, dimension| validate_record(record, dimension) }
56
+ CtxRunner::Evidence.validate(document, names: CiscoNxos::Profile::COMMANDS.keys) { |record, dimension| validate_record(record, dimension) }
57
57
  end
58
58
 
59
59
  # 在输出目录创建前验证所有会参与建议生成的嵌套字段。
@@ -21,6 +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
25
  target, fabric_key, dimensions, profile = request(target, fabric_key, dimensions, connector.platform, model)
25
26
 
26
27
  observations = {}
@@ -29,9 +30,8 @@ module Vxlan
29
30
  if collection.failure
30
31
  code = collection.failure.fetch(:code)
31
32
  dimensions.each { |name| observations[name] ||= failure(name, code) }
32
- observations[:collection] = failure(:collection, code)
33
33
  end
34
- snapshot(target, fabric_key, observations)
34
+ snapshot(target, fabric_key, observations, issues: collection.failure ? [ collection.failure ] : [])
35
35
  end
36
36
 
37
37
  # 借用已打开的会话;调用方负责连接和关闭,本方法不重连、不提权。
@@ -44,7 +44,10 @@ module Vxlan
44
44
 
45
45
  # 连接前固定请求数据,生命周期入口和借用会话入口共用相同校验。
46
46
  def request(target, fabric_key, dimensions, platform, model)
47
- raise ArgumentError, "Target must be a hash" unless target.is_a?(Hash)
47
+ target = CtxRunner::Target.snapshot(target)
48
+ unless fabric_key.is_a?(String) && !fabric_key.strip.empty? && !/[\x00-\x1f\x7f]/.match?(fabric_key)
49
+ raise ArgumentError, "Fabric identity must be nonempty text"
50
+ end
48
51
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions && (dimensions - COMMANDS.keys).empty?
49
52
  raise ArgumentError, "Unknown or duplicate VXLAN dimensions"
50
53
  end
@@ -60,7 +63,6 @@ module Vxlan
60
63
  reader = CtxDriver::Reader.new(session: session, clock: @clock)
61
64
  if (code = prepare_terminal(reader))
62
65
  dimensions.each { |name| observations[name] = failure(name, code) }
63
- observations[:collection] = failure(:collection, code)
64
66
  return observations
65
67
  end
66
68
  ordered = dimensions.include?(:configuration) ? [ :configuration, *dimensions.reject { |name| name == :configuration } ] : dimensions
@@ -76,16 +78,8 @@ module Vxlan
76
78
  end
77
79
 
78
80
  # 汇总证据完整性,不将查询成功解释为 Fabric 健康。
79
- def snapshot(target, fabric_key, observations)
80
- status = if observations.values.all?(&:complete?)
81
- :complete
82
- elsif observations.values.any? { |value| %i[complete partial].include?(value.status) }
83
- :partial
84
- else
85
- :failed
86
- end
87
- observations[:collection] ||= Observation.new(name: :collection, status: status, completed_at: @clock.call)
88
- Snapshot.new(target: target, fabric_key: fabric_key, dimensions: observations, observed_at: @clock.call)
81
+ def snapshot(target, fabric_key, observations, issues: [])
82
+ Snapshot.new(target: target, fabric_key: fabric_key, observations: observations, observed_at: @clock.call, issues: issues)
89
83
  end
90
84
 
91
85
  # 采集设备回显前,禁用分页并归一化终端宽度。
@@ -6,7 +6,7 @@ module Vxlan
6
6
  module Evidence
7
7
  # 外层采集成功不能覆盖记录层的部分解析或多份配置歧义。
8
8
  def self.complete_record(document)
9
- observation = document&.dig("snapshot", "dimensions", "configuration")
9
+ observation = document&.dig("snapshot", "observations", "configuration")
10
10
  return unless observation && observation["status"] == "complete"
11
11
  records = observation["records"]
12
12
  return unless records.is_a?(Array) && records.one?
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ctx_runner/evidence"
3
4
  require "ipaddr"
4
5
  require "json"
5
6
  require "digest"
@@ -43,7 +44,7 @@ module Vxlan
43
44
  @role = nil
44
45
  review_subnets(fabric, configs)
45
46
  end
46
- { schema_version: 2, scope: "same_role_same_deployed_vni", inventory: inventory, findings: @findings, device_items: @items.to_h, coverage: @coverage }
47
+ { schema_version: CtxRunner::Evidence::VERSION, kind: "fabric_review", scope: "same_role_same_deployed_vni", inventory: inventory, findings: @findings, device_items: @items.to_h, coverage: @coverage }
47
48
  end
48
49
 
49
50
  private
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ctx_runner/evidence"
3
4
  require_relative "cisco_nxos/profile"
4
5
  require_relative "config/evidence"
5
6
 
@@ -35,7 +36,7 @@ module Vxlan
35
36
  # 输入为报告 JSON 的字符串键结构,离线与实时报告共用此边界。
36
37
  def initialize(document)
37
38
  @document = document
38
- @dimensions = document.dig("snapshot", "dimensions") || {}
39
+ @dimensions = document.dig("snapshot", "observations") || {}
39
40
  end
40
41
 
41
42
  # 建议与采集完整性分开;没有命中规则并不代表网络健康。
@@ -47,7 +48,7 @@ module Vxlan
47
48
  items.concat(undefined_acl_items(config))
48
49
  end
49
50
  items.concat(runtime_tasks)
50
- { schema_version: 1, 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", device: @document["device"],
51
52
  observed_at: @document.dig("snapshot", "observed_at"), items: items, counts: items.map { |item| item[:status] }.tally }
52
53
  end
53
54
 
@@ -7,16 +7,24 @@ module Vxlan
7
7
  # 将纯平台解析事实封装为对应 VXLAN 维度观测。
8
8
  class RuntimeParser
9
9
  # 按请求的平台和型号选择运行解析行为。
10
- def initialize(platform: :cisco_nxos, model: nil)
10
+ def initialize(platform:, model: nil)
11
11
  @profile = ParserRegistry.fetch(platform, model: model)
12
12
  end
13
13
 
14
14
  # 拒绝配置维度,仅解析所选 VXLAN 运行查询。
15
- def parse(name, text, complete: false)
16
- raise ArgumentError, "Unsupported runtime dimension" if name == :configuration || !@profile.commands.key?(name)
15
+ def parse(name, text, complete: false, command: nil)
16
+ parse_many([ name ], text, complete: complete, command: command).fetch(name)
17
+ end
17
18
 
18
- result = @profile.parse(name, text, complete: complete)
19
- Observation.new(name: name, **result.to_h)
19
+ # 单条正文只允许投影属于同一声明命令的运行维度。
20
+ def parse_many(names, text, complete: false, command: nil)
21
+ unless names.is_a?(Array) && names.any? && names.uniq == names &&
22
+ names.all? { |name| name.is_a?(Symbol) && name != :configuration && @profile.commands.key?(name) }
23
+ raise ArgumentError, "Unsupported runtime dimensions"
24
+ end
25
+ @profile.parse_many(names, text, complete: complete, command: command).to_h do |name, result|
26
+ [ name, Observation.new(name: name, **result.to_h) ]
27
+ end
20
28
  end
21
29
  end
22
30
  end
@@ -1,32 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ctx_parser/immutable"
3
+ require "forwardable"
4
+ require "ctx_runner/snapshot"
4
5
  require_relative "observation"
6
+ require_relative "parser_registry"
5
7
 
6
8
  module Vxlan
7
- # 携带单设备 Fabric 身份及观测结果,不管理资产清单。
8
- Snapshot = Data.define(:target, :fabric_key, :dimensions, :observed_at) do
9
- # 事实由不可变领域观测持有;仅复制维度选择,不重复复制事实。
10
- def initialize(target:, fabric_key:, dimensions:, observed_at:)
11
- unless dimensions.is_a?(Hash) && dimensions.all? { |name, value| value.is_a?(Observation) && value.name == name }
12
- raise ArgumentError, "Dimensions must match their observations"
13
- end
14
-
15
- super(target: CtxParser::Immutable.copy(target), fabric_key: CtxParser::Immutable.copy(fabric_key),
16
- dimensions: dimensions.to_h { |name, value| [ name, value ] }.freeze, observed_at: observed_at&.dup&.freeze)
17
- end
9
+ # 领域组合公共快照,身份、维度、时间及采集结论共用同一契约。
10
+ class Snapshot
11
+ include CtxRunner::Document::Value
12
+ extend Forwardable
13
+ def_delegators :@snapshot, :target, :observations, :observed_at, :issues, :collection_status,
14
+ :observation, :complete?, :same_target?
15
+ attr_reader :fabric_key
18
16
 
19
- # 判断所有已选事实是否都有完整证据支持。
20
- def complete?
21
- selected = dimensions.values.reject { |dimension| dimension.status == :not_requested }
22
- selected.any? && selected.all?(&:complete?)
17
+ def initialize(target:, fabric_key:, observations:, observed_at:, issues: [])
18
+ unless fabric_key.is_a?(String) && !fabric_key.strip.empty? && !/[\x00-\x1f\x7f]/.match?(fabric_key)
19
+ raise ArgumentError, "Fabric identity must be nonempty text"
20
+ end
21
+ @fabric_key = fabric_key.dup.freeze
22
+ @snapshot = CtxRunner::Snapshot.new(target: target, observations: observations, observed_at: observed_at,
23
+ names: CiscoNxos::Profile::COMMANDS.keys, issues: issues)
24
+ freeze
23
25
  end
24
26
 
25
- # 展示对象身份与状态,避免泄露原始回显或凭据。
26
- def inspect = "#<#{self.class.name} fabric=#{fabric_key.inspect} complete=#{complete?}>"
27
+ def to_h = @snapshot.to_h.merge(fabric_key: fabric_key)
28
+ def inspect = "#<#{self.class.name} collection_status=#{collection_status}>"
27
29
  alias to_s inspect
28
-
29
- # PP 默认展开 Data 成员,显式沿用脱敏展示。
30
30
  def pretty_print(printer) = printer.text(inspect)
31
31
  end
32
32
  end
data/lib/vxlan/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Vxlan
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.5.1"
5
+ VERSION = "0.7.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.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: ctx_cli
13
+ name: forwardable
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
@@ -23,48 +23,86 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ctx_cli
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.4.0
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '0.5'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 0.4.0
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '0.5'
26
46
  - !ruby/object:Gem::Dependency
27
47
  name: ctx_runner
28
48
  requirement: !ruby/object:Gem::Requirement
29
49
  requirements:
30
50
  - - ">="
31
51
  - !ruby/object:Gem::Version
32
- version: '0'
52
+ version: 0.4.0
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '0.5'
33
56
  type: :runtime
34
57
  prerelease: false
35
58
  version_requirements: !ruby/object:Gem::Requirement
36
59
  requirements:
37
60
  - - ">="
38
61
  - !ruby/object:Gem::Version
39
- version: '0'
62
+ version: 0.4.0
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '0.5'
40
66
  - !ruby/object:Gem::Dependency
41
67
  name: ctx_parser
42
68
  requirement: !ruby/object:Gem::Requirement
43
69
  requirements:
44
70
  - - ">="
45
71
  - !ruby/object:Gem::Version
46
- version: '0'
72
+ version: 0.4.0
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.5'
47
76
  type: :runtime
48
77
  prerelease: false
49
78
  version_requirements: !ruby/object:Gem::Requirement
50
79
  requirements:
51
80
  - - ">="
52
81
  - !ruby/object:Gem::Version
53
- version: '0'
82
+ version: 0.4.0
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '0.5'
54
86
  - !ruby/object:Gem::Dependency
55
87
  name: ctx_driver
56
88
  requirement: !ruby/object:Gem::Requirement
57
89
  requirements:
58
90
  - - ">="
59
91
  - !ruby/object:Gem::Version
60
- version: '0'
92
+ version: 0.4.0
93
+ - - "<"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.5'
61
96
  type: :runtime
62
97
  prerelease: false
63
98
  version_requirements: !ruby/object:Gem::Requirement
64
99
  requirements:
65
100
  - - ">="
66
101
  - !ruby/object:Gem::Version
67
- version: '0'
102
+ version: 0.4.0
103
+ - - "<"
104
+ - !ruby/object:Gem::Version
105
+ version: '0.5'
68
106
  - !ruby/object:Gem::Dependency
69
107
  name: digest
70
108
  requirement: !ruby/object:Gem::Requirement