net-connector 0.15.0 → 0.16.0
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 +13 -0
- data/docs/architecture.md +16 -6
- data/docs/netdisco.md +8 -4
- data/docs/netdisco.zh-CN.md +10 -6
- data/docs/usage.md +1 -1
- data/docs/usage.zh-CN.md +2 -2
- data/lib/net/connector/collect/progress.rb +70 -0
- data/lib/net/connector/config.rb +367 -0
- data/lib/net/connector/device/interfaces/audit.rb +3 -3
- data/lib/net/connector/device/interfaces/{interface_blocks.rb → blocks.rb} +1 -1
- data/lib/net/connector/device/interfaces/{interface_description.rb → description.rb} +6 -6
- data/lib/net/connector/device/interfaces/immediate_strategy.rb +2 -2
- data/lib/net/connector/device/interfaces/{interface_name.rb → name.rb} +1 -1
- data/lib/net/connector/device/interfaces/stages.rb +2 -2
- data/lib/net/connector/device/interfaces/strategy.rb +3 -3
- data/lib/net/connector/device/interfaces.rb +5 -5
- data/lib/net/connector/engine/errors.rb +4 -4
- data/lib/net/connector/engine/result.rb +2 -2
- data/lib/net/connector/netdisco/cli/backup.rb +17 -13
- data/lib/net/connector/netdisco/cli/help.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +49 -16
- data/lib/net/connector/netdisco/cli/setup.rb +32 -12
- data/lib/net/connector/netdisco/cli/topology.rb +14 -7
- data/lib/net/connector/netdisco/cli.rb +27 -8
- data/lib/net/connector/netdisco/config/defaults.rb +1 -1
- data/lib/net/connector/netdisco/config/initializer.rb +17 -11
- data/lib/net/connector/netdisco/config.rb +49 -1
- data/lib/net/connector/netdisco/fleet.rb +1 -1
- data/lib/net/connector/netdisco/operation.rb +3 -3
- data/lib/net/connector/netdisco/progress.rb +41 -283
- data/lib/net/connector/netdisco/settings.rb +11 -2
- data/lib/net/connector/netdisco/topology/options.rb +3 -3
- data/lib/net/connector/parallel/pool.rb +3 -4
- data/lib/net/connector/progress.rb +288 -0
- data/lib/net/connector/resources.rb +40 -0
- data/lib/net/connector/vendor/cisco/topology.rb +1 -1
- data/lib/net/connector/vendor/h3c/topology.rb +1 -1
- data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
- data/lib/net/connector/vendor/huawei/topology.rb +2 -2
- data/lib/net/connector/version.rb +1 -1
- metadata +23 -6
- data/lib/net/connector/netdisco/resources.rb +0 -36
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
# Host-side configuration discovery. Requiring this file never loads device I/O.
|
|
8
|
+
class Config
|
|
9
|
+
NAMES = %w[vxlan wireless backup topology].freeze
|
|
10
|
+
COMMON_KEYS = %w[netdisco inventory ssh].freeze
|
|
11
|
+
CONFIG_ENV = { "vxlan" => "VXLAN_CONFIG", "wireless" => "WIRELESS_CONFIG",
|
|
12
|
+
"backup" => "NC_CONFIG", "topology" => "NC_CONFIG" }.freeze
|
|
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:)
|
|
129
|
+
name = name.to_s
|
|
130
|
+
raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name)
|
|
131
|
+
|
|
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
|
|
238
|
+
end
|
|
239
|
+
|
|
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?("$(")
|
|
334
|
+
|
|
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})"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -43,7 +43,7 @@ module Net
|
|
|
43
43
|
def initialize(host:, vendor:, strategy:, neighbors:, descriptions:, formatter:, description_matcher: nil)
|
|
44
44
|
@host, @vendor, @strategy = host, vendor, strategy
|
|
45
45
|
@neighbors, @descriptions, @formatter = neighbors, descriptions, formatter
|
|
46
|
-
@description_matcher = description_matcher || ->(current, proposed, _peer) {
|
|
46
|
+
@description_matcher = description_matcher || ->(current, proposed, _peer) { Description.equivalent?(current, proposed) }
|
|
47
47
|
@comparisons, @issues, @evidence, @changes = [], [], {}, []
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -74,9 +74,9 @@ module Net
|
|
|
74
74
|
return reject_interface(row, :interface_missing) unless @descriptions.key?(interface)
|
|
75
75
|
|
|
76
76
|
proposed = @formatter.call(peer)
|
|
77
|
-
|
|
77
|
+
Description.validate!(proposed)
|
|
78
78
|
local = @strategy.configuration_interface(peer.local_interface)
|
|
79
|
-
|
|
79
|
+
Description.validate_interface!(local)
|
|
80
80
|
row.merge!(expected: proposed, status: compare_status(current, proposed, peer))
|
|
81
81
|
@evidence[interface] = [peer.neighbor_name, peer.neighbor_interface, current]
|
|
82
82
|
return if row[:status] == :matches
|
|
@@ -4,7 +4,7 @@ module Net
|
|
|
4
4
|
module Connector
|
|
5
5
|
class Interfaces
|
|
6
6
|
# interface 块方言共用完整性校验;解析器不能静默遗漏未知接口头。
|
|
7
|
-
module
|
|
7
|
+
module Blocks
|
|
8
8
|
# 比较原配置接口头与解析记录,拒绝静默遗漏接口的解析结果。
|
|
9
9
|
def validate_descriptions!(config, rows)
|
|
10
10
|
expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "name"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
class Interfaces
|
|
8
8
|
# 描述文本与常见接口视图命令的纯构造方法,不连接或修改设备。
|
|
9
|
-
module
|
|
9
|
+
module Description
|
|
10
10
|
# 将邻居名称和接口拼成默认描述;统一邻居接口简称,原始发现记录保持不变。
|
|
11
11
|
def self.format(neighbor, abbreviate: true, lowercase: false)
|
|
12
12
|
port = if abbreviate
|
|
13
|
-
|
|
13
|
+
Name.short(neighbor.neighbor_interface, lowercase: lowercase)
|
|
14
14
|
else
|
|
15
15
|
lowercase ? neighbor.neighbor_interface.downcase : neighbor.neighbor_interface
|
|
16
16
|
end
|
|
@@ -25,10 +25,10 @@ module Net
|
|
|
25
25
|
old_prefix, separator, old_port = current.rpartition("_")
|
|
26
26
|
new_prefix, new_separator, new_port = expected.rpartition("_")
|
|
27
27
|
return false if separator.empty? || new_separator.empty? || !old_prefix.casecmp?(new_prefix)
|
|
28
|
-
return false unless
|
|
29
|
-
|
|
28
|
+
return false unless Name::RULES.any? { |pattern,| pattern.match?(old_port) } &&
|
|
29
|
+
Name::RULES.any? { |pattern,| pattern.match?(new_port) }
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Name.key(old_port) == Name.key(new_port)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# 生成进入接口、设置描述和退出视图的命令,不执行设备操作。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "strategy"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "blocks"
|
|
5
5
|
require_relative "../../engine/terminal/renderer"
|
|
6
6
|
|
|
7
7
|
module Net
|
|
@@ -9,7 +9,7 @@ module Net
|
|
|
9
9
|
class Interfaces
|
|
10
10
|
# 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
|
|
11
11
|
class ImmediateStrategy < Strategy
|
|
12
|
-
include
|
|
12
|
+
include Blocks
|
|
13
13
|
|
|
14
14
|
# 使用配置采集命令读回即时生效的接口描述。
|
|
15
15
|
def verification_commands = @device.config_commands
|
|
@@ -4,7 +4,7 @@ module Net
|
|
|
4
4
|
module Connector
|
|
5
5
|
class Interfaces
|
|
6
6
|
# 接口身份匹配与描述展示共用名称规则;展示简称不能直接用作下发命令。
|
|
7
|
-
module
|
|
7
|
+
module Name
|
|
8
8
|
RULES = [
|
|
9
9
|
[/\A(?:bridge-aggregation|bagg)(?=\d)/i, "bridge-aggregation", "BAGG"],
|
|
10
10
|
[/\A(?:route-aggregation|ragg)(?=\d)/i, "route-aggregation", "RAGG"],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../../engine/script"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "description"
|
|
5
5
|
|
|
6
6
|
module Net
|
|
7
7
|
module Connector
|
|
@@ -37,7 +37,7 @@ module Net
|
|
|
37
37
|
|
|
38
38
|
interfaces = {}
|
|
39
39
|
changes.each do |change|
|
|
40
|
-
|
|
40
|
+
Description.validate_interface!(change.interface)
|
|
41
41
|
key = strategy.interface_key(change.interface)
|
|
42
42
|
raise ArgumentError, "topology changes must identify unique interfaces" if interfaces.key?(key)
|
|
43
43
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "shellwords"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "description"
|
|
5
5
|
require_relative "quoted_text"
|
|
6
6
|
|
|
7
7
|
module Net
|
|
@@ -65,10 +65,10 @@ module Net
|
|
|
65
65
|
def description_key(row) = interface_key(row.fetch("INTERFACE"))
|
|
66
66
|
|
|
67
67
|
# 将发现接口名归一为查找当前配置所用的键。
|
|
68
|
-
def interface_key(name) =
|
|
68
|
+
def interface_key(name) = Name.key(name)
|
|
69
69
|
|
|
70
70
|
# 将发现协议常用的接口简称展开为配置视图可识别的名称。
|
|
71
|
-
def configuration_interface(name) =
|
|
71
|
+
def configuration_interface(name) = Name.configuration(name)
|
|
72
72
|
|
|
73
73
|
# 还原 PAN-OS set 配置或 Alteon dump 中的引号描述。
|
|
74
74
|
def decode_description(value)
|
|
@@ -137,7 +137,7 @@ module Net
|
|
|
137
137
|
# 在同一会话内采集邻居和描述,返回逐接口证据与候选修正计划。
|
|
138
138
|
def audit(abbreviate: true, lowercase: false, description_matcher: nil, &formatter)
|
|
139
139
|
validate_format_options!(abbreviate, lowercase)
|
|
140
|
-
formatter ||= ->(peer) {
|
|
140
|
+
formatter ||= ->(peer) { Description.format(peer, abbreviate: abbreviate, lowercase: lowercase) }
|
|
141
141
|
@device.with_operation(:plan) do
|
|
142
142
|
AuditBuilder.new(host: @device.host, vendor: @device.vendor, strategy: @strategy,
|
|
143
143
|
neighbors: neighbors, descriptions: descriptions, formatter: formatter, description_matcher: description_matcher).call
|
|
@@ -191,10 +191,10 @@ module Net
|
|
|
191
191
|
proposed = if formatter
|
|
192
192
|
formatter.call(neighbor)
|
|
193
193
|
else
|
|
194
|
-
|
|
194
|
+
Description.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
|
|
195
195
|
end
|
|
196
|
-
|
|
197
|
-
next if
|
|
196
|
+
Description.validate!(proposed)
|
|
197
|
+
next if Description.equivalent?(old, proposed)
|
|
198
198
|
|
|
199
199
|
Change.new(interface: @strategy.configuration_interface(neighbor.local_interface), old_description: old,
|
|
200
200
|
new_description: proposed, neighbor: neighbor)
|
|
@@ -270,7 +270,7 @@ module Net
|
|
|
270
270
|
end
|
|
271
271
|
|
|
272
272
|
plan.changes.each do |change|
|
|
273
|
-
|
|
273
|
+
Description.validate!(change.new_description)
|
|
274
274
|
unless evidence.fetch(@strategy.interface_key(change.interface)) ==
|
|
275
275
|
[change.neighbor.neighbor_name, change.neighbor.neighbor_interface, change.old_description]
|
|
276
276
|
raise ArgumentError, "plan changes do not match its discovery evidence"
|
|
@@ -36,8 +36,8 @@ module Net
|
|
|
36
36
|
copy
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
def inspect = "#<#{self.class} code=#{code.inspect} phase=#{phase.inspect}
|
|
39
|
+
# 摘要复用日志诊断词表;扩展钩子可提供原始元数据,但不能借摘要输出配置正文。
|
|
40
|
+
def inspect = "#<#{self.class} code=#{Metadata.code(code).inspect} phase=#{Metadata.phase(phase).inspect}>"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
class ConnectionError < Error; end
|
|
@@ -89,8 +89,8 @@ module Net
|
|
|
89
89
|
freeze
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
#
|
|
93
|
-
def inspect = "#<#{self.class} type=#{type}>"
|
|
92
|
+
# 类型名称可能由外部异常类提供,摘要只展示共享词表接受的名称。
|
|
93
|
+
def inspect = "#<#{self.class} type=#{Error::Metadata.type(type)}>"
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
end
|
|
@@ -52,8 +52,8 @@ module Net
|
|
|
52
52
|
config || output
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
def inspect = "#<#{self.class} success=#{success?} steps=#{steps.size} code=#{error&.code.inspect}>"
|
|
55
|
+
# 摘要只展示受控错误码,不展开扩展钩子提供的原始元数据。
|
|
56
|
+
def inspect = "#<#{self.class} success=#{success?} steps=#{steps.size} code=#{Error::Metadata.code(error&.code).inspect}>"
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
end
|
|
@@ -18,12 +18,13 @@ module Net
|
|
|
18
18
|
class Backup
|
|
19
19
|
# 保存批次模式与调用方 IO,构造阶段不读取清单、不创建目录。
|
|
20
20
|
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr,
|
|
21
|
-
default_config: nil, default_directory: nil, default_env: nil,
|
|
21
|
+
default_config: nil, default_directory: nil, default_env: nil, configuration_options: {})
|
|
22
22
|
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
23
23
|
|
|
24
24
|
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
25
25
|
@default_config, @default_directory = default_config, default_directory
|
|
26
|
-
@
|
|
26
|
+
@default_env = default_env
|
|
27
|
+
@configuration_options = configuration_options
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# 先固定并校验策略,再预览、执行和保存报告;成功判定使用最终报告。
|
|
@@ -33,6 +34,12 @@ module Net
|
|
|
33
34
|
apply_defaults(options)
|
|
34
35
|
return 0 if initialize_config?(options)
|
|
35
36
|
|
|
37
|
+
if options[:show_config]
|
|
38
|
+
settings = CLI::Options.settings(options, env: @env, configuration: @configuration,
|
|
39
|
+
defaults: @default_directory ? { "NC_BACKUP_DIRECTORY" => @default_directory } : {})
|
|
40
|
+
@output.puts JSON.pretty_generate(settings.public_config.merge(sources: CLI::Options.sources(@configuration, options)))
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
36
43
|
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
37
44
|
inventory, credentials = Credentials.resolve(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
38
45
|
progress = Progress.new(io: @error, enabled: !options[:no_progress], verbose: options[:verbose])
|
|
@@ -88,21 +95,17 @@ module Net
|
|
|
88
95
|
plan.selected.empty? || plan.outcomes.compact.any? { |item| !%i[filtered sample_limit].include?(item.status) } ? 1 : 0
|
|
89
96
|
end
|
|
90
97
|
|
|
91
|
-
#
|
|
98
|
+
# 显式宿主路径仍可注入;示例默认通过共享发现器读取。
|
|
92
99
|
def apply_defaults(options)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
path = values["NC_CONFIG"] || @env["NC_CONFIG"]
|
|
98
|
-
document = path && File.file?(path) ? Config.load(path) : {}
|
|
99
|
-
values["NC_BACKUP_DIRECTORY"] = @default_directory unless document.key?("NC_BACKUP_DIRECTORY")
|
|
100
|
+
options.fetch(:environment)["NC_CONFIG"] ||= @default_config if @default_config && !@env["NC_CONFIG"]
|
|
101
|
+
options[:env_file] ||= @default_env if @default_env
|
|
102
|
+
@configuration = CLI::Options.configuration(options, env: @env, mode: @mode, **@configuration_options)
|
|
100
103
|
end
|
|
101
104
|
|
|
102
105
|
# 配置不存在时生成并验证初始文件;本次运行到此结束,不继续连接设备。
|
|
103
106
|
def initialize_config?(options)
|
|
104
|
-
Setup.new(mode: @mode, options: options,
|
|
105
|
-
|
|
107
|
+
Setup.new(mode: @mode, options: options, input: @input, output: @error,
|
|
108
|
+
directory: @default_directory || File.expand_path("backups"), configuration: @configuration).initialize_files?
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
# 清单离线回退和本地保存故障单独提示,不覆盖设备操作结果。
|
|
@@ -117,7 +120,8 @@ module Net
|
|
|
117
120
|
|
|
118
121
|
# 预检完成后才创建批次目录;补充日志路径时保持原策略快照与动态凭据来源。
|
|
119
122
|
def prepare(options)
|
|
120
|
-
settings = CLI::Options.settings(options, env: @env,
|
|
123
|
+
settings = CLI::Options.settings(options, env: @env, configuration: @configuration,
|
|
124
|
+
defaults: @default_directory ? { "NC_BACKUP_DIRECTORY" => @default_directory } : {}).for_run(mode: @mode)
|
|
121
125
|
success_policy = options.fetch(:success_policy, :selected)
|
|
122
126
|
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
123
127
|
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
@@ -6,7 +6,7 @@ module Net
|
|
|
6
6
|
class CLI
|
|
7
7
|
# 简要帮助从实际解析器筛选;范例按执行模式生成完整命令。
|
|
8
8
|
module Help
|
|
9
|
-
COMMON = %w[init config directory interactive non-interactive host hosts name exclude-name vendor
|
|
9
|
+
COMMON = %w[init config env-file show-config directory interactive non-interactive host hosts name exclude-name vendor
|
|
10
10
|
plan inventory-only parallel refresh-inventory json quiet no-progress help help-all examples].freeze
|
|
11
11
|
|
|
12
12
|
# 从完整解析器帮助中筛选常用选项,避免维护重复的帮助文本。
|