net-connector 0.16.1 → 0.17.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: 8afbc2a37dcb0b9b96be6fd1af171201cb5315803452632f4af2be0db4d5a297
4
- data.tar.gz: f11815c48829959e83e89b2bfd37956c4ee9c8eded44c92c200445292fe458c0
3
+ metadata.gz: 33503c92ccbe8f7000652c0b47b967a8f2f40e6c9edd7081ba305c3246efe820
4
+ data.tar.gz: f2d4c434caab1b888d1b4cd9bf25a0def4e7e26eb63b2e6e5e93d8b56cc34c0d
5
5
  SHA512:
6
- metadata.gz: 4de68d2427f07acaca0820aac064faeabad7d73a4be81e3286566affb3300a84511955a1f5b721751a488b38651cafe0226a2daecd1bed54d8521f7967b0bd48
7
- data.tar.gz: c5d55197568da8f3a7945dd44697ad79d840ca7416408c57b9d605f1e2afb772d0a634eff72102e364e81fee6fdbb526a020300fa8085ac2018b58866cf96b96
6
+ metadata.gz: e8c126666632d1f4a708cbcc42c1df5ad434d37978a55fe2db363e3628f2c7e2779192d05ad7d8989933d389d92a6de520669a93a47c46209816a85d423abcca
7
+ data.tar.gz: ad704438b1ed05942371fe3f57a7ebf66f563261406682013b18f57c8cfbc8b01a190a41a685a2991f4d53ff3e2912d2cf8c8e272b593bc83e1a439c821ef349
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.17.1 - 2026-10-22
4
+
5
+ - Delegate shared configuration, private initialization, option groups and terminal interaction to ctx_cli 0.1.0.
6
+
7
+ ## 0.17.0 - 2026-10-22
8
+
9
+ - Centralize inventory selection and CLI filters in Net::Connector::Inventory; add vendor/model inclusion, exclusion and matching modes.
10
+
3
11
  ## 未发布
4
12
 
5
13
  ## 0.16.1 - 2026-10-21
data/README.md CHANGED
@@ -62,3 +62,7 @@ script/ci
62
62
  The gate scans secrets, lints Ruby and workflows, runs tests and local PTY benchmarks, and verifies isolated gem installs. See [CONTRIBUTING](CONTRIBUTING.md), [security reporting](SECURITY.md), and the [changelog](CHANGELOG.md). Issues and pull requests may be written in English or Chinese.
63
63
 
64
64
  Licensed under [MIT](LICENSE).
65
+
66
+ ## ctx_cli 公共入口
67
+
68
+ 宿主配置文件读取、私有文件初始化、公共参数与终端交互由 ctx_cli 维护。原 `Net::Connector::Config`、配置路径、优先级和 backup/TFTP/topology 策略继续保留。源码联调使用 `CTX_CLI_PATH=../ctx_cli`;隔离安装可通过 `CTX_CLI_GEM=/absolute/path/ctx_cli-0.1.0.gem` 传入精确候选。
@@ -1,366 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
3
+ require "ctx_cli/configuration"
4
4
 
5
5
  module Net
6
6
  module Connector
7
- # Host-side configuration discovery. Requiring this file never loads device I/O.
8
- class Config
7
+ # 保留原配置入口和路径政策;通用发现、来源与快照由 ctx_cli 维护。
8
+ class Config < CtxCli::Configuration
9
9
  NAMES = %w[vxlan wireless backup topology].freeze
10
10
  COMMON_KEYS = %w[netdisco inventory ssh].freeze
11
11
  CONFIG_ENV = { "vxlan" => "VXLAN_CONFIG", "wireless" => "WIRELESS_CONFIG",
12
12
  "backup" => "NC_CONFIG", "topology" => "NC_CONFIG" }.freeze
13
13
 
14
- attr_reader :data, :specific, :process_environment, :file_environment, :environment,
15
- :config_files, :environment_files, :sources
16
-
17
- def initialize(name:, env: ENV, script: $PROGRAM_NAME, cwd: Dir.pwd, home: Dir.home,
18
- directories: nil, config: nil, env_file: nil, config_env: nil,
19
- path_fields: [], env_path_keys: [], path_literals: {})
20
- @name = name.to_s
21
- raise ArgumentError, "unsupported configuration name: #{@name}" unless NAMES.include?(@name)
22
-
23
- @cwd = File.expand_path(cwd)
24
- @home = File.expand_path(home)
25
- @config_env = config_env || CONFIG_ENV.fetch(@name)
26
- @env_path_keys = (env_path_keys.map(&:to_s) + [@config_env]).uniq
27
- @path_literals = self.class.freeze_tree(self.class.copy(path_literals.transform_keys(&:to_s)))
28
- @process_environment = self.class.copy(env.to_h)
29
- resolve_environment_paths!(@process_environment, @cwd)
30
- self.class.freeze_tree(@process_environment)
31
- @user_directory = File.join(user_config_home, "net-connector")
32
- @directories = (directories || ["/etc/net-connector", "/usr/local/etc/net-connector", @user_directory,
33
- File.dirname(File.expand_path(script, @cwd)), @cwd]).map do |path|
34
- File.expand_path(path, @cwd)
35
- end.uniq
36
- @explicit_config = absolute(config, @cwd)
37
- @explicit_env_file = absolute(env_file, @cwd)
38
- @path_fields = path_fields.map { |path| path.to_s.split(".") }
39
- @data = @specific = @file_environment = @environment = {}.freeze
40
- @config_files = @environment_files = [].freeze
41
- @sources = { "configuration_files" => @config_files, "environment_files" => @environment_files, "fields" => {}.freeze }.freeze
42
- end
43
-
44
- # Read environment files without mutating ENV or evaluating any YAML.
45
- def load_environment(allow_missing: false)
46
- return self if @environment_loaded
47
-
48
- files = candidates(".env", @explicit_env_file)
49
- values = {}
50
- loaded = []
51
- files.each do |path|
52
- next unless readable_file?(path, explicit: path == @explicit_env_file, allow_missing: allow_missing)
53
-
54
- parsed = parse_environment(path, values.merge(@process_environment))
55
- resolve_environment_paths!(parsed, File.dirname(path))
56
- values.merge!(parsed)
57
- loaded << path
58
- end
59
- @file_environment = self.class.freeze_tree(values)
60
- @environment = self.class.freeze_tree(values.merge(self.class.copy(@process_environment)))
61
- @environment_files = self.class.freeze_tree(loaded)
62
- # Empty selectors are absent, including an empty original process variable.
63
- selector = present(@process_environment[@config_env]) || present(values[@config_env])
64
- @selected_config = @explicit_config || absolute(selector, @cwd)
65
- @environment_loaded = true
66
- update_sources({})
67
- self
68
- end
69
-
70
- # Freeze one batch snapshot; repeated calls do not re-read files.
71
- def load(allow_missing: false)
72
- return self if @loaded
73
-
74
- load_environment(allow_missing: allow_missing)
75
- merged = {}
76
- specific = {}
77
- fields = {}
78
- loaded = []
79
- @applicable = false
80
- paths = @directories.flat_map { |directory| [File.join(directory, "net-connector.yml"), File.join(directory, "#{@name}.yml")] }
81
- paths = final_candidates(paths, @selected_config)
82
- paths.each do |path|
83
- next unless readable_file?(path, explicit: path == @selected_config, allow_missing: allow_missing)
84
-
85
- document = read_yaml(path)
86
- if File.basename(path) == "net-connector.yml"
87
- common, native, applicable = shared_sections(document, path)
88
- merged = merge_layer(merged, common, path, fields)
89
- @applicable ||= applicable
90
- else
91
- native = document
92
- validate_string_keys!(native, path)
93
- @applicable = true
94
- end
95
- merged = merge_layer(merged, native, path, fields)
96
- specific = self.class.merge(specific, resolve_paths(native, path))
97
- loaded << path
98
- end
99
- @data = self.class.freeze_tree(merged)
100
- @specific = self.class.freeze_tree(specific)
101
- @config_files = self.class.freeze_tree(loaded)
102
- update_sources(fields)
103
- @loaded = true
104
- self
105
- end
106
-
107
- def config
108
- @selected_config || @explicit_config || @config_files.last || init_config
109
- end
110
-
111
- def env_file
112
- @explicit_env_file || @environment_files.last || init_env_file
113
- end
114
-
115
- def init_config
116
- @selected_config || @explicit_config || File.join(@user_directory, "#{@name}.yml")
117
- end
118
-
119
- def init_env_file
120
- @explicit_env_file || File.join(@user_directory, ".env")
121
- end
122
-
123
- def missing?
124
- !@applicable
125
- end
126
-
127
- # Serialize native values using the same target-file schema as discovery.
128
- def self.dump(document, name:, path:)
14
+ def initialize(name:, config_env: nil, **options)
129
15
  name = name.to_s
130
16
  raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name)
131
17
 
132
- YAML.dump(File.basename(path) == "net-connector.yml" ? { name => document } : document)
133
- end
134
-
135
- # Recursive, non-destructive replacement: arrays, false and nil are values.
136
- def self.merge(*hashes)
137
- hashes.reduce({}) do |result, hash|
138
- hash.each do |key, value|
139
- result[copy(key)] = result[key].is_a?(Hash) && value.is_a?(Hash) ? merge(result[key], value) : copy(value)
140
- end
141
- result
142
- end
143
- end
144
-
145
- def self.copy(value)
146
- case value
147
- when Hash then value.to_h { |key, entry| [copy(key), copy(entry)] }
148
- when Array then value.map { |entry| copy(entry) }
149
- when String then value.dup
150
- else value
151
- end
152
- end
153
-
154
- def self.freeze_tree(value)
155
- case value
156
- when Hash then value.each { |key, entry| freeze_tree(key); freeze_tree(entry) }
157
- when Array then value.each { |entry| freeze_tree(entry) }
158
- end
159
- value.freeze
160
- end
161
-
162
- private
163
-
164
- def present(value)
165
- value unless value.nil? || value.to_s.strip.empty?
166
- end
167
-
168
- def absolute(path, directory)
169
- File.expand_path(path.to_s, directory) if present(path)
170
- end
171
-
172
- def user_config_home
173
- absolute(present(@process_environment["XDG_CONFIG_HOME"]), @cwd) || File.join(@home, ".config")
174
- end
175
-
176
- def candidates(filename, explicit)
177
- final_candidates(@directories.map { |directory| File.join(directory, filename) }, explicit)
178
- end
179
-
180
- def final_candidates(paths, explicit)
181
- paths = paths.reject { |path| path == explicit } + [explicit] if explicit
182
- paths.uniq
183
- end
184
-
185
- def readable_file?(path, explicit:, allow_missing:)
186
- unless File.exist?(path)
187
- raise ArgumentError, "configuration file does not exist: #{path}" if explicit && !allow_missing
188
-
189
- return false
190
- end
191
- return true if File.file?(path) && File.readable?(path)
192
-
193
- raise ArgumentError, "configuration path is not a readable regular file: #{path}"
194
- end
195
-
196
- def read_yaml(path)
197
- contents = File.read(path)
198
- validate_yaml_structure!(contents, path)
199
- document = YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: false)
200
- document = {} if document.nil?
201
- raise ArgumentError, "configuration must contain a mapping: #{path}" unless document.is_a?(Hash)
202
-
203
- document
204
- rescue Psych::Exception, SystemCallError, IOError => error
205
- raise ArgumentError, "cannot read configuration file: #{path} (#{error.class})", cause: nil
206
- end
207
-
208
- # Overrides are explicit file layers; one file cannot hide duplicate keys
209
- # or silently discard later documents before the tool validates its scope.
210
- def validate_yaml_structure!(contents, path)
211
- stream = YAML.parse_stream(contents)
212
- raise ArgumentError, "configuration must contain a single YAML document: #{path}" if stream.children.size > 1
213
-
214
- stream.each do |node|
215
- next unless node.is_a?(Psych::Nodes::Mapping)
216
-
217
- keys = {}
218
- node.children.each_slice(2) do |key, _|
219
- next unless key.is_a?(Psych::Nodes::Scalar)
220
- if keys.key?(key.value)
221
- raise ArgumentError, "duplicate configuration key at line #{key.start_line + 1}: #{path}"
222
- end
223
-
224
- keys[key.value] = true
225
- end
226
- end
227
- end
228
-
229
- def validate_string_keys!(value, path)
230
- case value
231
- when Hash
232
- raise ArgumentError, "configuration keys must be strings: #{path}" unless value.keys.all?(String)
233
-
234
- value.each_value { |entry| validate_string_keys!(entry, path) }
235
- when Array
236
- value.each { |entry| validate_string_keys!(entry, path) }
237
- end
18
+ super(name: name, config_env: config_env || CONFIG_ENV.fetch(name), namespace: "net-connector",
19
+ names: NAMES, common_keys: COMMON_KEYS, **options)
238
20
  end
239
21
 
240
- def shared_sections(document, path)
241
- unknown = document.keys - ["common", *NAMES]
242
- raise ArgumentError, "unknown shared configuration namespaces in #{path}: #{unknown.join(", ")}" unless unknown.empty?
243
-
244
- common = document.fetch("common", {})
245
- native = document.fetch(@name, {})
246
- unless common.is_a?(Hash) && native.is_a?(Hash)
247
- raise ArgumentError, "common and #{@name} configuration must contain mappings: #{path}"
248
- end
249
- unknown_common = common.keys - COMMON_KEYS
250
- unless unknown_common.empty?
251
- raise ArgumentError, "unsupported common configuration fields in #{path}: #{unknown_common.join(", ")}"
252
- end
253
- validate_string_keys!(common, path)
254
- validate_string_keys!(native, path)
255
- [common, native, document.key?(@name)]
256
- end
257
-
258
- def resolve_paths(document, path)
259
- result = self.class.copy(document)
260
- @path_fields.each { |segments| resolve_path!(result, segments, File.dirname(path), segments.join(".")) }
261
- result
262
- end
263
-
264
- def resolve_path!(value, segments, directory, field)
265
- return unless value.is_a?(Hash) && !segments.empty?
266
-
267
- key, *rest = segments
268
- keys = key == "*" ? value.keys : [key]
269
- keys.each do |entry|
270
- next unless value.key?(entry)
271
-
272
- if rest.empty?
273
- value[entry] = declared_path(value[entry], directory, field) if value[entry].is_a?(String) && !value[entry].empty?
274
- else
275
- resolve_path!(value[entry], rest, directory, field)
276
- end
277
- end
278
- end
279
-
280
- # Declared paths may identify remote archive roots as well as local files.
281
- def declared_path(value, directory, field)
282
- raise ArgumentError if value.to_s.include?("\0")
283
-
284
- return value if @path_literals.fetch(field, []).include?(value)
285
-
286
- value.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}) ? value : absolute(value, directory)
287
- rescue ArgumentError
288
- raise ArgumentError, "invalid path #{field}", cause: nil
289
- end
290
-
291
- def resolve_environment_paths!(values, directory)
292
- @env_path_keys.each do |key|
293
- values[key] = declared_path(values[key], directory, key) if present(values[key])
294
- end
295
- end
296
-
297
- def merge_layer(previous, document, path, fields)
298
- resolved = resolve_paths(document, path)
299
- record_fields(resolved, path, fields, previous: previous)
300
- self.class.merge(previous, resolved)
301
- end
302
-
303
- def record_fields(document, source, fields, prefix = nil, previous: {})
304
- document.each do |key, value|
305
- name = [prefix, key].compact.join(".")
306
- before = previous.is_a?(Hash) ? previous[key] : nil
307
- next if value.is_a?(Hash) && value.empty? && before.is_a?(Hash) && !before.empty?
308
-
309
- if value.is_a?(Hash) && !value.empty?
310
- fields.delete(name)
311
- record_fields(value, source, fields, name, previous: before)
312
- else
313
- fields.delete_if { |field, _| field == name || field.start_with?("#{name}.") }
314
- fields[name] = source
315
- end
316
- end
317
- end
318
-
319
- def update_sources(fields)
320
- @sources = self.class.freeze_tree({ "configuration_files" => @config_files,
321
- "environment_files" => @environment_files, "fields" => fields })
322
- end
323
-
324
- # Dotenv's grammar is reused, but substitutions are deliberately isolated:
325
- # its default implementation reads real ENV and executes shell commands.
326
- def parse_environment(path, inherited)
327
- require "dotenv"
328
- process = @process_environment
329
- variable_pattern = Dotenv::Substitutions::Variable.singleton_class.const_get(:VARIABLE)
330
- parser_class = Class.new(Dotenv::Parser) do
331
- define_singleton_method(:substitutions) do
332
- [lambda do |value, local|
333
- raise ArgumentError, "command substitution is unsupported" if value.include?("$(")
22
+ # 共享文件按工具分区;各专用 YAML 保持原有字段布局。
23
+ def self.dump(document, name:, path:)
24
+ raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name.to_s)
334
25
 
335
- value.gsub(variable_pattern) do |variable|
336
- match = Regexp.last_match
337
- if match[1] == "\\"
338
- variable[1..]
339
- elsif match[3]
340
- process.fetch(match[3]) { local.fetch(match[3]) { inherited.fetch(match[3], "") } }
341
- else
342
- variable
343
- end
344
- end
345
- end]
346
- end
347
- define_method(:expand_newlines) do |value|
348
- if (@hash["DOTENV_LINEBREAK_MODE"] || inherited["DOTENV_LINEBREAK_MODE"]) == "legacy"
349
- value.gsub('\\n', "\n").gsub('\\r', "\r")
350
- else
351
- value.gsub('\\n', "\\\\\\n").gsub('\\r', "\\\\\\r")
352
- end
353
- end
354
- private :expand_newlines
355
- end
356
- contents = File.read(path)
357
- remainder = contents.gsub(Dotenv::Parser::LINE, "")
358
- unless remainder.lines.all? { |line| line.strip.empty? || line.lstrip.start_with?("#") }
359
- raise ArgumentError, "invalid environment syntax"
360
- end
361
- parser_class.call(contents, overwrite: true)
362
- rescue Dotenv::FormatError, ArgumentError, SystemCallError, IOError => error
363
- raise ArgumentError, "cannot read environment file: #{path} (#{error.class})"
26
+ super(document, name: name, path: path, namespace: "net-connector")
364
27
  end
365
28
  end
366
29
  end
@@ -4,7 +4,7 @@ require "ipaddr"
4
4
 
5
5
  module Net
6
6
  module Connector
7
- module Netdisco
7
+ module Inventory
8
8
  # 精确地址用索引,网段和范围只保存端点;绝不展开地址或发起扫描。
9
9
  class AddressSet
10
10
  # 规范化精确地址并合并同族网段区间,不展开大型地址范围。
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "selection"
5
+
6
+ module Net
7
+ module Connector
8
+ module Inventory
9
+ # 脚本共享筛选词汇和参数;配置来源及优先级仍由宿主决定。
10
+ module Filters
11
+ LISTS = {
12
+ "hosts" => :include_hosts, "exclude_hosts" => :exclude_hosts,
13
+ "names" => :include_names, "exclude_names" => :exclude_names,
14
+ "vendors" => :include_vendors, "exclude_vendors" => :exclude_vendors,
15
+ "models" => :include_models, "exclude_models" => :exclude_models
16
+ }.freeze
17
+ MODES = %w[name_match model_match].freeze
18
+ FIELDS = (LISTS.keys + MODES).freeze
19
+ FLAGS = {
20
+ "--hosts LIST" => "hosts", "--exclude-hosts LIST" => "exclude_hosts",
21
+ "--name LIST" => "names", "--exclude-name LIST" => "exclude_names",
22
+ "--vendor LIST" => "vendors", "--exclude-vendor LIST" => "exclude_vendors",
23
+ "--model LIST" => "models", "--exclude-model LIST" => "exclude_models"
24
+ }.freeze
25
+
26
+ def self.selection(values)
27
+ arguments = LISTS.to_h { |key, argument| [argument, values.fetch(key, [])] }
28
+ MODES.each { |key| arguments[key.to_sym] = values.fetch(key, :contains) }
29
+ Selection.new(**arguments)
30
+ end
31
+
32
+ # 每个回调只交付本次参数;追加或覆盖由配置适配器显式处理。
33
+ def self.add_options(parser, single_host: true, &accept)
34
+ flags = single_host ? {"--host IP" => "hosts"}.merge(FLAGS) : FLAGS
35
+ flags.each do |flag, key|
36
+ parser.on(flag, "筛选已有清单,同类条件取并集,排除优先") do |value|
37
+ items = value.split(",", -1).map(&:strip)
38
+ raise OptionParser::InvalidArgument, "filter list contains empty items" if items.empty? || items.any?(&:empty?)
39
+
40
+ accept.call(key, items)
41
+ end
42
+ end
43
+ MODES.each do |key|
44
+ parser.on("--#{key.tr("_", "-")} MODE", %w[contains prefix glob], "匹配方式 contains / prefix / glob") { |value| accept.call(key, value) }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "address_set"
4
+
5
+ module Net
6
+ module Connector
7
+ module Inventory
8
+ # 只筛选已有设备,不识别厂商、不发现地址、不建立连接。
9
+ class Selection
10
+ Decision = Data.define(:reasons) do
11
+ def initialize(reasons:)
12
+ super(reasons: reasons.dup.freeze)
13
+ end
14
+
15
+ def selected? = reasons.empty?
16
+ end
17
+
18
+ def initialize(include_hosts: [], exclude_hosts: [], include_names: [], exclude_names: [],
19
+ include_vendors: [], exclude_vendors: [], include_models: [], exclude_models: [],
20
+ name_match: :contains, model_match: :contains)
21
+ @include_hosts, @exclude_hosts = AddressSet.new(include_hosts), AddressSet.new(exclude_hosts)
22
+ @names = [text_list(include_names), text_list(exclude_names), mode(name_match)].freeze
23
+ @models = [text_list(include_models), text_list(exclude_models), mode(model_match)].freeze
24
+ @vendors = [vendor_list(include_vendors), vendor_list(exclude_vendors)].freeze
25
+ freeze
26
+ end
27
+
28
+ # 同字段包含取并集,不同字段取交集;排除优先,原因不携带设备正文。
29
+ def evaluate(host:, vendor: nil, name: nil, model: nil)
30
+ reasons = []
31
+ if @exclude_hosts.include?(host)
32
+ reasons << :host_excluded
33
+ elsif !@include_hosts.empty? && !@include_hosts.include?(host)
34
+ reasons << :host_not_included
35
+ end
36
+ vendor = vendor.to_s
37
+ if @vendors.last.include?(vendor)
38
+ reasons << :vendor_excluded
39
+ elsif !@vendors.first.empty? && !@vendors.first.include?(vendor)
40
+ reasons << :vendor_not_included
41
+ end
42
+ reasons << text_reason(:name, name, @names)
43
+ reasons << text_reason(:model, model, @models)
44
+ Decision.new(reasons: reasons.compact)
45
+ end
46
+
47
+ def selected?(host, vendor = nil, name: nil, model: nil)
48
+ evaluate(host: host, vendor: vendor, name: name, model: model).selected?
49
+ end
50
+
51
+ def inspect = "#<#{self.class}>"
52
+ alias to_s inspect
53
+
54
+ private
55
+
56
+ def mode(value)
57
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && %w[contains prefix glob].include?(value.to_s)
58
+ raise ArgumentError, "match mode must be contains, prefix or glob"
59
+ end
60
+ value.to_sym
61
+ end
62
+
63
+ def text_list(values)
64
+ unless values.is_a?(Array) && values.all? { |v| v.is_a?(String) && v.valid_encoding? && !v.strip.empty? && !/[\x00-\x1f\x7f]/.match?(v) }
65
+ raise ArgumentError, "filters must be arrays of nonempty text"
66
+ end
67
+ values.map { |v| v.downcase.freeze }.uniq.freeze
68
+ end
69
+
70
+ def vendor_list(values)
71
+ unless values.is_a?(Array) && values.all? { |v| v.is_a?(String) || v.is_a?(Symbol) }
72
+ raise ArgumentError, "vendors must be strings or symbols"
73
+ end
74
+ text_list(values.map(&:to_s))
75
+ end
76
+
77
+ def text_reason(field, value, rules)
78
+ includes, excludes, match = rules
79
+ value = value&.to_s&.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")&.downcase
80
+ return :"#{field}_excluded" if value && excludes.any? { |pattern| matches?(pattern, value, match) }
81
+ return :"#{field}_not_included" if !includes.empty? && (!value || includes.none? { |pattern| matches?(pattern, value, match) })
82
+
83
+ nil
84
+ end
85
+
86
+ def matches?(pattern, value, match)
87
+ case match
88
+ when :contains then value.include?(pattern)
89
+ when :prefix then value.start_with?(pattern)
90
+ when :glob then File.fnmatch?(pattern, value)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "ctx_cli/options"
5
+ require "ctx_cli/terminal"
4
6
  require_relative "../settings"
5
7
  require_relative "../planner"
6
8
  require_relative "../config"
7
9
  require_relative "../../config"
8
10
  require_relative "help"
11
+ require_relative "../../inventory/filters"
9
12
 
10
13
  module Net
11
14
  module Connector
@@ -65,13 +68,9 @@ module Net
65
68
  def self.add_terminal_options(opts, values)
66
69
  opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
67
70
  opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
68
- opts.on("--quiet", "--no-progress", "关闭进度;可配合 --json") { values[:no_progress] = true }
69
- opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
70
- opts.on("--json", "在 stdout 输出 JSON;默认显示可读摘要") { values[:json] = true }
71
+ CtxCli::Options.output(opts, values, quiet: :no_progress)
71
72
  opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
72
- opts.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { values[:init] = true }
73
- opts.on("--interactive", "缺少文件时询问是否创建,并可交互填写") { values[:interactive] = true }
74
- opts.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { values[:interactive] = false }
73
+ CtxCli::Options.initialization(opts, values)
75
74
  end
76
75
 
77
76
  # 登记互斥的拓扑执行模式和格式化参数,应用模式必须显式选择。
@@ -208,25 +207,16 @@ module Net
208
207
 
209
208
  # 同类 CLI 筛选替换 YAML;重复参数在本次调用内合并,绝不把范围展开为地址列表。
210
209
  def self.add_filters(opts, values)
211
- {
212
- "--hosts PATTERNS" => ["NC_INCLUDE_HOSTS", "包含地址、网段或范围,逗号分隔"],
213
- "--exclude-hosts PATTERNS" => ["NC_EXCLUDE_HOSTS", "排除地址、网段或范围"],
214
- "--name PATTERNS" => ["NC_INCLUDE_NAMES", "包含设备名称,可重复或逗号分隔"],
215
- "--exclude-name PATTERNS" => ["NC_EXCLUDE_NAMES", "排除设备名称,可重复或逗号分隔"],
216
- "--vendor NAMES" => ["NC_INCLUDE_VENDORS", "包含连接器厂商名"],
217
- "--exclude-vendor NAMES" => ["NC_EXCLUDE_VENDORS", "排除连接器厂商名"]
218
- }.each do |flag, (key, description)|
219
- opts.on(flag, description) do |value|
220
- items = value.split(",", -1).map(&:strip)
221
- raise OptionParser::InvalidArgument if items.empty? || items.any?(&:empty?)
222
-
223
- previous = values[:environment][key]&.split(",") || []
224
- values[:environment][key] = (previous + items).uniq.join(",")
210
+ Net::Connector::Inventory::Filters.add_options(opts, single_host: false) do |key, value|
211
+ field = Net::Connector::Inventory::Filters::LISTS.fetch(key, key).to_s
212
+ environment_key = "NC_#{field.upcase}"
213
+ if value.is_a?(Array)
214
+ previous = values[:environment][environment_key]&.split(",") || []
215
+ values[:environment][environment_key] = (previous + value).uniq.join(",")
216
+ else
217
+ values[:environment][environment_key] = value
225
218
  end
226
219
  end
227
- opts.on("--name-match MODE", %w[contains prefix glob], "名称匹配方式:contains / prefix / glob") do |value|
228
- values[:environment]["NC_NAME_MATCH"] = value
229
- end
230
220
  opts.on("--host IP", "选择单台设备,可重复") do |value|
231
221
  raise OptionParser::InvalidArgument if value.empty? || value.match?(/[\/,]/)
232
222
 
@@ -265,15 +255,7 @@ module Net
265
255
  return unless values[flag]
266
256
 
267
257
  raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
268
- raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless input.tty?
269
- require "io/console"
270
- output.print "#{label}:"
271
- output.flush
272
- password = input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
273
- output.puts
274
- raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
275
-
276
- values[:environment][key] = password
258
+ values[:environment][key] = CtxCli::Terminal.new(input: input, out: output).password(label: label)
277
259
  end
278
260
  private_class_method :read_password, :parser_for
279
261
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "ctx_cli/terminal"
4
5
  require_relative "../config/initializer"
5
6
  require_relative "../config/defaults"
6
7
 
@@ -98,7 +99,7 @@ module Net
98
99
 
99
100
  # 标准输入凭据协议独占输入;只有真实终端才允许配置问答。
100
101
  def terminal?
101
- !@options[:stdin_credentials] && @input.respond_to?(:tty?) && @input.tty? && @output.respond_to?(:tty?) && @output.tty?
102
+ !@options[:stdin_credentials] && CtxCli::Terminal.tty?(@input, @output)
102
103
  end
103
104
 
104
105
  # 创建和定制为独立选择;显式 --init 默认直接生成已识别值。
@@ -15,7 +15,8 @@ module Net
15
15
  "cache_directory" => "./.cache/net-connector/inventory", "inventory_timeout" => 300,
16
16
  "page_size" => 500, "allow_insecure_http" => false },
17
17
  "inventory" => { "include_hosts" => [], "exclude_hosts" => [], "include_names" => [], "exclude_names" => [],
18
- "include_vendors" => [], "exclude_vendors" => [], "name_match" => "contains" },
18
+ "include_vendors" => [], "exclude_vendors" => [], "name_match" => "contains",
19
+ "include_models" => [], "exclude_models" => [], "model_match" => "contains" },
19
20
  "ssh" => { "protocol" => "ssh", "host_key_policy" => "accept_new", "log_level" => "info" },
20
21
  "backup" => { "directory" => directory || "./backups", "parallel" => 4 }
21
22
  }
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
- require "tempfile"
4
+ require "ctx_cli/initialization"
5
+ require "ctx_cli/terminal"
5
6
  require_relative "../settings"
6
7
  require_relative "../../config"
7
8
 
@@ -14,6 +15,7 @@ module Net
14
15
  # 绑定配置问答的输入输出,保留调用方对 IO 的所有权。
15
16
  def initialize(input: $stdin, output: $stderr)
16
17
  @input, @output = input, output
18
+ @writer = CtxCli::Initialization.new(prefix: ".net-connector-config-")
17
19
  end
18
20
 
19
21
  # 完成默认值校验与可选问答后独占发布新配置,不覆盖已存在文件。
@@ -26,7 +28,7 @@ module Net
26
28
 
27
29
  values = YAML.safe_load(YAML.dump(document), aliases: false)
28
30
  validate!(values)
29
- interactive = @input.respond_to?(:tty?) && @input.tty? && @output.respond_to?(:tty?) && @output.tty? if interactive.nil?
31
+ interactive = CtxCli::Terminal.tty?(@input, @output) if interactive.nil?
30
32
  configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("进入交互配置?[y/N]", "n").casecmp?("y")
31
33
  validate!(values)
32
34
  name = mode == :topology ? "topology" : "backup"
@@ -43,14 +45,7 @@ module Net
43
45
  private
44
46
 
45
47
  # 判断已有配置是否为普通文件;符号链接或其他类型不作为可用配置。
46
- def existing?(path)
47
- stat = File.lstat(path)
48
- raise ArgumentError, "config path must be a regular file" unless stat.file?
49
-
50
- true
51
- rescue Errno::ENOENT
52
- false
53
- end
48
+ def existing?(path) = @writer.existing?(path)
54
49
 
55
50
  # 通过完整策略快照校验候选文档,拒绝生成不可执行的配置。
56
51
  def validate!(document)
@@ -102,10 +97,11 @@ module Net
102
97
  if document.dig("netdisco", "source") == "http"
103
98
  field(document, "netdisco", "url", "清单 URL", mode.nil? ? "https://netdisco.example.invalid" : nil)
104
99
  end
105
- %w[include_hosts exclude_hosts include_names exclude_names].each do |key|
100
+ %w[include_hosts exclude_hosts include_names exclude_names include_models exclude_models].each do |key|
106
101
  field(document, "inventory", key, key, [], list: true)
107
102
  end
108
103
  field(document, "inventory", "name_match", "名称匹配 contains/prefix/glob", "contains")
104
+ field(document, "inventory", "model_match", "型号匹配 contains/prefix/glob", "contains")
109
105
  %w[include_vendors exclude_vendors].each { |key| field(document, "inventory", key, key, [], list: true) }
110
106
  if document.key?("topology")
111
107
  field(document, "topology", "parallel", "拓扑并发数 1..500", inherited_parallel)
@@ -117,51 +113,8 @@ module Net
117
113
  end
118
114
  end
119
115
 
120
- # 尝试关闭目录与临时文件全部句柄,清理失败不覆盖原始异常。
121
- def close_files(file, parent, primary)
122
- Thread.handle_interrupt(Exception => :never) do
123
- error = nil
124
- [-> { parent&.close }, -> { file&.close! }].each do |close|
125
- begin
126
- close.call
127
- rescue Exception => failure # rubocop:disable Lint/RescueException -- 两个资源均尝试关闭,保留首个故障。
128
- error ||= failure
129
- end
130
- end
131
- raise error if error && !primary
132
- end
133
- end
134
-
135
- # 先写入并同步私有临时文件,再以独占硬链接发布完整配置并同步目录。
136
- # 最终路径只在内容写全并同步后出现;已有目标由独占链接保护,取消也不能留下半份配置。
137
- def write(path, contents)
138
- directory = File.realpath(File.dirname(File.expand_path(path)))
139
- destination = File.join(directory, File.basename(path))
140
- file = parent = nil
141
- begin
142
- Thread.handle_interrupt(Exception => :never) { file = Tempfile.new([".net-connector-config-", ".tmp"], directory) }
143
- file.binmode
144
- file.chmod(0o600)
145
- file.write(contents)
146
- file.flush
147
- file.fsync
148
- begin
149
- File.link(file.path, destination)
150
- rescue Errno::EEXIST
151
- return :exists if existing?(destination)
152
-
153
- raise
154
- end
155
- Thread.handle_interrupt(Exception => :never) { parent = File.open(directory, File::RDONLY | File::NOFOLLOW) }
156
- parent.fsync
157
- :created
158
- rescue Exception => failure # rubocop:disable Lint/RescueException -- 清理不能覆盖输入取消或发布故障。
159
- primary = failure
160
- raise
161
- ensure
162
- close_files(file, parent, primary)
163
- end
164
- end
116
+ # 公开文档已校验;排他发布、同步与清理由公共初始化器负责。
117
+ def write(path, contents) = @writer.create(path: path, contents: contents)
165
118
  end
166
119
  end
167
120
  end
@@ -45,6 +45,9 @@ module Net
45
45
  "include_names" => ["NC_INCLUDE_NAMES", :list],
46
46
  "exclude_names" => ["NC_EXCLUDE_NAMES", :list],
47
47
  "name_match" => ["NC_NAME_MATCH", :string],
48
+ "include_models" => ["NC_INCLUDE_MODELS", :list],
49
+ "exclude_models" => ["NC_EXCLUDE_MODELS", :list],
50
+ "model_match" => ["NC_MODEL_MATCH", :string],
48
51
  "exclude_vendors" => ["NC_EXCLUDE_VENDORS", :list],
49
52
  "include_vendors" => ["NC_INCLUDE_VENDORS", :list],
50
53
  "vendor_overrides" => ["NC_VENDOR_OVERRIDES", :json],
@@ -38,7 +38,7 @@ module Net
38
38
  end
39
39
  vendor = rules.resolve(row)
40
40
  name = [row["name"], row["dns"]].find { |value| value && !value.strip.empty? }
41
- issue ||= :filtered unless rules.selected?(host, vendor, name: name&.strip)
41
+ issue ||= :filtered unless rules.selected?(host, vendor, name: name&.strip, model: row["model"])
42
42
  issue ||= :unsupported_vendor unless vendor
43
43
  new(host: host, source_ip: source_ip, name: name&.strip, vendor: vendor,
44
44
  source_vendor: row["vendor"], model: row["model"], os: row["os"],
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "ipaddr"
4
4
  require_relative "../../connector"
5
- require_relative "address_set"
5
+ require_relative "../inventory/selection"
6
6
 
7
7
  module Net
8
8
  module Connector
@@ -12,17 +12,11 @@ module Net
12
12
  # 校验并保存清单过滤和厂商映射规则。
13
13
  def initialize(include_hosts: [], exclude_hosts: [], include_vendors: [], vendor_overrides: {},
14
14
  host_overrides: {}, mappings: [], include_names: [], exclude_names: [],
15
- name_match: :contains, exclude_vendors: [])
16
- @include_hosts = AddressSet.new(include_hosts)
17
- @exclude_hosts = AddressSet.new(exclude_hosts)
18
- @include_vendors = vendor_list(include_vendors)
19
- @exclude_vendors = vendor_list(exclude_vendors)
20
- @include_names = name_list(include_names)
21
- @exclude_names = name_list(exclude_names)
22
- unless (name_match.is_a?(String) || name_match.is_a?(Symbol)) && %w[contains prefix glob].include?(name_match.to_s)
23
- raise ArgumentError, "name_match must be contains, prefix or glob"
24
- end
25
- @name_match = name_match.to_sym
15
+ name_match: :contains, exclude_vendors: [], include_models: [], exclude_models: [], model_match: :contains)
16
+ @selection = Net::Connector::Inventory::Selection.new(include_hosts: include_hosts, exclude_hosts: exclude_hosts,
17
+ include_names: include_names, exclude_names: exclude_names, name_match: name_match,
18
+ include_vendors: vendor_list(include_vendors), exclude_vendors: vendor_list(exclude_vendors),
19
+ include_models: include_models, exclude_models: exclude_models, model_match: model_match)
26
20
  @vendor_overrides = vendor_mapping(vendor_overrides)
27
21
  @host_overrides = host_mapping(host_overrides)
28
22
  @mappings = device_mappings(mappings)
@@ -153,19 +147,8 @@ module Net
153
147
  end
154
148
 
155
149
  # 判断设备是否通过地址和厂商过滤。
156
- def selected?(host, vendor, name: nil)
157
- return false if @exclude_hosts.include?(host)
158
- return false if !@include_hosts.empty? && !@include_hosts.include?(host)
159
- return false if @exclude_vendors.include?(vendor)
160
- return false if !@include_vendors.empty? && !@include_vendors.include?(vendor)
161
-
162
- return true if @include_names.empty? && @exclude_names.empty?
163
-
164
- value = name.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "").downcase
165
- return false if @exclude_names.any? { |pattern| name_matches?(pattern, value) }
166
- return false if !@include_names.empty? && @include_names.none? { |pattern| name_matches?(pattern, value) }
167
-
168
- true
150
+ def selected?(host, vendor, name: nil, model: nil)
151
+ @selection.selected?(host, vendor, name: name, model: model)
169
152
  end
170
153
 
171
154
  private
@@ -176,23 +159,6 @@ module Net
176
159
  wireless ? :h3c_wireless : :h3c
177
160
  end
178
161
 
179
- # 校验名称过滤项为有效非空文本,统一小写并去重冻结。
180
- def name_list(values)
181
- unless values.is_a?(Array) && values.all? { |value| value.is_a?(String) && !value.strip.empty? && value.valid_encoding? }
182
- raise ArgumentError, "name filters must be arrays of nonempty strings"
183
- end
184
- values.map { |value| value.downcase.freeze }.uniq.freeze
185
- end
186
-
187
- # 按包含、前缀或通配符模式匹配已规范化的设备名称。
188
- def name_matches?(pattern, value)
189
- case @name_match
190
- when :contains then value.include?(pattern)
191
- when :prefix then value.start_with?(pattern)
192
- when :glob then File.fnmatch?(pattern, value)
193
- end
194
- end
195
-
196
162
  # 将设备标签转为便于匹配的小写词串。
197
163
  def normalize(value) = value.to_s.downcase.gsub(/[^a-z0-9]+/, " ").strip
198
164
  end
@@ -190,6 +190,8 @@ module Net
190
190
  exclude_vendors: list("NC_EXCLUDE_VENDORS"),
191
191
  include_names: list("NC_INCLUDE_NAMES"), exclude_names: list("NC_EXCLUDE_NAMES"),
192
192
  name_match: raw("NC_NAME_MATCH", "contains"),
193
+ include_models: list("NC_INCLUDE_MODELS"), exclude_models: list("NC_EXCLUDE_MODELS"),
194
+ model_match: raw("NC_MODEL_MATCH", "contains"),
193
195
  vendor_overrides: json("NC_VENDOR_OVERRIDES", {}),
194
196
  host_overrides: json("NC_HOST_OVERRIDES", {}),
195
197
  mappings: json("NC_DEVICE_RULES", []))
@@ -381,6 +383,8 @@ module Net
381
383
  exclude_vendors: list("NC_EXCLUDE_VENDORS"),
382
384
  include_names: list("NC_INCLUDE_NAMES"), exclude_names: list("NC_EXCLUDE_NAMES"),
383
385
  name_match: raw("NC_NAME_MATCH", "contains"),
386
+ include_models: list("NC_INCLUDE_MODELS"), exclude_models: list("NC_EXCLUDE_MODELS"),
387
+ model_match: raw("NC_MODEL_MATCH", "contains"),
384
388
  vendor_overrides: json("NC_VENDOR_OVERRIDES", {}),
385
389
  host_overrides: json("NC_HOST_OVERRIDES", {}),
386
390
  device_rules: json("NC_DEVICE_RULES", []) },
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.16.1"
6
+ VERSION = "0.17.1"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.1
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-21 00:00:00.000000000 Z
10
+ date: 2026-10-22 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ctx_cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: expect-pty
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -417,8 +431,10 @@ files:
417
431
  - lib/net/connector/engine/terminal/renderer.rb
418
432
  - lib/net/connector/engine/terminal/text.rb
419
433
  - lib/net/connector/engine/transports.rb
434
+ - lib/net/connector/inventory/address_set.rb
435
+ - lib/net/connector/inventory/filters.rb
436
+ - lib/net/connector/inventory/selection.rb
420
437
  - lib/net/connector/netdisco.rb
421
- - lib/net/connector/netdisco/address_set.rb
422
438
  - lib/net/connector/netdisco/batch.rb
423
439
  - lib/net/connector/netdisco/cli.rb
424
440
  - lib/net/connector/netdisco/cli/backup.rb