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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2efb2207fd79cd9e4ef9724ebca3a5da93aa10db56719c1a0b6f4ac3562ba444
4
- data.tar.gz: 73730c534034650b6bf44e917675dab25e445f5dcb1967263f3610a2324f3830
3
+ metadata.gz: 203d6c19eed76f37e033ba015e36a39953de0ba63dc5fd4130873a83f1e097c3
4
+ data.tar.gz: bb21ff1519721b7ee99cdedc5af2a3ac34c8542563c06ce9d3cd1328c31c4d6f
5
5
  SHA512:
6
- metadata.gz: 869198020e0207f4896732548a88cf2c3b0abef61dd143c025636021b49b513b54848645a10b26a18624961386eaf524cd9f2588da7d7b7843812e549d8ad0ff
7
- data.tar.gz: fcf9a413bd76e02fcae70f8af7f52e5de9ebd5ddf836bd86d3e45c4f404368bb3bdcdb1a5491346b1220ddc8f2a5d250fd418ec6bbcde8255c6a4576ff4b2a57
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.16.0`(已包含 `.env` 加载依赖 dotenv)。本仓库 Gemfile 的 `examples` 组已声明连接器,不进入 vxlan.gemspec 的运行依赖。只安装 vxlan gem 的宿主需要自行声明相应可选依赖。
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.16.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 候选包。
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
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) = Digest::SHA256.hexdigest(JSON.generate(canonical(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)
@@ -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, @state, @started = nil, nil, false
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
- raise ArgumentError, "必须使用真实交互终端" unless @input.tty? && @out.tty? && @error.tty?
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
- @out.print "确认业务策略及维护窗口后输入 #{token};其他输入取消:"
74
- @out.flush
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
- @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)
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
- record_failure("interrupted")
110
- @error.puts "操作中断;#{@started ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
85
+ report_journal_failure
86
+ @error.puts "操作中断;#{@execution&.started? ? '设备效果可能不完整,请先回读核对,禁止重放。' : '尚未下发业务配置。'}"
111
87
  130
112
88
  rescue StandardError, LoadError => error
113
- record_failure(error.class.name)
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 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
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
@@ -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
- @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
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
- 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
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.16.0;请在宿主 Gemfile 添加或 gem install net-connector", cause: nil
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 = @options.values
97
- rows = if @options.dynamic_inventory?
98
- @progress.reading_inventory
99
- settings = @options.netdisco_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 = @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) }
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
- @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"))
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 = @options.environment[name]
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 = @options.values.fetch("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, 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
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
@@ -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 hosts exclude_hosts names exclude_names].freeze
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] && !(input.tty? && error.tty?)
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.16.0;读取 .env 还需要 dotenv", cause: nil
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
- FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
156
- begin
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
- if input.tty? && error.tty? && flags[:interactive] != false
178
- error.print "配置不存在:#{config};是否创建配置与环境模板?[y/N] "
179
- error.flush
180
- if input.gets&.strip&.match?(/\Ay(?:es)?\z/i)
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
- p.on("--config FILE", "最高优先级 YAML,叠加系统/用户/脚本/工作目录配置") { |v| @explicit_config = File.expand_path(v) }
235
- p.on("--env-file FILE", "最高优先级环境文件;进程环境仍优先") { |v| @explicit_env_file = File.expand_path(v) }
236
- p.on("-p", "--parallel N", Integer, "设备并发 1~500;继承 NC_PARALLEL,未配置时 4") { |v| @overrides["parallel"] = v }
237
- p.on("--directory DIR", "按设备保存报告的目录") { |v| @overrides["directory"] = File.expand_path(v) }
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
- { "--host IP" => "hosts", "--hosts LIST" => "hosts", "--exclude-hosts LIST" => "exclude_hosts",
242
- "--name LIST" => "names", "--exclude-name LIST" => "exclude_names" }.each do |option, key|
243
- p.on(option, "筛选已有清单,同类条件取并集,排除优先") { |v| (@overrides[key] ||= []).concat(list(v)) }
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
- p.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { flags[:init] = true }
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
- p.on("--json", "stdout 输出 JSON;默认显示可读摘要") { flags[:json] = true }
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
- p.on("--verbose", "显示逐台结果及安全命令事件") { flags[:verbose] = true }
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
- %w[hosts exclude_hosts names exclude_names].each do |key|
290
- items = values[key] = values.fetch(key, [])
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)