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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README-zh.md +32 -4
- data/README.md +32 -4
- data/bin/README.md +203 -0
- data/bin/vxlan +8 -0
- data/docs/architecture-zh.md +62 -60
- data/docs/evidence-contracts.md +54 -0
- data/docs/remediation.md +84 -0
- data/docs/review-2026-10-19.md +31 -0
- data/docs/review-2026-10-21.md +19 -0
- data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
- data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
- data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
- data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
- data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
- data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
- data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
- data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
- data/examples/README.md +20 -0
- data/examples/collect.rb +8 -0
- data/examples/parse.rb +5 -0
- data/lib/vxlan/acl_policy.rb +50 -0
- data/lib/vxlan/change_plan/svi_acl.rb +76 -0
- data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
- data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
- data/lib/vxlan/change_plan.rb +131 -0
- data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
- data/lib/vxlan/cisco_nxos/evpn_l2route.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +65 -11
- data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
- data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +3 -2
- data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +11 -5
- data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +10 -4
- data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
- data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
- data/lib/vxlan/cli/apply.rb +172 -0
- data/lib/vxlan/cli/collect.rb +210 -0
- data/lib/vxlan/cli/connector.rb +23 -0
- data/lib/vxlan/cli/options.rb +311 -0
- data/lib/vxlan/cli/parse.rb +58 -0
- data/lib/vxlan/cli/report.rb +223 -0
- data/lib/vxlan/cli/review/fabric.rb +102 -0
- data/lib/vxlan/cli/review/report.rb +165 -0
- data/lib/vxlan/cli/review.rb +125 -0
- data/lib/vxlan/cli/templates/env.example +12 -0
- data/lib/vxlan/cli/templates/vxlan.yml +32 -0
- data/lib/vxlan/cli.rb +41 -0
- data/lib/vxlan/collector.rb +59 -28
- data/lib/vxlan/config/evidence.rb +18 -0
- data/lib/vxlan/config/review.rb +56 -0
- data/lib/vxlan/{configuration.rb → config.rb} +1 -1
- data/lib/vxlan/fabric/review.rb +327 -0
- data/lib/vxlan/fabric/scope.rb +43 -0
- data/lib/vxlan/observation.rb +3 -0
- data/lib/vxlan/recommendations.rb +193 -0
- data/lib/vxlan/service/catalog.rb +77 -0
- data/lib/vxlan/service/mapping.rb +53 -0
- data/lib/vxlan/snapshot.rb +3 -0
- data/lib/vxlan/subnet_index.rb +88 -0
- data/lib/vxlan/version.rb +1 -1
- data/lib/vxlan.rb +4 -2
- metadata +138 -7
- data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
- 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
|