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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/docs/architecture.md +16 -6
  4. data/docs/netdisco.md +8 -4
  5. data/docs/netdisco.zh-CN.md +10 -6
  6. data/docs/usage.md +1 -1
  7. data/docs/usage.zh-CN.md +2 -2
  8. data/lib/net/connector/collect/progress.rb +70 -0
  9. data/lib/net/connector/config.rb +367 -0
  10. data/lib/net/connector/device/interfaces/audit.rb +3 -3
  11. data/lib/net/connector/device/interfaces/{interface_blocks.rb → blocks.rb} +1 -1
  12. data/lib/net/connector/device/interfaces/{interface_description.rb → description.rb} +6 -6
  13. data/lib/net/connector/device/interfaces/immediate_strategy.rb +2 -2
  14. data/lib/net/connector/device/interfaces/{interface_name.rb → name.rb} +1 -1
  15. data/lib/net/connector/device/interfaces/stages.rb +2 -2
  16. data/lib/net/connector/device/interfaces/strategy.rb +3 -3
  17. data/lib/net/connector/device/interfaces.rb +5 -5
  18. data/lib/net/connector/engine/errors.rb +4 -4
  19. data/lib/net/connector/engine/result.rb +2 -2
  20. data/lib/net/connector/netdisco/cli/backup.rb +17 -13
  21. data/lib/net/connector/netdisco/cli/help.rb +1 -1
  22. data/lib/net/connector/netdisco/cli/options.rb +49 -16
  23. data/lib/net/connector/netdisco/cli/setup.rb +32 -12
  24. data/lib/net/connector/netdisco/cli/topology.rb +14 -7
  25. data/lib/net/connector/netdisco/cli.rb +27 -8
  26. data/lib/net/connector/netdisco/config/defaults.rb +1 -1
  27. data/lib/net/connector/netdisco/config/initializer.rb +17 -11
  28. data/lib/net/connector/netdisco/config.rb +49 -1
  29. data/lib/net/connector/netdisco/fleet.rb +1 -1
  30. data/lib/net/connector/netdisco/operation.rb +3 -3
  31. data/lib/net/connector/netdisco/progress.rb +41 -283
  32. data/lib/net/connector/netdisco/settings.rb +11 -2
  33. data/lib/net/connector/netdisco/topology/options.rb +3 -3
  34. data/lib/net/connector/parallel/pool.rb +3 -4
  35. data/lib/net/connector/progress.rb +288 -0
  36. data/lib/net/connector/resources.rb +40 -0
  37. data/lib/net/connector/vendor/cisco/topology.rb +1 -1
  38. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  39. data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
  40. data/lib/net/connector/vendor/huawei/topology.rb +2 -2
  41. data/lib/net/connector/version.rb +1 -1
  42. metadata +23 -6
  43. 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) { InterfaceDescription.equivalent?(current, proposed) }
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
- InterfaceDescription.validate!(proposed)
77
+ Description.validate!(proposed)
78
78
  local = @strategy.configuration_interface(peer.local_interface)
79
- InterfaceDescription.validate_interface!(local)
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 InterfaceBlocks
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 "interface_name"
3
+ require_relative "name"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  class Interfaces
8
8
  # 描述文本与常见接口视图命令的纯构造方法,不连接或修改设备。
9
- module InterfaceDescription
9
+ module Description
10
10
  # 将邻居名称和接口拼成默认描述;统一邻居接口简称,原始发现记录保持不变。
11
11
  def self.format(neighbor, abbreviate: true, lowercase: false)
12
12
  port = if abbreviate
13
- InterfaceName.short(neighbor.neighbor_interface, lowercase: lowercase)
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 InterfaceName::RULES.any? { |pattern,| pattern.match?(old_port) } &&
29
- InterfaceName::RULES.any? { |pattern,| pattern.match?(new_port) }
28
+ return false unless Name::RULES.any? { |pattern,| pattern.match?(old_port) } &&
29
+ Name::RULES.any? { |pattern,| pattern.match?(new_port) }
30
30
 
31
- InterfaceName.key(old_port) == InterfaceName.key(new_port)
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 "interface_blocks"
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 InterfaceBlocks
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 InterfaceName
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 "interface_description"
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
- InterfaceDescription.validate_interface!(change.interface)
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 "interface_description"
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) = InterfaceName.key(name)
68
+ def interface_key(name) = Name.key(name)
69
69
 
70
70
  # 将发现协议常用的接口简称展开为配置视图可识别的名称。
71
- def configuration_interface(name) = InterfaceName.configuration(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) { InterfaceDescription.format(peer, abbreviate: abbreviate, lowercase: lowercase) }
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
- InterfaceDescription.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
194
+ Description.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
195
195
  end
196
- InterfaceDescription.validate!(proposed)
197
- next if InterfaceDescription.equivalent?(old, proposed)
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
- InterfaceDescription.validate!(change.new_description)
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} host=#{host.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, load_env: false)
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
- @load_env, @default_env = load_env, default_env
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
- values = options.fetch(:environment)
94
- values["NC_CONFIG"] = @default_config if @default_config && !values.key?("NC_CONFIG") && !@env.key?("NC_CONFIG")
95
- return unless @default_directory && !values.key?("NC_BACKUP_DIRECTORY") && !@env.key?("NC_BACKUP_DIRECTORY")
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, env: @env, input: @input, output: @error,
105
- env_path: @default_env, directory: @default_directory, load_env: @load_env).initialize_files?
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, load_env: @load_env).for_run(mode: @mode)
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
  # 从完整解析器帮助中筛选常用选项,避免维护重复的帮助文本。