vxlan 0.3.1 → 0.4.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 +17 -0
- data/bin/README.md +18 -2
- data/lib/vxlan/change_plan.rb +5 -11
- data/lib/vxlan/cli/apply.rb +18 -44
- data/lib/vxlan/cli/collect.rb +54 -152
- data/lib/vxlan/cli/options.rb +34 -49
- data/lib/vxlan/cli/parse.rb +13 -15
- data/lib/vxlan/cli/report.rb +34 -178
- data/lib/vxlan/cli/review/report.rb +2 -10
- data/lib/vxlan/cli.rb +28 -27
- data/lib/vxlan/collection_plan.rb +35 -0
- data/lib/vxlan/collector.rb +21 -62
- data/lib/vxlan/observation.rb +3 -24
- data/lib/vxlan/version.rb +1 -1
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 203d6c19eed76f37e033ba015e36a39953de0ba63dc5fd4130873a83f1e097c3
|
|
4
|
+
data.tar.gz: bb21ff1519721b7ee99cdedc5af2a3ac34c8542563c06ce9d3cd1328c31c4d6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa2702cbc3fc196d77c7cb4ac24aca422f4e03eaf6ac4967d07b946e762ff774b9359d24deae4430ca9e7a8644992909e00b185c48d14b575d30bbeb037f381c
|
|
7
|
+
data.tar.gz: fea09bbf0ea945b4c33913f759aad4e4181bd4da4148593ade4e177a18939938ef7c0b327bbce9da0b3ac18e95d9bee9ebced5afd2014b4c35978bd9b80fb7c0
|
data/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
Changes to the public contract are recorded here. Versions follow Semantic Versioning.
|
|
4
4
|
|
|
5
|
+
## 0.4.0 (2026-10-22)
|
|
6
|
+
|
|
7
|
+
- Delegate batch completion and cancellation/failure precedence to CtxRunner::Batch, keeping domain review and CLI presentation explicit.
|
|
8
|
+
|
|
9
|
+
- Centralize inventory selection and CLI filters in Net::Connector::Inventory; add vendor/model inclusion, exclusion and matching modes.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
- Delegate shared collection, evidence and execution contracts to ctx_runner.
|
|
13
|
+
- Preserve domain parsing, change policies, CLI layouts and connection ownership.
|
|
14
|
+
|
|
5
15
|
## Unreleased
|
|
6
16
|
|
|
17
|
+
## 0.4.1 (2026-10-22)
|
|
18
|
+
|
|
19
|
+
- Delegate command dispatch, common options, initialization, terminal interaction, offline IO and collection entrypoint orchestration to ctx_cli 0.1.0.
|
|
20
|
+
- Keep domain policies, device capabilities and ctx_runner completion semantics in their existing layers.
|
|
21
|
+
|
|
22
|
+
- Delegate batch completion and cancellation/failure precedence to CtxRunner::Batch, keeping domain review and CLI presentation explicit.
|
|
23
|
+
|
|
7
24
|
## 0.3.1 (2026-10-21)
|
|
8
25
|
|
|
9
26
|
- Index Fabric address-group evidence by referenced object, preserving host order and missing dependencies without repeatedly searching every host's reference list.
|
data/bin/README.md
CHANGED
|
@@ -17,7 +17,7 @@ bundle exec ruby examples/offline.rb
|
|
|
17
17
|
|
|
18
18
|
`bin/` 的公开内容是 `vxlan` 与本说明;现场 `vxlan.yml`、`.env`、`vxlans/` 由用户按需创建,不进入版本库或安装包。配置和凭据模板统一位于 `lib/vxlan/cli/templates/`,使用 `--init` 生成,不维护重复模板。
|
|
19
19
|
|
|
20
|
-
离线解析和帮助不加载 net-connector。采集配置与联网入口可选依赖 `net-connector ~> 0.
|
|
20
|
+
离线解析和帮助不加载 net-connector。采集配置与联网入口可选依赖 `net-connector ~> 0.17.1`(已包含 `.env` 加载依赖 dotenv)。本仓库 Gemfile 的 `examples` 组已声明连接器,不进入 vxlan.gemspec 的运行依赖。只安装 vxlan gem 的宿主需要自行声明相应可选依赖。
|
|
21
21
|
|
|
22
22
|
## 正式命令
|
|
23
23
|
|
|
@@ -25,7 +25,7 @@ bundle exec ruby examples/offline.rb
|
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
27
|
gem install vxlan
|
|
28
|
-
gem install net-connector -v '~> 0.
|
|
28
|
+
gem install net-connector -v '~> 0.17.1'
|
|
29
29
|
vxlan --help
|
|
30
30
|
vxlan collect --init --non-interactive
|
|
31
31
|
vxlan collect --inventory-only
|
|
@@ -201,3 +201,19 @@ Fabric 的 ACL 比较包含有序规则和引用地址组的成员。对象缺
|
|
|
201
201
|
`fabric-policy.yml` 显式声明各 Fabric 的 `leaves`、`borders`、`spines`。Leaf 与 Border 按各自已部署的共同 VNI 比较;Spine 检查角色冲突。业务目录联合 VLAN/NVE/EVPN 识别不完整部署,总览显示覆盖范围、未纳入设备和缺证据成员。只有完整一致的对端与本机唯一业务接口相互印证、且全 Fabric 无跨 VNI 网段冲突时,才建议补齐已有 VLAN 的 VNI 关联;不会推导全量铺开 VLAN,也不按命名猜测角色。
|
|
202
202
|
|
|
203
203
|
每次采集独立合并进程环境与指定 `.env`,不改写调用者环境;凭据、清单与参数使用同一份本次有效环境。Fabric 策略在连接前加载、校验并固定,采集中途修改策略文件只影响下一批。下发只接受带校验文件的 `plan.json` 与 `--item ID`,不接受旧版独立单项计划。
|
|
204
|
+
|
|
205
|
+
## 统一设备筛选
|
|
206
|
+
|
|
207
|
+
collect 的筛选与备份/拓扑共用 net-connector 的 Inventory::Selection。需要安装包含 `net/connector/inventory/filters` 的当前候选连接器;collect 帮助也从该组件读取参数定义,离线 parse/review 不加载它。
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
vxlan collect --hosts 192.0.2.0/24 --name edge --exclude-name spare --model 'S68*' --model-match glob --plan
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
IP 支持单地址、CIDR、范围;名称及型号支持 contains(默认)、prefix、glob,不区分大小写。`--vendor`/`--exclude-vendor` 使用连接器平台标识(如 cisco_nxos、h3c、h3c_wireless、huawei),不是原始厂商标签。同字段条件为 OR,跨字段为 AND,排除优先。缺失型号不命中包含规则。`--model`/`--exclude-model`、`--name-match`/`--model-match` 可组合;筛选只选择已有清单,不扫描地址、不扩大平台支持范围。
|
|
214
|
+
|
|
215
|
+
YAML 顶层对应 hosts/exclude_hosts、names/exclude_names、vendors/exclude_vendors、models/exclude_models、name_match/model_match。Netdisco inventory 段使用 include_hosts/include_names/include_vendors/include_models 及对应 exclude 字段。
|
|
216
|
+
|
|
217
|
+
## ctx_cli 公共入口
|
|
218
|
+
|
|
219
|
+
分发、公共选项、配置初始化、离线输入输出、终端确认和采集入口由 ctx_cli 组合提供;领域规则与 ctx_runner 业务状态保持各自职责。`CTX_CLI_PATH=../ctx_cli bundle exec rake` 用于源码联调;普通入口先安装 ctx_cli 候选包。
|
data/lib/vxlan/change_plan.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ctx_runner/fingerprint"
|
|
4
4
|
require "json"
|
|
5
5
|
require "ipaddr"
|
|
6
6
|
require "ctx_parser/immutable"
|
|
@@ -34,16 +34,10 @@ module Vxlan
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# 字符串键规范化后计算语义指纹;Hash 顺序不影响结果,数组保留业务顺序。
|
|
37
|
-
def self.fingerprint(value) =
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
def self.canonical(value)
|
|
41
|
-
case value
|
|
42
|
-
when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |entry| canonical(entry) }
|
|
43
|
-
when Array then value.map { |entry| canonical(entry) }
|
|
44
|
-
else value
|
|
45
|
-
end
|
|
46
|
-
end
|
|
37
|
+
def self.fingerprint(value) = CtxRunner::Fingerprint.call(value)
|
|
38
|
+
|
|
39
|
+
# 兼容既有领域入口,配置身份协议仅由 Runner 实现。
|
|
40
|
+
def self.canonical(value) = CtxRunner::Fingerprint.canonical(value)
|
|
47
41
|
|
|
48
42
|
# 构造时一次校验和渲染,随后调用者不能改变已确认的参数或命令。
|
|
49
43
|
def initialize(change)
|
data/lib/vxlan/cli/apply.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "ctx_cli/terminal"
|
|
4
5
|
require "json"
|
|
5
6
|
require "digest"
|
|
7
|
+
require "ctx_runner/execution"
|
|
6
8
|
require "ipaddr"
|
|
7
9
|
require "vxlan"
|
|
8
10
|
require_relative "report"
|
|
@@ -45,7 +47,7 @@ module Vxlan
|
|
|
45
47
|
|
|
46
48
|
# 没有 --yes 或自动回退;零退出码只确认配置回读,不声称业务已验收。
|
|
47
49
|
def run(argv)
|
|
48
|
-
@store, @
|
|
50
|
+
@store, @execution = nil, nil
|
|
49
51
|
options = { directory: File.join(Dir.pwd, "vxlan-executions"), port: 22 }
|
|
50
52
|
parser = OptionParser.new do |p|
|
|
51
53
|
p.banner = "用法:vxlan apply --plan FILE --item ID [--env-file FILE](必须真实终端明确确认)"
|
|
@@ -58,7 +60,8 @@ module Vxlan
|
|
|
58
60
|
end
|
|
59
61
|
rest = parser.parse(argv.dup)
|
|
60
62
|
return @out.puts(parser) || 0 if options[:help]
|
|
61
|
-
|
|
63
|
+
terminal = CtxCli::Terminal.new(input: @input, out: @out)
|
|
64
|
+
terminal.require_tty!(@error)
|
|
62
65
|
raise ArgumentError, "需要单个计划" unless rest.empty? && options[:plan] && options[:port].between?(1, 65_535)
|
|
63
66
|
body = File.read(options[:plan])
|
|
64
67
|
hash = Digest::SHA256.hexdigest(body)
|
|
@@ -70,48 +73,21 @@ module Vxlan
|
|
|
70
73
|
@out.puts Array(plan["prerequisites"]).map { |value| "条件:#{value}" }
|
|
71
74
|
@out.puts "计划 SHA256:#{hash}\n拟执行命令:\n#{change.commands.join("\n")}\n条件回退(不自动执行):\n#{change.rollback.join("\n")}"
|
|
72
75
|
token = "APPLY #{host} #{hash[0, 12]} #{options[:item]}"
|
|
73
|
-
|
|
74
|
-
@out.
|
|
75
|
-
return @out.puts("已取消,未连接设备。") || 1 unless @input.gets&.strip == token
|
|
76
|
+
confirmed = terminal.confirm(token: token, prompt: "确认业务策略及维护窗口后输入 #{token};其他输入取消:")
|
|
77
|
+
return @out.puts("已取消,未连接设备。") || 1 unless confirmed
|
|
76
78
|
settings = credentials(options)
|
|
77
79
|
@store = Report.new(options[:directory])
|
|
78
|
-
@
|
|
79
|
-
@
|
|
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)
|
|
80
|
+
@execution = CtxRunner::Execution.new(plan: plan, change: change, journal: @store, plan_sha256: hash, item: options[:item])
|
|
81
|
+
@execution.run { |operation| with_session(plan["device"], settings, &operation) }
|
|
106
82
|
@out.puts "配置回读已确认;仍需完成业务验收,未执行保存配置。日志:#{@store.directory}/execution.json"
|
|
107
83
|
0
|
|
108
84
|
rescue Interrupt
|
|
109
|
-
|
|
110
|
-
@error.puts "操作中断;#{@started ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
|
|
85
|
+
report_journal_failure
|
|
86
|
+
@error.puts "操作中断;#{@execution&.started? ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
|
|
111
87
|
130
|
|
112
88
|
rescue StandardError, LoadError => error
|
|
113
|
-
|
|
114
|
-
@error.puts "下发停止(#{error.class.name});#{@started ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
|
|
89
|
+
report_journal_failure
|
|
90
|
+
@error.puts "下发停止(#{error.class.name});#{@execution&.started? ? '效果未完整确认,先回读,不自动重放或回退。' : '未下发业务配置;检查终端、计划、凭据及当前配置是否仍匹配。'}"
|
|
115
91
|
2
|
|
116
92
|
end
|
|
117
93
|
|
|
@@ -158,14 +134,12 @@ module Vxlan
|
|
|
158
134
|
end
|
|
159
135
|
end
|
|
160
136
|
|
|
161
|
-
#
|
|
162
|
-
def
|
|
163
|
-
return unless @
|
|
164
|
-
@state["status"] = @started ? "uncertain" : "failed"
|
|
165
|
-
@state["error"] = code
|
|
166
|
-
@store.write_execution(@state)
|
|
167
|
-
rescue StandardError, Interrupt
|
|
137
|
+
# 公共执行器保存失败状态;CLI 只呈现持久性尚未确认的诊断。
|
|
138
|
+
def report_journal_failure
|
|
139
|
+
return unless @execution&.journal_failed?
|
|
168
140
|
@error.puts "执行日志收尾未确认;保留已有检查点,禁止据此重放配置。"
|
|
141
|
+
rescue StandardError, Interrupt
|
|
142
|
+
nil
|
|
169
143
|
end
|
|
170
144
|
end
|
|
171
145
|
end
|
data/lib/vxlan/cli/collect.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "vxlan"
|
|
4
|
+
require "ctx_runner/batch"
|
|
5
|
+
require "ctx_cli/collection"
|
|
4
6
|
require_relative "options"
|
|
5
7
|
require_relative "report"
|
|
6
8
|
require_relative "connector"
|
|
@@ -14,89 +16,33 @@ module Vxlan
|
|
|
14
16
|
@env, @out, @error, @input = env, out, error, input
|
|
15
17
|
@connector_factory, @executor = connector_factory, executor
|
|
16
18
|
@defaults = options
|
|
17
|
-
@lock = Mutex.new
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
#
|
|
21
|
+
# 公共入口编排顺序与失败收尾;以下方法是显式领域适配协议。
|
|
21
22
|
def run(argv)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
23
|
+
progress = ->(options) do
|
|
24
|
+
require "net/connector/collect/progress"
|
|
25
|
+
Net::Connector::Collect::Progress.new(io: @error, enabled: !options.flags[:quiet], verbose: options.flags[:verbose])
|
|
31
26
|
end
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
27
|
+
CtxCli::Collection.new(adapter: self, options: ->(arguments) { Options.new(arguments, env: @env, out: @out, **@defaults) },
|
|
28
|
+
progress: progress, batch: CtxRunner::Batch, label: "VXLAN", report_error: Report::Error,
|
|
29
|
+
failure_code: ->(error) { CtxDriver::Failure.code(error, :collection_failed) },
|
|
30
|
+
out: @out, error: @error, input: @input).run(argv)
|
|
83
31
|
end
|
|
84
32
|
|
|
85
|
-
private
|
|
86
|
-
|
|
87
33
|
# 只在需要库存或网络执行时加载可选依赖,帮助和静态预览保持独立。
|
|
88
34
|
def load_network
|
|
89
35
|
require "net/connector"
|
|
90
36
|
rescue LoadError
|
|
91
|
-
raise LoadError, "联网采集需要 net-connector >= 0.
|
|
37
|
+
raise LoadError, "联网采集需要 net-connector >= 0.17.1;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
|
|
92
38
|
end
|
|
93
39
|
|
|
94
40
|
# YAML 静态设备或显式 Netdisco 来源二选一;所有重复地址在连接前拒绝。
|
|
95
|
-
def inventory
|
|
96
|
-
values =
|
|
97
|
-
rows = if
|
|
98
|
-
|
|
99
|
-
settings =
|
|
41
|
+
def inventory(options, progress:)
|
|
42
|
+
values = options.values
|
|
43
|
+
rows = if options.dynamic_inventory?
|
|
44
|
+
progress.reading_inventory
|
|
45
|
+
settings = options.netdisco_settings
|
|
100
46
|
Net::Connector::Netdisco::Fleet.new(settings: settings).devices.filter_map do |device|
|
|
101
47
|
next unless device.ready? && device.vendor == :cisco_nxos
|
|
102
48
|
{ "host" => device.host, "name" => device.name || device.host, "model" => device.model }
|
|
@@ -104,107 +50,63 @@ module Vxlan
|
|
|
104
50
|
else
|
|
105
51
|
values.fetch("devices")
|
|
106
52
|
end
|
|
107
|
-
targets =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) }
|
|
53
|
+
targets = options.normalize_devices(rows)
|
|
54
|
+
total = targets.size
|
|
55
|
+
selection = Net::Connector::Inventory::Filters.selection(values)
|
|
56
|
+
selected = targets.select do |device|
|
|
57
|
+
selection.selected?(device.fetch("host"), "cisco_nxos", name: device["name"], model: device["model"])
|
|
117
58
|
end
|
|
59
|
+
[ selected, total ]
|
|
118
60
|
end
|
|
119
61
|
|
|
62
|
+
# 所有设备先完成凭据、端点与资源预检,再允许创建报告和连接。
|
|
63
|
+
def prepare(devices, options)
|
|
64
|
+
credentials = credentials!(options)
|
|
65
|
+
load_network unless @connector_factory && @executor
|
|
66
|
+
unless @connector_factory
|
|
67
|
+
devices.each { |device| Net::Connector::Configuration.new(host: device.fetch("host"), **credentials).validate_endpoint! }
|
|
68
|
+
end
|
|
69
|
+
unless @executor
|
|
70
|
+
require "net/connector/resources"
|
|
71
|
+
Net::Connector::Resources.prepare!(parallel: [ devices.size, options.values.fetch("parallel") ].min)
|
|
72
|
+
end
|
|
73
|
+
credentials
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def report(options) = Report.new(options.values.fetch("directory"), scripts: options.flags[:scripts] == true)
|
|
77
|
+
def after_collect(report, options) = report.review_collected_fabric(options.fabric_policy)
|
|
78
|
+
|
|
120
79
|
# 缺失用户名时不尝试连接;密码允许省略以支持明确的 SSH key/agent。
|
|
121
|
-
def credentials!
|
|
122
|
-
username = secret("NC_CISCO_NXOS_USERNAME") || secret("NC_DEVICE_USERNAME")
|
|
80
|
+
def credentials!(options)
|
|
81
|
+
username = secret(options, "NC_CISCO_NXOS_USERNAME") || secret(options, "NC_DEVICE_USERNAME")
|
|
123
82
|
raise ArgumentError, "请设置 NC_DEVICE_USERNAME 或 NC_CISCO_NXOS_USERNAME;密码可通过环境或 SSH key/agent 提供" unless username
|
|
124
|
-
|
|
125
|
-
password: secret("NC_CISCO_NXOS_PASSWORD") || secret("NC_DEVICE_PASSWORD"), enable_password: secret("NC_ENABLE_PASSWORD"))
|
|
83
|
+
options.values.fetch("ssh").transform_keys(&:to_sym).merge(username: username,
|
|
84
|
+
password: secret(options, "NC_CISCO_NXOS_PASSWORD") || secret(options, "NC_DEVICE_PASSWORD"), enable_password: secret(options, "NC_ENABLE_PASSWORD"))
|
|
126
85
|
end
|
|
127
86
|
|
|
128
87
|
# 空白环境值视为缺失,不修改或回显凭据。
|
|
129
|
-
def secret(name)
|
|
130
|
-
value =
|
|
88
|
+
def secret(options, name)
|
|
89
|
+
value = options.environment[name]
|
|
131
90
|
value unless value.nil? || value.strip.empty?
|
|
132
91
|
end
|
|
133
92
|
|
|
134
93
|
# 每个任务使用独立 Collector 和会话;公共执行器负责有界并发和线程回收。
|
|
135
|
-
def execute(devices, &block)
|
|
136
|
-
parallel =
|
|
94
|
+
def execute(devices, options, &block)
|
|
95
|
+
parallel = options.values.fetch("parallel")
|
|
137
96
|
return @executor.call(devices, parallel, &block) if @executor
|
|
138
97
|
Net::Connector::Parallel::Pool.new(parallel: parallel).map(devices, &block)
|
|
139
98
|
end
|
|
140
99
|
|
|
141
100
|
# 单台失败保留其他设备和已获得维度;报告持久化失败交由批次终止处理。
|
|
142
|
-
def collect(device,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
101
|
+
def collect(device, options, context:, progress:)
|
|
102
|
+
credentials = context.merge(on_event: progress.method(:event))
|
|
103
|
+
connector = @connector_factory ? @connector_factory.call(device, credentials) : Connector.new(device, credentials)
|
|
104
|
+
snapshot = Vxlan::Collector.new.collect(connector: connector, target: device, fabric_key: options.values.fetch("fabric"),
|
|
105
|
+
model: device["model"], dimensions: options.values.fetch("dimensions").map(&:to_sym))
|
|
106
|
+
snapshot
|
|
196
107
|
end
|
|
197
108
|
|
|
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
|
|
109
|
+
private :load_network, :credentials!, :secret
|
|
208
110
|
end
|
|
209
111
|
end
|
|
210
112
|
end
|
data/lib/vxlan/cli/options.rb
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "ctx_cli/options"
|
|
5
|
+
require "ctx_cli/terminal"
|
|
6
|
+
require "ctx_cli/initialization"
|
|
7
|
+
require "net/connector/inventory/filters"
|
|
4
8
|
require "yaml"
|
|
5
9
|
require "ipaddr"
|
|
6
|
-
require "fileutils"
|
|
7
|
-
require "tempfile"
|
|
8
10
|
|
|
9
11
|
module Vxlan
|
|
10
12
|
module CLI
|
|
11
13
|
# 采集入口的公开参数;凭据始终留在环境中,不传入解析库或 YAML。
|
|
12
14
|
class Options
|
|
13
|
-
FIELDS = %w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh
|
|
15
|
+
FIELDS = (%w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh] + Net::Connector::Inventory::Filters::FIELDS).freeze
|
|
14
16
|
SSH_FIELDS = %w[port command_timeout login_timeout host_key_policy known_hosts].freeze
|
|
15
17
|
SSH_DEFAULTS = { "host_key_policy" => "strict", "command_timeout" => 60, "login_timeout" => 15 }.freeze
|
|
16
18
|
attr_reader :values, :config, :env_file, :flags, :fabric_policy, :environment
|
|
@@ -46,7 +48,7 @@ module Vxlan
|
|
|
46
48
|
|
|
47
49
|
# 自动化拒绝强制问答;帮助分支无需检查终端或消费输入。
|
|
48
50
|
def validate_interaction!(input:, error:)
|
|
49
|
-
if flags[:interactive] && !
|
|
51
|
+
if flags[:interactive] && !CtxCli::Terminal.tty?(input, error)
|
|
50
52
|
raise ArgumentError, "--interactive 需要交互终端;自动化请使用 --init --non-interactive"
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -62,7 +64,7 @@ module Vxlan
|
|
|
62
64
|
@environment = @files.environment
|
|
63
65
|
@config, @env_file = @files.config, @files.env_file
|
|
64
66
|
rescue LoadError
|
|
65
|
-
raise LoadError, "采集配置需要 net-connector >= 0.
|
|
67
|
+
raise LoadError, "采集配置需要 net-connector >= 0.17.1;读取 .env 还需要 dotenv", cause: nil
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
# 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
|
|
@@ -149,22 +151,11 @@ module Vxlan
|
|
|
149
151
|
end
|
|
150
152
|
overrides_parallel = @overrides.key?("parallel") || parallel_source&.fetch("VXLAN_PARALLEL", nil)
|
|
151
153
|
document = overrides_parallel ? values : values.reject { |key, _| key == "parallel" }
|
|
152
|
-
template = template.lines.select { |line| line.start_with?("#") }.join +
|
|
154
|
+
template = template.lines("\n").select { |line| line.start_with?("#") }.join("") +
|
|
153
155
|
Net::Connector::Config.dump(document, name: "vxlan", path: config)
|
|
154
156
|
{ config => template, env_file => File.read(File.join(__dir__, "templates/env.example")) }.each do |path, body|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
Tempfile.create([ ".vxlan-init-", ".tmp" ], File.dirname(path)) do |file|
|
|
158
|
-
file.chmod(0o600)
|
|
159
|
-
file.write(body)
|
|
160
|
-
file.flush
|
|
161
|
-
file.fsync
|
|
162
|
-
File.link(file.path, path)
|
|
163
|
-
end
|
|
164
|
-
@out.puts "已创建:#{path}"
|
|
165
|
-
rescue Errno::EEXIST
|
|
166
|
-
@out.puts "已保留:#{path}"
|
|
167
|
-
end
|
|
157
|
+
status = CtxCli::Initialization.new(prefix: ".vxlan-init-").create(path: path, contents: body)
|
|
158
|
+
@out.puts "#{status == :created ? '已创建' : '已保留'}:#{path}"
|
|
168
159
|
end
|
|
169
160
|
@out.puts "请检查配置及凭据后再次运行;本次未读取清单、未连接设备。"
|
|
170
161
|
end
|
|
@@ -174,10 +165,10 @@ module Vxlan
|
|
|
174
165
|
@files.load
|
|
175
166
|
@config, @env_file = @files.config, @files.env_file
|
|
176
167
|
return false unless @files.missing?
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if
|
|
168
|
+
terminal = CtxCli::Terminal.new(input: input, out: error)
|
|
169
|
+
if terminal.tty? && flags[:interactive] != false
|
|
170
|
+
answer = terminal.question("配置不存在:#{config};是否创建配置与环境模板?[y/N] ")
|
|
171
|
+
if answer&.match?(/\Ay(?:es)?\z/i)
|
|
181
172
|
initialize_files
|
|
182
173
|
return true
|
|
183
174
|
end
|
|
@@ -231,40 +222,37 @@ module Vxlan
|
|
|
231
222
|
def parser
|
|
232
223
|
OptionParser.new do |p|
|
|
233
224
|
p.banner = "用法:#{@command} [选项](只读 VXLAN 采集)"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
225
|
+
CtxCli::Options.configuration(p, parallel_help: "设备并发 1~500;继承 NC_PARALLEL,未配置时 4") do |key, value|
|
|
226
|
+
case key
|
|
227
|
+
when :config then @explicit_config = File.expand_path(value, Dir.pwd)
|
|
228
|
+
when :env_file then @explicit_env_file = File.expand_path(value, Dir.pwd)
|
|
229
|
+
when :directory then @overrides["directory"] = File.expand_path(value, Dir.pwd)
|
|
230
|
+
when :parallel then @overrides["parallel"] = value
|
|
231
|
+
end
|
|
232
|
+
end
|
|
238
233
|
p.on("--fabric-policy FILE", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
|
|
239
234
|
p.on("--fabric NAME", "采集所属 Fabric 身份") { |v| @overrides["fabric"] = v }
|
|
240
235
|
p.on("--dimensions LIST", "逗号分隔维度;--help 查看支持项") { |v| @overrides["dimensions"] = list(v) }
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
236
|
+
Net::Connector::Inventory::Filters.add_options(p) do |key, value|
|
|
237
|
+
if value.is_a?(Array)
|
|
238
|
+
(@overrides[key] ||= []).concat(value)
|
|
239
|
+
else
|
|
240
|
+
@overrides[key] = value
|
|
241
|
+
end
|
|
244
242
|
end
|
|
245
|
-
|
|
246
|
-
p.on("--interactive", "缺少配置时询问是否创建;要求真实终端") { flags[:interactive] = true }
|
|
247
|
-
p.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { flags[:interactive] = false }
|
|
243
|
+
CtxCli::Options.initialization(p, flags)
|
|
248
244
|
p.on("--plan", "--inventory-only", "预览筛选清单,不连接设备") { flags[:inventory_only] = true }
|
|
249
245
|
p.on("--show-config", "查看公开有效配置") { flags[:show_config] = true }
|
|
250
|
-
|
|
251
|
-
p.on("--quiet", "--no-progress", "关闭 stderr 进度,可配合 --json") { flags[:quiet] = true }
|
|
246
|
+
CtxCli::Options.output(p, flags)
|
|
252
247
|
p.on("--[no-]scripts", "可选导出配置建议,默认仅诊断") { |value| flags[:scripts] = value }
|
|
253
|
-
|
|
254
|
-
p.on("-h", "--help", "查看选项帮助") { flags[:help] = true }
|
|
255
|
-
p.on("--help-all", "查看全部选项、路径与环境约定") { flags[:help_all] = true }
|
|
256
|
-
p.on("--examples", "查看常用命令范例,不执行任务") { flags[:examples] = true }
|
|
248
|
+
CtxCli::Options.information(p, flags)
|
|
257
249
|
p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
258
250
|
p.separator "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。退出码:0 完整/预览;1 未完成/无目标;2 参数/持久化错误;130 取消。"
|
|
259
251
|
end
|
|
260
252
|
end
|
|
261
253
|
|
|
262
254
|
# 列表中的空项属于输入错误,不能被静默删掉。
|
|
263
|
-
def list(value)
|
|
264
|
-
values = value.split(",", -1).map(&:strip)
|
|
265
|
-
raise ArgumentError, "列表不能有空项" if values.any?(&:empty?)
|
|
266
|
-
values
|
|
267
|
-
end
|
|
255
|
+
def list(value) = CtxCli::Options.list(value)
|
|
268
256
|
|
|
269
257
|
# 先验证整批策略,避免在前面设备连接后才发现后面的非法配置。
|
|
270
258
|
def validate!
|
|
@@ -286,11 +274,8 @@ module Vxlan
|
|
|
286
274
|
raise ArgumentError, "netdisco_config 须为配置路径,且不能同时声明 devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
|
|
287
275
|
values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
|
|
288
276
|
end
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
raise ArgumentError, "#{key} 须为非空字符串数组" unless items.is_a?(Array) && items.all? { |v| v.is_a?(String) && !v.strip.empty? }
|
|
292
|
-
end
|
|
293
|
-
%w[hosts exclude_hosts].each { |key| values[key].each { |v| IPAddr.new(v) } }
|
|
277
|
+
Net::Connector::Inventory::Filters::LISTS.each_key { |key| values[key] = values.fetch(key, []) }
|
|
278
|
+
Net::Connector::Inventory::Filters.selection(values)
|
|
294
279
|
ssh = values["ssh"]
|
|
295
280
|
raise ArgumentError, "ssh 包含未知或敏感字段" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
|
|
296
281
|
ssh = SSH_DEFAULTS.merge(ssh)
|