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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +4 -0
- data/lib/net/connector/config.rb +10 -347
- data/lib/net/connector/{netdisco → inventory}/address_set.rb +1 -1
- data/lib/net/connector/inventory/filters.rb +50 -0
- data/lib/net/connector/inventory/selection.rb +96 -0
- data/lib/net/connector/netdisco/cli/options.rb +14 -32
- data/lib/net/connector/netdisco/cli/setup.rb +2 -1
- data/lib/net/connector/netdisco/config/defaults.rb +2 -1
- data/lib/net/connector/netdisco/config/initializer.rb +9 -56
- data/lib/net/connector/netdisco/config.rb +3 -0
- data/lib/net/connector/netdisco/device.rb +1 -1
- data/lib/net/connector/netdisco/rules.rb +8 -42
- data/lib/net/connector/netdisco/settings.rb +4 -0
- data/lib/net/connector/version.rb +1 -1
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33503c92ccbe8f7000652c0b47b967a8f2f40e6c9edd7081ba305c3246efe820
|
|
4
|
+
data.tar.gz: f2d4c434caab1b888d1b4cd9bf25a0def4e7e26eb63b2e6e5e93d8b56cc34c0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` 传入精确候选。
|
data/lib/net/connector/config.rb
CHANGED
|
@@ -1,366 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ctx_cli/configuration"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
raise ArgumentError, "
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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] &&
|
|
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 "
|
|
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 =
|
|
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
|
|
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 "
|
|
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
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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", []) },
|
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.
|
|
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-
|
|
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
|