vxlan 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README-zh.md +32 -4
  4. data/README.md +32 -4
  5. data/bin/README.md +203 -0
  6. data/bin/vxlan +8 -0
  7. data/docs/architecture-zh.md +62 -60
  8. data/docs/evidence-contracts.md +54 -0
  9. data/docs/remediation.md +84 -0
  10. data/docs/review-2026-10-19.md +31 -0
  11. data/docs/review-2026-10-21.md +19 -0
  12. data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
  13. data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
  14. data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
  15. data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
  16. data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
  17. data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
  18. data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
  19. data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
  20. data/examples/README.md +20 -0
  21. data/examples/collect.rb +8 -0
  22. data/examples/parse.rb +5 -0
  23. data/lib/vxlan/acl_policy.rb +50 -0
  24. data/lib/vxlan/change_plan/svi_acl.rb +76 -0
  25. data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
  26. data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
  27. data/lib/vxlan/change_plan.rb +131 -0
  28. data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
  29. data/lib/vxlan/cisco_nxos/evpn_l2route.rb +1 -1
  30. data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +65 -11
  31. data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
  32. data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
  33. data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +3 -2
  34. data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
  35. data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +11 -5
  36. data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +10 -4
  37. data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
  38. data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
  39. data/lib/vxlan/cli/apply.rb +172 -0
  40. data/lib/vxlan/cli/collect.rb +210 -0
  41. data/lib/vxlan/cli/connector.rb +23 -0
  42. data/lib/vxlan/cli/options.rb +311 -0
  43. data/lib/vxlan/cli/parse.rb +58 -0
  44. data/lib/vxlan/cli/report.rb +223 -0
  45. data/lib/vxlan/cli/review/fabric.rb +102 -0
  46. data/lib/vxlan/cli/review/report.rb +165 -0
  47. data/lib/vxlan/cli/review.rb +125 -0
  48. data/lib/vxlan/cli/templates/env.example +12 -0
  49. data/lib/vxlan/cli/templates/vxlan.yml +32 -0
  50. data/lib/vxlan/cli.rb +41 -0
  51. data/lib/vxlan/collector.rb +59 -28
  52. data/lib/vxlan/config/evidence.rb +18 -0
  53. data/lib/vxlan/config/review.rb +56 -0
  54. data/lib/vxlan/{configuration.rb → config.rb} +1 -1
  55. data/lib/vxlan/fabric/review.rb +327 -0
  56. data/lib/vxlan/fabric/scope.rb +43 -0
  57. data/lib/vxlan/observation.rb +3 -0
  58. data/lib/vxlan/recommendations.rb +193 -0
  59. data/lib/vxlan/service/catalog.rb +77 -0
  60. data/lib/vxlan/service/mapping.rb +53 -0
  61. data/lib/vxlan/snapshot.rb +3 -0
  62. data/lib/vxlan/subnet_index.rb +88 -0
  63. data/lib/vxlan/version.rb +1 -1
  64. data/lib/vxlan.rb +4 -2
  65. metadata +138 -7
  66. data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
  67. data/lib/vxlan/configuration_review.rb +0 -54
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "digest"
6
+ require "ipaddr"
7
+ require "vxlan"
8
+ require_relative "report"
9
+ require_relative "../change_plan"
10
+
11
+ module Vxlan
12
+ module CLI
13
+ # 原生会话适配:读取使用固定查询提示符,写入由连接器识别配置模式提示符。
14
+ class ApplySession
15
+ # 借用一次已连接且独占的会话,不重连、不重放。
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, "配置回读失败" unless capture.success?
25
+ observation = Vxlan::Config.new.parse(capture.body, complete: true)
26
+ raise ArgumentError, "配置证据不完整" unless observation.complete? && observation.records.one?
27
+ JSON.parse(JSON.generate(observation.records.first))
28
+ end
29
+
30
+ # 每次只发一条命令;失败立即交还宿主,原始输出不进入终端和日志报告。
31
+ def write(command)
32
+ result = @native.execute_command(command.strip, output_sensitive: true)
33
+ raise IOError, "设备命令未确认" unless result.success? && result.steps.one?
34
+ true
35
+ end
36
+ end
37
+
38
+ # 交互式单设备下发:展示、确认、重新读取、记录意图、执行、回读,禁止无人值守确认。
39
+ class Apply
40
+ # 注入连接工厂仅用于宿主集成和离线失败测试。
41
+ def initialize(env: ENV, out: $stdout, error: $stderr, input: $stdin, connector_factory: nil, discovery: {})
42
+ @env, @out, @error, @input, @factory = env, out, error, input, connector_factory
43
+ @discovery = discovery
44
+ end
45
+
46
+ # 没有 --yes 或自动回退;零退出码只确认配置回读,不声称业务已验收。
47
+ def run(argv)
48
+ @store, @state, @started = nil, nil, false
49
+ options = { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }
50
+ parser = OptionParser.new do |p|
51
+ p.banner = "用法:vxlan apply --plan FILE --item ID [--env-file FILE](必须真实终端明确确认)"
52
+ p.on("--plan FILE", "批次 plan.json,须有同目录 SHA256 校验文件") { |value| options[:plan] = value }
53
+ p.on("--item ID", "批次计划中明确选择一个增量,禁止批量执行") { |value| options[:item] = value }
54
+ p.on("--env-file FILE", "显式凭据文件,进程环境优先") { |value| options[:env_file] = value }
55
+ p.on("--directory DIR", "私有执行日志目录") { |value| options[:directory] = value }
56
+ p.on("--port N", Integer, "SSH 端口,默认 22") { |value| options[:port] = value }
57
+ p.on("-h", "--help", "查看帮助,不连接设备") { options[:help] = true }
58
+ end
59
+ rest = parser.parse(argv.dup)
60
+ return @out.puts(parser) || 0 if options[:help]
61
+ raise ArgumentError, "必须使用真实交互终端" unless @input.tty? && @out.tty? && @error.tty?
62
+ raise ArgumentError, "需要单个计划" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
63
+ body = File.read(options[:plan])
64
+ hash = Digest::SHA256.hexdigest(body)
65
+ plan = CtxParser::Immutable.copy(select_plan(JSON.parse(body), options, hash))
66
+ change = validate_plan(plan)
67
+ host = plan.fetch("device").fetch("host")
68
+ @out.puts "目标:#{plan['device']['name']}(#{host});#{plan['scope']};#{plan['title']}"
69
+ @out.puts "影响:#{plan['impact']}"
70
+ @out.puts Array(plan["prerequisites"]).map { |value| "条件:#{value}" }
71
+ @out.puts "计划 SHA256:#{hash}\n拟执行命令:\n#{change.commands.join("\n")}\n条件回退(不自动执行):\n#{change.rollback.join("\n")}"
72
+ token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
73
+ @out.print "确认业务策略及维护窗口后输入 #{token};其他输入取消:"
74
+ @out.flush
75
+ return @out.puts("已取消,未连接设备。") || 1 unless @input.gets&.strip == token
76
+ settings = credentials(options)
77
+ @store = Report.new(options[:directory])
78
+ @state = { "status" => "confirmed", "device" => plan["device"], "plan_sha256" => hash, "plan_item" => options[:item], "expected_config_sha256" => plan["expected_config_sha256"], "commands" => plan["commands"], "steps" => [] }
79
+ @store.write_execution(@state)
80
+ session_opened, verified = false, false
81
+ with_session(plan["device"], settings) do |session|
82
+ raise IOError, "会话回调重复,禁止重放" if session_opened
83
+ session_opened = true
84
+ before = CtxParser::Immutable.copy(session.read_configuration)
85
+ unless ChangePlan.fingerprint(before) == plan["expected_config_sha256"] && change.eligible?(before)
86
+ raise ArgumentError, "配置已变化或增量前提不满足"
87
+ end
88
+ change.commands.each_with_index do |command, index|
89
+ @state["status"] = "executing"
90
+ @state["pending_command"] = index
91
+ @store.write_execution(@state)
92
+ @started = true
93
+ raise IOError, "设备命令未确认" unless session.write(command) == true
94
+ @state["steps"] << { "index" => index, "status" => "acknowledged" }
95
+ @state.delete("pending_command")
96
+ @store.write_execution(@state)
97
+ end
98
+ after = CtxParser::Immutable.copy(session.read_configuration)
99
+ raise IOError, "配置验收未通过" unless change.satisfied?(after, before: before)
100
+ @state["observed_config_sha256"] = ChangePlan.fingerprint(after)
101
+ verified = true
102
+ end
103
+ raise IOError, "会话未完成配置验证" unless verified
104
+ @state["status"] = "configuration_verified"
105
+ @store.write_execution(@state)
106
+ @out.puts "配置回读已确认;仍需完成业务验收,未执行保存配置。日志:#{@store.directory}/execution.json"
107
+ 0
108
+ rescue Interrupt
109
+ record_failure("interrupted")
110
+ @error.puts "操作中断;#{@started ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
111
+ 130
112
+ rescue StandardError, LoadError => error
113
+ record_failure(error.class.name)
114
+ @error.puts "下发停止(#{error.class.name});#{@started ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
115
+ 2
116
+ end
117
+
118
+ private
119
+
120
+ # 聚合文件必须校验哈希并选中唯一动作;绝不隐式选择第一项或循环执行。
121
+ def select_plan(document, options, hash)
122
+ id = options[:item]
123
+ unless document.is_a?(Hash) && document["schema_version"] == 2 && document["platform"] == "cisco_nxos" && document["changes"].is_a?(Hash) &&
124
+ id.is_a?(String) && id.match?(/\A[A-Za-z0-9_.-]+\z/) && document["changes"].key?(id)
125
+ raise ArgumentError, "需要批次计划及有效 --item"
126
+ end
127
+ raise ArgumentError, "计划校验失败" unless File.read("#{options[:plan]}.sha256").strip == hash
128
+ document["changes"].fetch(id)
129
+ end
130
+
131
+ # 不信任文件中的命令,重新从有限动作渲染并逐字核对,拒绝额外命令。
132
+ def validate_plan(plan)
133
+ raise ArgumentError, "计划类型不匹配" unless plan.is_a?(Hash) && plan["schema_version"] == 1 && plan["platform"] == "cisco_nxos"
134
+ host = plan.fetch("device").fetch("host")
135
+ raise ArgumentError, "目标地址无效" unless host.is_a?(String) && !host.include?("/") && IPAddr.new(host).to_s == host
136
+ raise ArgumentError, "配置指纹无效" unless plan["expected_config_sha256"].is_a?(String) && plan["expected_config_sha256"].match?(/\A[0-9a-f]{64}\z/)
137
+ change = ChangePlan.build(plan.fetch("change"))
138
+ raise ArgumentError, "计划命令被修改" unless change.commands == plan["commands"] && change.rollback == plan["rollback"]
139
+ change
140
+ end
141
+
142
+ # 共用分层环境加载器,显式文件最高、进程环境优先;凭据不进入计划或日志。
143
+ def credentials(options)
144
+ require "net/connector/config"
145
+ env = Net::Connector::Config.new(name: "vxlan", env: @env, env_file: options[:env_file], **@discovery).load_environment.environment
146
+ username = env["NC_CISCO_NXOS_USERNAME"] || env["NC_DEVICE_USERNAME"]
147
+ raise ArgumentError, "缺少设备用户名" unless username.is_a?(String) && !username.strip.empty?
148
+ { username: username, password: env["NC_CISCO_NXOS_PASSWORD"] || env["NC_DEVICE_PASSWORD"], enable_password: env["NC_ENABLE_PASSWORD"],
149
+ port: options[:port], host_key_policy: "strict", command_timeout: 60, login_timeout: 15 }
150
+ end
151
+
152
+ # 原生会话只打开一次,读写处于同一操作租约中。
153
+ def with_session(device, settings, &operation)
154
+ return @factory.call(device, settings, &operation) if @factory
155
+ require "net/connector"
156
+ Net::Connector.open(:cisco_nxos, host: device.fetch("host"), **settings) do |native|
157
+ native.with_operation(:vxlan_apply) { operation.call(ApplySession.new(native)) }
158
+ end
159
+ end
160
+
161
+ # 失败记录尽力持久化;记录失败也不允许继续发送任何命令。
162
+ def record_failure(code)
163
+ return unless @store && @state
164
+ @state["status"] = @started ? "uncertain" : "failed"
165
+ @state["error"] = code
166
+ @store.write_execution(@state)
167
+ rescue StandardError, Interrupt
168
+ @error.puts "执行日志收尾未确认;保留已有检查点,禁止据此重放配置。"
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vxlan"
4
+ require_relative "options"
5
+ require_relative "report"
6
+ require_relative "connector"
7
+
8
+ module Vxlan
9
+ module CLI
10
+ # 批量入口拥有配置、清单、并行与报告,不向核心库引入宿主依赖。
11
+ class Collect
12
+ # 可注入清单、传输与执行器,离线集成验证不需要任何真实设备。
13
+ def initialize(env: ENV, out: $stdout, error: $stderr, input: $stdin, connector_factory: nil, executor: nil, **options)
14
+ @env, @out, @error, @input = env, out, error, input
15
+ @connector_factory, @executor = connector_factory, executor
16
+ @defaults = options
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ # 先预检全部输入和凭据,再创建报告和连接;失败分类不打印原始异常正文。
21
+ def run(argv)
22
+ # 每次运行独立持有批次状态;预检失败不能改写上一次报告。
23
+ @options, @store, @progress, @devices, @results = nil, nil, nil, [], []
24
+ @options = Options.new(argv, env: @env, out: @out, **@defaults)
25
+ return 0 if @options.informational?
26
+ @options.validate_interaction!(input: @input, error: @error)
27
+ @options.load_environment
28
+ if @options.flags[:init]
29
+ @options.initialize_files
30
+ return 0
31
+ end
32
+ return 0 if @options.missing?(input: @input, error: @error)
33
+ @options.load
34
+ require "net/connector/collect/progress"
35
+ @progress = Net::Connector::Collect::Progress.new(io: @error, enabled: !@options.flags[:quiet], verbose: @options.flags[:verbose])
36
+ @progress.notice("配置:#{@options.config}")
37
+ return output(@options.public_values.merge("sources" => @options.sources)) if @options.flags[:show_config]
38
+ @devices = devices = inventory
39
+ return output(devices) if @options.flags[:inventory_only]
40
+ if devices.empty?
41
+ @progress.notice("没有匹配设备,未建立连接")
42
+ @out.puts JSON.generate(status: :no_devices, total: 0) if @options.flags[:json]
43
+ return 1
44
+ end
45
+ credentials = credentials!
46
+ load_network unless @connector_factory && @executor
47
+ unless @connector_factory
48
+ devices.each { |device| Net::Connector::Configuration.new(host: device.fetch("host"), **credentials).validate_endpoint! }
49
+ end
50
+ unless @executor
51
+ require "net/connector/resources"
52
+ Net::Connector::Resources.prepare!(parallel: [ devices.size, @options.values.fetch("parallel") ].min)
53
+ end
54
+ @store = Report.new(@options.values.fetch("directory"), scripts: @options.flags[:scripts] == true)
55
+ @progress.plan(devices: devices, total: @inventory_total, parallel: @options.values.fetch("parallel"), label: "VXLAN 只读采集")
56
+ @progress.with_updates do
57
+ execute(devices) { |device| collect(device, credentials) }
58
+ end
59
+ @store.review_collected_fabric(@options.fabric_policy)
60
+ summary = finish
61
+ return 2 unless summary
62
+ summary[:status] == :complete ? 0 : 1
63
+ rescue Interrupt
64
+ finish(interrupted: true) if @store
65
+ diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
66
+ 130
67
+ rescue SystemCallError, IOError => error
68
+ finish(report_error: error) if @store
69
+ diagnostic "VXLAN 参数或报告错误:#{report_diagnostic(error)}"
70
+ 2
71
+ rescue OptionParser::ParseError, ArgumentError, LoadError => error
72
+ if @store
73
+ finish(execution_error: error)
74
+ diagnostic "VXLAN 执行失败:#{error.class.name};已采集结果见批次报告"
75
+ else
76
+ diagnostic "VXLAN 参数、依赖或报告错误:#{error.message};使用 --help 查看参数"
77
+ end
78
+ 2
79
+ rescue StandardError => error
80
+ finish(execution_error: error) if @store
81
+ diagnostic "VXLAN 清单或执行失败:#{CtxDriver::Failure.code(error, :collection_failed)}(#{error.class.name})"
82
+ 2
83
+ end
84
+
85
+ private
86
+
87
+ # 只在需要库存或网络执行时加载可选依赖,帮助和静态预览保持独立。
88
+ def load_network
89
+ require "net/connector"
90
+ rescue LoadError
91
+ raise LoadError, "联网采集需要 net-connector >= 0.16.0;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
92
+ end
93
+
94
+ # YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
95
+ def inventory
96
+ values = @options.values
97
+ rows = if @options.dynamic_inventory?
98
+ @progress.reading_inventory
99
+ settings = @options.netdisco_settings
100
+ Net::Connector::Netdisco::Fleet.new(settings: settings).devices.filter_map do |device|
101
+ next unless device.ready? && device.vendor == :cisco_nxos
102
+ { "host" => device.host, "name" => device.name || device.host, "model" => device.model }
103
+ end
104
+ else
105
+ values.fetch("devices")
106
+ end
107
+ targets = @options.normalize_devices(rows)
108
+ @inventory_total = targets.size
109
+ includes = values.fetch("hosts").map { |v| IPAddr.new(v) }
110
+ excludes = values.fetch("exclude_hosts").map { |v| IPAddr.new(v) }
111
+ targets.select do |device|
112
+ host, name = device.values_at("host", "name")
113
+ (includes.empty? || includes.any? { |network| network.include?(host) }) &&
114
+ excludes.none? { |network| network.include?(host) } &&
115
+ (values.fetch("names").empty? || values.fetch("names").any? { |v| name.downcase.include?(v.downcase) }) &&
116
+ values.fetch("exclude_names").none? { |v| name.downcase.include?(v.downcase) }
117
+ end
118
+ end
119
+
120
+ # 缺失用户名时不尝试连接;密码允许省略以支持明确的 SSH key/agent。
121
+ def credentials!
122
+ username = secret("NC_CISCO_NXOS_USERNAME") || secret("NC_DEVICE_USERNAME")
123
+ raise ArgumentError, "请设置 NC_DEVICE_USERNAME 或 NC_CISCO_NXOS_USERNAME;密码可通过环境或 SSH key/agent 提供" unless username
124
+ @options.values.fetch("ssh").transform_keys(&:to_sym).merge(username: username,
125
+ password: secret("NC_CISCO_NXOS_PASSWORD") || secret("NC_DEVICE_PASSWORD"), enable_password: secret("NC_ENABLE_PASSWORD"))
126
+ end
127
+
128
+ # 空白环境值视为缺失,不修改或回显凭据。
129
+ def secret(name)
130
+ value = @options.environment[name]
131
+ value unless value.nil? || value.strip.empty?
132
+ end
133
+
134
+ # 每个任务使用独立 Collector 和会话;公共执行器负责有界并发和线程回收。
135
+ def execute(devices, &block)
136
+ parallel = @options.values.fetch("parallel")
137
+ return @executor.call(devices, parallel, &block) if @executor
138
+ Net::Connector::Parallel::Pool.new(parallel: parallel).map(devices, &block)
139
+ end
140
+
141
+ # 单台失败保留其他设备和已获得维度;报告持久化失败交由批次终止处理。
142
+ def collect(device, credentials)
143
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+ snapshot = nil
145
+ @progress.start(device)
146
+ credentials = credentials.merge(on_event: @progress.method(:event))
147
+ begin
148
+ connector = @connector_factory ? @connector_factory.call(device, credentials) : Connector.new(device, credentials)
149
+ snapshot = Vxlan::Collector.new.collect(connector: connector, target: device, fabric_key: @options.values.fetch("fabric"),
150
+ model: device["model"], dimensions: @options.values.fetch("dimensions").map(&:to_sym))
151
+ dimensions = snapshot.dimensions.transform_values { |v| { status: v.status, records: v.records.size, issues: v.issues.map { |issue| issue[:code] }.uniq } }
152
+ status = snapshot.dimensions.fetch(:collection).status
153
+ result = { device: device, status: status, dimensions: dimensions }
154
+ rescue StandardError => error
155
+ result = { device: device, status: :failed, error: CtxDriver::Failure.code(error, :collection_failed), dimensions: {} }
156
+ end
157
+ result[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
158
+ result[:report_saved] = false
159
+ @lock.synchronize { @results << result }
160
+ @store.write(result, snapshot: snapshot)
161
+ @lock.synchronize { result[:report_saved] = true }
162
+ @progress.result(result)
163
+ result
164
+ end
165
+
166
+ # 调度器已回收线程;摘要失败只交付安全诊断,不覆盖先前的取消事实。
167
+ def finish(interrupted: false, report_error: nil, execution_error: nil)
168
+ results = @results.sort_by { |result| result[:device]["host"] }
169
+ options = { interrupted: interrupted, requested: @devices,
170
+ report_error: report_error && report_diagnostic(report_error), execution_error: execution_error&.class&.name }
171
+ summary = @store.finish(results, **options, output_error: @progress.output_error)
172
+ @progress.finish(summary)
173
+ # 末尾提示也可能首次失败;只补写输出诊断,不重复设备任务。
174
+ if @progress.output_error && !summary[:output_error]
175
+ summary = @store.finish(results, **options, output_error: @progress.output_error)
176
+ end
177
+ @out.puts JSON.pretty_generate(summary) if @options.flags[:json]
178
+ summary
179
+ rescue SystemCallError, IOError => error
180
+ diagnostic "VXLAN 摘要报告错误:#{report_diagnostic(error)}"
181
+ diagnostic "摘要恢复未确认;请依据退出码及报告目录检查,不能将旧摘要视为本次成功。" if @store.recovery_failed?
182
+ nil
183
+ end
184
+
185
+ # 错误提示失败不能覆盖取消或执行退出码;已失效的进度流不再重试。
186
+ def diagnostic(message)
187
+ return if @progress&.output_error
188
+ @error.puts(message)
189
+ rescue StandardError
190
+ nil
191
+ end
192
+
193
+ # 自有发布错误消息不含正文;外部文件异常只显示类型,不打印 message/cause。
194
+ def report_diagnostic(error)
195
+ error.instance_of?(Report::Error) ? error.message : error.class.name
196
+ end
197
+
198
+ # 预览默认显示逐设备身份;公开配置由显式开关展示。
199
+ def output(value)
200
+ if @options.flags[:json] || value.is_a?(Hash)
201
+ @out.puts JSON.pretty_generate(value)
202
+ else
203
+ value.each { |device| @out.puts "#{device.fetch('host')}\t#{device.fetch('name')}\t#{device['model']}" }
204
+ @out.puts "设备:#{value.size} 台;未建立设备连接"
205
+ end
206
+ value.is_a?(Array) && value.empty? ? 1 : 0
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vxlan
4
+ module CLI
5
+ # 可选传输适配器;只由联网采集使用,解析库不引用 net-connector。
6
+ class Connector
7
+ # 固定设备与接入选项,不在构造时建立会话。
8
+ def initialize(device, settings)
9
+ @device, @settings = device, settings
10
+ end
11
+
12
+ # 向 Collector 声明已知平台,不把品牌猜测当作型号识别。
13
+ def platform = :cisco_nxos
14
+
15
+ # 连接器拥有原生会话;CtxDriver 仅借用,不负责关闭或重连。
16
+ def open
17
+ Net::Connector.open(:cisco_nxos, host: @device.fetch("host"), **@settings) do |native|
18
+ native.with_operation(:vxlan) { yield native }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end