net-connector 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -65
  3. data/README.md +88 -95
  4. data/docs/VERIFICATION.md +2 -1
  5. data/docs/architecture.md +144 -248
  6. data/lib/net/connector/device/base.rb +1 -0
  7. data/lib/net/connector/device/interface_description.rb +3 -0
  8. data/lib/net/connector/device/profile/builder.rb +236 -0
  9. data/lib/net/connector/device/profile.rb +2 -227
  10. data/lib/net/connector/device/running_config/strategy.rb +5 -0
  11. data/lib/net/connector/device/running_config.rb +5 -0
  12. data/lib/net/connector/engine/dialogue.rb +1 -0
  13. data/lib/net/connector/engine/errors.rb +2 -0
  14. data/lib/net/connector/engine/log.rb +13 -9
  15. data/lib/net/connector/engine/session.rb +1 -0
  16. data/lib/net/connector/netdisco/cli.rb +20 -20
  17. data/lib/net/connector/netdisco/device.rb +4 -8
  18. data/lib/net/connector/netdisco/fleet.rb +2 -48
  19. data/lib/net/connector/netdisco/plan.rb +77 -0
  20. data/lib/net/connector/netdisco/planner.rb +12 -23
  21. data/lib/net/connector/netdisco/worker.rb +31 -24
  22. data/lib/net/connector/operations/tftp/file_upload.rb +42 -0
  23. data/lib/net/connector/operations/tftp/strategy.rb +9 -11
  24. data/lib/net/connector/operations/topology/strategy.rb +14 -0
  25. data/lib/net/connector/operations/topology.rb +1 -0
  26. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  27. data/lib/net/connector/vendor/cisco_ios/topology.rb +9 -0
  28. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
  29. data/lib/net/connector/vendor/h3c/tftp_backup.rb +2 -18
  30. data/lib/net/connector/vendor/h3c/topology.rb +9 -0
  31. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  32. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +4 -0
  33. data/lib/net/connector/vendor/hillstone/topology.rb +9 -0
  34. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -18
  35. data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
  36. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +7 -5
  37. data/lib/net/connector/vendor/palo_alto/topology.rb +10 -0
  38. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -2
  39. data/lib/net/connector/vendor/radware/topology.rb +2 -0
  40. data/lib/net/connector/version.rb +1 -1
  41. metadata +26 -24
@@ -50,7 +50,7 @@ module Net
50
50
  end
51
51
  if options[:host]
52
52
  host = IPAddr.new(options[:host]).to_s
53
- raise ArgumentError, "host #{options[:host]} was not found in inventory" unless plan.inventory.any? { |device| device.host == host }
53
+ raise ArgumentError, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
54
54
  end
55
55
  if options[:plan]
56
56
  @output.puts JSON.pretty_generate(plan_summary(plan))
@@ -74,7 +74,7 @@ module Net
74
74
  @error.puts "net-connector-backup: #{exception.message}"
75
75
  2
76
76
  rescue StandardError => exception
77
- @error.puts "net-connector-backup failed (#{exception.class})"
77
+ @error.puts "net-connector-backup 失败(#{exception.class})"
78
78
  2
79
79
  end
80
80
 
@@ -84,25 +84,25 @@ module Net
84
84
  def parse_options
85
85
  options = { config: @env["NET_CONNECTOR_CONFIG"] }
86
86
  parser = OptionParser.new do |args|
87
- args.banner = "Usage: net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
88
- args.on("--config FILE", "Read non-secret YAML settings") { |value| options[:config] = value }
89
- args.on("--show-config", "Print effective non-secret settings") { options[:show_config] = true }
90
- args.on("--plan", "Preview selected inventory without device connections") { options[:plan] = true }
91
- args.on("--tftp", "Use device-initiated TFTP backup") { options[:tftp] = true }
92
- args.on("--export IP", "Export a saved local configuration") { |value| options[:export] = value }
93
- args.on("--output FILE", "Write exported configuration to a private file") { |value| options[:output] = value }
94
- args.on("--directory PATH", "Backup and report directory") { |value| options[:directory] = value }
95
- args.on("--host IP", "Select one inventory device by management IP") { |value| options[:host] = value }
96
- args.on("--concurrency N", Integer, "Maximum simultaneous devices") { |value| options[:concurrency] = value }
97
- args.on("--limit-per-vendor N", Integer, "Select N devices per vendor (1..5)") do |value|
87
+ args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
88
+ args.on("--config FILE", "读取不含凭据的 YAML 设置") { |value| options[:config] = value }
89
+ args.on("--show-config", "显示生效的非敏感设置") { options[:show_config] = true }
90
+ args.on("--plan", "预览设备清单,不连接设备") { options[:plan] = true }
91
+ args.on("--tftp", "由设备发起 TFTP 备份") { options[:tftp] = true }
92
+ args.on("--export IP", "导出已保存的本地配置") { |value| options[:export] = value }
93
+ args.on("--output FILE", "将导出配置写入私有文件") { |value| options[:output] = value }
94
+ args.on("--directory PATH", "备份和报告目录") { |value| options[:directory] = value }
95
+ args.on("--host IP", "按管理地址选择一台设备") { |value| options[:host] = value }
96
+ args.on("--concurrency N", Integer, "最大并发设备数") { |value| options[:concurrency] = value }
97
+ args.on("--limit-per-vendor N", Integer, "每厂商选择 N 台设备(1 至 5)") do |value|
98
98
  options[:limit] = value
99
99
  end
100
- args.on("--all", "Select all ready devices") { options[:all] = true }
101
- args.on("-v", "--version", "Print version") do
100
+ args.on("--all", "选择全部就绪设备") { options[:all] = true }
101
+ args.on("-v", "--version", "显示版本") do
102
102
  @output.puts Net::Connector::VERSION
103
103
  options[:done] = true
104
104
  end
105
- args.on("-h", "--help", "Show help") do
105
+ args.on("-h", "--help", "显示帮助") do
106
106
  @output.puts args
107
107
  options[:done] = true
108
108
  end
@@ -116,11 +116,11 @@ module Net
116
116
  # 拒绝互斥或缺少依赖的命令行选项。
117
117
  def validate_options!(options)
118
118
  modes = [:show_config, :plan, :export].count { |key| options[key] }
119
- raise ArgumentError, "choose one of --show-config, --plan, or --export" if modes > 1
120
- raise ArgumentError, "--output requires --export" if options[:output] && !options[:export]
121
- raise ArgumentError, "--all and --limit-per-vendor cannot be combined" if options[:all] && options[:limit]
119
+ raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
120
+ raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
121
+ raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
122
122
  if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host])
123
- raise ArgumentError, "--export cannot be combined with backup options"
123
+ raise ArgumentError, "--export 不能与备份选项同时使用"
124
124
  end
125
125
  end
126
126
 
@@ -48,18 +48,14 @@ module Net
48
48
 
49
49
  # 生成设备 CLI 可用的 ASCII 远端文件名。
50
50
  def tftp_filename
51
- return "running-config.xml" if vendor == :palo_alto
52
-
53
51
  label = name.to_s.encode(Encoding::US_ASCII, invalid: :replace, undef: :replace, replace: "-")
54
52
  .gsub(/[^A-Za-z0-9._-]+/, "-").byteslice(0, 180)
55
53
  .gsub(/\A[._-]+|[._-]+\z/, "")
56
54
  label = vendor&.to_s || "device" if label.empty?
57
- extension = case vendor
58
- when :radware then "tgz"
59
- when :hillstone then "dat"
60
- else "cfg"
61
- end
62
- TftpTarget.filename(host, extension: extension, label: label)
55
+ strategy = Net::Connector.vendor_class(vendor).profile.tftp_strategy if vendor
56
+ # 旧版自定义策略只需实现实例接口,缺少命名接口时沿用通用 cfg 名称。
57
+ strategy = Operations::Tftp::Strategy unless strategy.respond_to?(:filename)
58
+ TftpTarget.validate_path!(strategy.filename(host, label: label))
63
59
  end
64
60
 
65
61
  # 按厂商及设备地址实例化连接器。
@@ -63,7 +63,7 @@ module Net
63
63
  end
64
64
  end
65
65
 
66
- # 让设备主动导出配置,避免 PAN-OS 固定远端文件名在同批任务中冲突。
66
+ # 让设备主动导出配置,在执行前检查同批目标文件名冲突。
67
67
  def tftp_backup_all(server:, source_files: {}, concurrency: @settings.concurrency,
68
68
  limit_per_vendor: 5, vrfs: {}, on_start: nil, on_result: nil, plan: nil,
69
69
  report_directory: @settings.backup_directory)
@@ -131,53 +131,7 @@ module Net
131
131
  def validate_plan!(plan, mode)
132
132
  raise ArgumentError, "plan must be a #{mode} Netdisco plan" unless plan.is_a?(Plan) && plan.mode == mode
133
133
 
134
- inventory = plan.inventory
135
- ready = plan.ready
136
- outcomes = plan.outcomes
137
- unless inventory.is_a?(Array) && inventory.all?(Device) && ready.is_a?(Array) &&
138
- outcomes.is_a?(Array) && outcomes.size == inventory.size
139
- raise ArgumentError, "plan inventory and outcomes must have the same device slots"
140
- end
141
-
142
- selected = {}
143
- previous_index = -1
144
- ready.each do |task|
145
- unless task.is_a?(Array) && task.size == 2 && task.first.is_a?(Integer)
146
- raise ArgumentError, "plan contains an invalid device task"
147
- end
148
-
149
- index, device = task
150
- unless index > previous_index && index < inventory.size && inventory[index].equal?(device) && device.ready?
151
- raise ArgumentError, "plan tasks must reference ready inventory devices in order"
152
- end
153
-
154
- selected[index] = true
155
- previous_index = index
156
- end
157
- if mode == :tftp && ready.count { |_index, device| device.vendor == :palo_alto } > 1
158
- raise ArgumentError, "plan cannot upload multiple Palo Alto devices to one TFTP filename"
159
- end
160
-
161
- inventory.each_with_index do |device, index|
162
- result = outcomes[index]
163
- next if selected[index] && result.nil?
164
-
165
- unless !selected[index] && valid_skipped_outcome?(result, device, mode)
166
- raise ArgumentError, "plan outcome does not match its inventory slot"
167
- end
168
- end
169
- end
170
-
171
- # 未执行设备必须保留与清单一致的跳过原因,且不能带有执行产物。
172
- def valid_skipped_outcome?(result, device, mode)
173
- return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
174
- result.error_code.nil? && result.error_type.nil? &&
175
- result.started_at.nil? && result.finished_at.nil?
176
-
177
- return result.status == device.issue unless device.ready?
178
-
179
- result.status == :sample_limit ||
180
- (mode == :tftp && device.vendor == :palo_alto && result.status == :remote_filename_collision)
134
+ plan.validate!
181
135
  end
182
136
 
183
137
  # 构造包含错误类型的单台设备结果。
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Netdisco
6
+ # 固定一份清单上的选择结果与跳过原因,供预览和执行复用。
7
+ Plan = Data.define(:mode, :inventory, :ready, :outcomes) do
8
+ # Data 只冻结对象自身;这里复制并冻结可能由调用方修改的计划容器。
9
+ def initialize(mode:, inventory:, ready:, outcomes:)
10
+ unless inventory.is_a?(Array) && ready.is_a?(Array) && outcomes.is_a?(Array)
11
+ raise ArgumentError, "plan inventory, ready tasks, and outcomes must be Arrays"
12
+ end
13
+
14
+ tasks = ready.map { |task| task.is_a?(Array) ? task.dup.freeze : task }.freeze
15
+ super(mode: mode, inventory: inventory.dup.freeze, ready: tasks, outcomes: outcomes.dup.freeze)
16
+ end
17
+
18
+ # 按清单顺序返回选中的设备。
19
+ def selected = ready.map(&:last)
20
+
21
+ # 在任何设备 I/O 前校验完整快照,外部构造或修改的计划也必须遵守相同约束。
22
+ def validate!
23
+ raise ArgumentError, "plan mode must be backup or tftp" unless %i[backup tftp].include?(mode)
24
+ unless inventory.all?(Device) && outcomes.size == inventory.size
25
+ raise ArgumentError, "plan inventory and outcomes must have the same device slots"
26
+ end
27
+
28
+ selected_indices = {}
29
+ filenames = {}
30
+ previous_index = -1
31
+ ready.each do |task|
32
+ unless task.is_a?(Array) && task.size == 2 && task.first.is_a?(Integer)
33
+ raise ArgumentError, "plan contains an invalid device task"
34
+ end
35
+
36
+ index, device = task
37
+ unless index > previous_index && index < inventory.size && inventory[index].equal?(device) && device.ready?
38
+ raise ArgumentError, "plan tasks must reference ready inventory devices in order"
39
+ end
40
+ if mode == :tftp
41
+ filename = device.tftp_filename
42
+ raise ArgumentError, "plan cannot upload multiple devices to one TFTP filename" if filenames.key?(filename)
43
+
44
+ filenames[filename] = true
45
+ end
46
+ selected_indices[index] = true
47
+ previous_index = index
48
+ end
49
+
50
+ inventory.each_with_index do |device, index|
51
+ result = outcomes[index]
52
+ next if selected_indices[index] && result.nil?
53
+
54
+ unless !selected_indices[index] && valid_skipped_outcome?(result, device, filenames)
55
+ raise ArgumentError, "plan outcome does not match its inventory slot"
56
+ end
57
+ end
58
+ self
59
+ end
60
+
61
+ private
62
+
63
+ # 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
64
+ def valid_skipped_outcome?(result, device, filenames)
65
+ return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
66
+ result.error_code.nil? && result.error_type.nil? &&
67
+ result.started_at.nil? && result.finished_at.nil?
68
+
69
+ return result.status == device.issue unless device.ready?
70
+
71
+ result.status == :sample_limit ||
72
+ (mode == :tftp && result.status == :remote_filename_collision && filenames.key?(device.tftp_filename))
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,24 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "plan"
4
+
3
5
  module Net
4
6
  module Connector
5
7
  module Netdisco
6
- # 固定一份清单上的选择结果与跳过原因,供预览和执行复用。
7
- Plan = Data.define(:mode, :inventory, :ready, :outcomes) do
8
- # Data 只冻结对象自身;这里复制并冻结可能由调用方修改的计划容器。
9
- def initialize(mode:, inventory:, ready:, outcomes:)
10
- unless inventory.is_a?(Array) && ready.is_a?(Array) && outcomes.is_a?(Array)
11
- raise ArgumentError, "plan inventory, ready tasks, and outcomes must be Arrays"
12
- end
13
-
14
- tasks = ready.map { |task| task.is_a?(Array) ? task.dup.freeze : task }.freeze
15
- super(mode: mode, inventory: inventory.dup.freeze, ready: tasks, outcomes: outcomes.dup.freeze)
16
- end
17
-
18
- # 按清单顺序返回选中的设备。
19
- def selected = ready.map(&:last)
20
- end
21
-
22
8
  # 根据设备就绪状态、厂商采样及远端文件冲突建立备份计划。
23
9
  class Planner
24
10
  # 保存本次规划使用的清单快照。
@@ -37,22 +23,25 @@ module Net
37
23
  else
38
24
  candidates
39
25
  end
40
- # PAN-OS 导出使用固定远端文件名,同批只允许一台设备写入。
41
- palo_alto_index = selected.find { |device, _index| device.vendor == :palo_alto }&.last if mode == :tftp
42
26
  selected_indices = selected.to_h { |_device, index| [index, true] }
27
+ filenames = {}
28
+ if mode == :tftp
29
+ # 先按采样顺序保留每个目标的首台设备,再按清单顺序输出结果。
30
+ selected.each { |device, index| filenames[device.tftp_filename] ||= index }
31
+ end
43
32
  outcomes = Array.new(@inventory.size)
44
33
  ready = @inventory.each_with_index.filter_map do |device, index|
45
34
  if !device.ready?
46
35
  outcomes[index] = skipped(device, device.issue)
47
36
  nil
48
- elsif mode == :tftp && device.vendor == :palo_alto && index != palo_alto_index
37
+ elsif !selected_indices.key?(index)
38
+ outcomes[index] = skipped(device, :sample_limit)
39
+ nil
40
+ elsif mode == :tftp && filenames.fetch(device.tftp_filename) != index
49
41
  outcomes[index] = skipped(device, :remote_filename_collision)
50
42
  nil
51
- elsif selected_indices.key?(index)
52
- [index, device].freeze
53
43
  else
54
- outcomes[index] = skipped(device, :sample_limit)
55
- nil
44
+ [index, device].freeze
56
45
  end
57
46
  end
58
47
  Plan.new(mode: mode, inventory: @inventory, ready: ready.freeze, outcomes: outcomes.freeze)
@@ -21,37 +21,44 @@ module Net
21
21
  worker_count = [tasks.size, @concurrency].min
22
22
  worker_count.times { queue << nil }
23
23
  callback_errors = Queue.new
24
- threads = Array.new(worker_count) do
25
- Thread.new do
26
- Thread.current.report_on_exception = false
27
- while (task = queue.pop)
28
- index, device = task
29
- started_at = Time.now.utc
30
- begin
31
- on_start&.call(device)
32
- rescue StandardError => error
33
- callback_errors << { host: device.host, error_type: error.class.name }.freeze
34
- end
35
- result = begin
36
- yield device
37
- rescue StandardError => error
38
- on_error.call(device, error)
39
- end
40
- result = result.with(started_at: started_at, finished_at: Time.now.utc)
41
- outcomes[index] = result
42
- Array(on_result).each do |callback|
24
+ finished = Queue.new
25
+ threads = []
26
+ completed = false
27
+ begin
28
+ # 创建线程也属于批次生命周期;后续创建失败时要关闭已有任务。
29
+ worker_count.times do
30
+ threads << Thread.new do
31
+ Thread.current.report_on_exception = false
32
+ while (task = queue.pop)
33
+ index, device = task
34
+ started_at = Time.now.utc
43
35
  begin
44
- callback.call(result)
36
+ on_start&.call(device)
45
37
  rescue StandardError => error
46
38
  callback_errors << { host: device.host, error_type: error.class.name }.freeze
47
39
  end
40
+ result = begin
41
+ yield device
42
+ rescue StandardError => error
43
+ on_error.call(device, error)
44
+ end
45
+ result = result.with(started_at: started_at, finished_at: Time.now.utc)
46
+ outcomes[index] = result
47
+ Array(on_result).each do |callback|
48
+ begin
49
+ callback.call(result)
50
+ rescue StandardError => error
51
+ callback_errors << { host: device.host, error_type: error.class.name }.freeze
52
+ end
53
+ end
48
54
  end
55
+ ensure
56
+ finished << Thread.current
49
57
  end
50
58
  end
51
- end
52
- completed = false
53
- begin
54
- threads.each(&:value)
59
+ # 按完成顺序观察失败,避免后启动线程的中断被前面的慢设备阻塞。
60
+ # 结果仍写回清单槽位,完成通知不会改变公开结果的顺序。
61
+ worker_count.times { finished.pop.value }
55
62
  completed = true
56
63
  ensure
57
64
  # 中断或某个 worker 抛出非 StandardError 时,不让其他设备任务
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ module Tftp
9
+ # H3C 和华为共用已保存文件上传流程,源文件发现仍由厂商策略负责。
10
+ class FileUpload < Strategy
11
+ # 未指定目标名时,设备使用配置源文件的 basename。
12
+ def default_path(source_file) = File.basename(source_file)
13
+
14
+ # 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
15
+ def script(target, source_file:, vrf: nil)
16
+ raise ArgumentError, "TFTP file upload does not use vrf" if vrf
17
+
18
+ destination = target.explicit_path? ? " #{target.path}" : ""
19
+ Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
20
+ end
21
+
22
+ # 只接受明确的成功消息、非零传输字节数或完整进度行。
23
+ def complete?(result)
24
+ completion_lines(result).any? { |line| completed_upload?(line) }
25
+ end
26
+
27
+ private
28
+
29
+ # 两个厂商的上传完成消息使用相同的文本及进度格式。
30
+ def completed_upload?(line)
31
+ line.match?(/\A
32
+ (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
33
+ (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
34
+ \z/ix) ||
35
+ line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
36
+ completed_progress?(line)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -13,6 +13,14 @@ module Net
13
13
  autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
14
14
 
15
15
  class Strategy
16
+ # 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
17
+ def self.file_extension = "cfg"
18
+
19
+ # 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
20
+ def self.filename(host, label: nil)
21
+ TftpTarget.filename(host, extension: file_extension, label: label)
22
+ end
23
+
16
24
  # 保存 TFTP 厂商策略使用的设备对象。
17
25
  def initialize(device)
18
26
  @device = device
@@ -23,7 +31,7 @@ module Net
23
31
  def source_file(value) = value
24
32
 
25
33
  # 根据设备地址生成默认远端文件名。
26
- def default_path(_source_file) = TftpTarget.filename(@device.host, extension: "cfg")
34
+ def default_path(_source_file) = self.class.filename(@device.host)
27
35
 
28
36
  # 返回目标中指定的远端文件名。
29
37
  def remote_path(target, _result) = target.path
@@ -53,16 +61,6 @@ module Net
53
61
  line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
54
62
  end
55
63
 
56
- # H3C 和华为的上传完成消息使用相同的文本及进度格式。
57
- def completed_upload?(line)
58
- line.match?(/\A
59
- (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
60
- (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
61
- \z/ix) ||
62
- line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
63
- completed_progress?(line)
64
- end
65
-
66
64
  # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
67
65
  def copied_bytes?(line)
68
66
  line.match?(/\A
@@ -15,24 +15,38 @@ module Net
15
15
 
16
16
  # 厂商拓扑规则;默认不提供任何设备能力。
17
17
  class Strategy
18
+ # 保存设备供厂商规则查询档案和会话状态。
18
19
  def initialize(device)
19
20
  @device = device
20
21
  end
21
22
 
23
+ # 声明策略类支持的拓扑能力,默认全部关闭。
22
24
  def self.supports?(_capability) = false
25
+ # 将实例级能力查询交给策略类声明。
23
26
  def supports?(capability) = self.class.supports?(capability)
24
27
 
28
+ # 返回读取邻居的命令,未定义时不支持发现。
25
29
  def neighbor_command = nil
30
+ # 根据原始输出选择邻居解析模板。
26
31
  def neighbor_template(_output) = nil
32
+ # 返回当前接口描述所用的解析模板。
27
33
  def description_template = nil
34
+ # 返回输出中应解析出的邻居数量,供完整性校验使用。
28
35
  def expected_neighbor_count(_output, _template) = 0
36
+ # 只有明确的空表证据才允许返回空邻居列表。
29
37
  def empty_neighbor_output?(_output) = false
38
+ # 返回进入设备配置视图的命令。
30
39
  def enter_configuration = nil
40
+ # 返回离开配置视图和保存配置所需的命令。
31
41
  def finish_commands = []
42
+ # 为一条接口描述变更生成厂商命令。
32
43
  def change_commands(_change) = []
44
+ # 标明邻居发现使用的协议。
33
45
  def protocol = :lldp
46
+ # 在下发前允许厂商调整命令对象及超时。
34
47
  def script_command(command) = command
35
48
 
49
+ # 将发现接口名归一为查找当前配置所用的键。
36
50
  def interface_key(name) = InterfaceName.key(name)
37
51
 
38
52
  # 将发现协议常用的接口简称展开为配置视图可识别的名称。
@@ -155,6 +155,7 @@ module Net
155
155
  Result.new(steps: result.steps, error: error)
156
156
  end
157
157
 
158
+ # 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
158
159
  def check_change_support!(phase)
159
160
  return if @strategy.supports?(:interface_description_changes)
160
161
 
@@ -6,6 +6,7 @@ module Net
6
6
  module Connector
7
7
  module CiscoIos
8
8
  class RunningConfig < Net::Connector::RunningConfig::Rendered
9
+ # 去掉终端长度设置、保存命令回显和易变化的配置时间注释。
9
10
  def clean(text)
10
11
  config = super
11
12
  config.gsub!(/^[^\n]*#\s*terminal\s+length\s+0\s*\n/i, "")
@@ -6,24 +6,33 @@ module Net
6
6
  module Connector
7
7
  module CiscoIos
8
8
  class Topology < Operations::Topology::Strategy
9
+ # IOS 提供 CDP 邻居、配置描述读取和描述变更。
9
10
  def self.supports?(capability)
10
11
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
11
12
  end
12
13
 
14
+ # 使用详细 CDP 输出获取对端设备与端口。
13
15
  def neighbor_command = "show cdp neighbors detail"
16
+ # 从运行配置解析接口描述。
14
17
  def description_template = "cisco_ios_running_config_interfaces.textfsm"
18
+ # 标记发现协议,供邻居记录使用。
15
19
  def protocol = :cdp
20
+ # 进入全局配置视图。
16
21
  def enter_configuration = "configure terminal"
22
+ # 退出配置视图并执行档案定义的保存命令。
17
23
  def finish_commands = ["end"] + @device.profile.save_commands
18
24
 
25
+ # 每个 Device ID 表示一条应由模板解析的邻居记录。
19
26
  def expected_neighbor_count(output, _template)
20
27
  output.scan(/^\s*Device ID:\s*\S+/i).size
21
28
  end
22
29
 
30
+ # 只有设备明确报告零 CDP 邻居时才认可空表。
23
31
  def empty_neighbor_output?(output)
24
32
  output.match?(/Total cdp entries displayed\s*:\s*0|no cdp neighbors/i)
25
33
  end
26
34
 
35
+ # 复用公共接口描述命令,保留本机接口原名。
27
36
  def change_commands(change)
28
37
  InterfaceDescription.commands(interface: change.interface, description: change.new_description)
29
38
  end
@@ -6,6 +6,7 @@ module Net
6
6
  module Connector
7
7
  module CiscoNxos
8
8
  class RunningConfig < CiscoIos::RunningConfig
9
+ # 先清除 NX-OS 分页与进度回显,再复用 IOS 的配置清理规则。
9
10
  def clean(text)
10
11
  without_pager = text.to_s.gsub(
11
12
  /[ \t]*(?:\x1b\[[0-9;]*[A-Za-z])*--More--
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/tftp/strategy"
3
+ require_relative "../../operations/tftp/file_upload"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module H3c
8
- class TftpBackup < Operations::Tftp::Strategy
8
+ class TftpBackup < Operations::Tftp::FileUpload
9
9
  # 选取并校验 H3C 启动配置文件路径。
10
10
  def source_file(value)
11
11
  return TftpTarget.validate_source_file!(value) if value
@@ -21,22 +21,6 @@ module Net
21
21
 
22
22
  TftpTarget.validate_source_file!(path.sub(/\(\*\)\z/, ""))
23
23
  end
24
-
25
- # 根据 H3C 源文件确定默认导出名称。
26
- def default_path(source_file) = File.basename(source_file)
27
-
28
- # 构造当前厂商的 TFTP 导出交互脚本。
29
- def script(target, source_file:, vrf: nil)
30
- raise ArgumentError, "H3C TFTP backup does not use vrf" if vrf
31
-
32
- destination = target.explicit_path? ? " #{target.path}" : ""
33
- Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
34
- end
35
-
36
- # 检查设备回显是否确认 TFTP 传输完成。
37
- def complete?(result)
38
- completion_lines(result).any? { |line| completed_upload?(line) }
39
- end
40
24
  end
41
25
  end
42
26
  end
@@ -7,15 +7,21 @@ module Net
7
7
  module H3c
8
8
  class Topology < Operations::Topology::Strategy
9
9
  TABLE_HEADER = /^\s*(?:System Name\s+Local Interface\b|(?:Local Interface|LocalIf)\s+)/i
10
+ # H3C 提供 LLDP 邻居、配置描述读取和描述变更。
10
11
  def self.supports?(capability)
11
12
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
12
13
  end
13
14
 
15
+ # 读取设备的 LLDP 邻居列表。
14
16
  def neighbor_command = "display lldp neighbor-information list"
17
+ # 从运行配置解析当前接口描述。
15
18
  def description_template = "h3c_interface_descriptions.textfsm"
19
+ # 进入系统视图以修改接口描述。
16
20
  def enter_configuration = "system-view"
21
+ # 返回用户视图并执行保存命令。
17
22
  def finish_commands = ["return"] + @device.profile.save_commands
18
23
 
24
+ # H3C 不同版本的列顺序不同,按表头选对应模板。
19
25
  def neighbor_template(output)
20
26
  return "h3c_lldp_name_first.textfsm" if output.match?(/^\s*System Name\s+Local Interface\b/im)
21
27
  return "h3c_lldp_local_first.textfsm" if output.match?(/^\s*(?:Local Interface|LocalIf)\s+/im)
@@ -24,6 +30,7 @@ module Net
24
30
  host: @device.host, phase: :discover)
25
31
  end
26
32
 
33
+ # 按表头后的有效数据行计数,未知行会让完整性校验失败。
27
34
  def expected_neighbor_count(output, template)
28
35
  local_column = template == "h3c_lldp_name_first.textfsm" ? 1 : 0
29
36
  lines = neighbor_lines(output)
@@ -38,12 +45,14 @@ module Net
38
45
  # 表头之后的未知行不能作为零邻居证据。
39
46
  def empty_neighbor_output?(output) = neighbor_lines(output).empty?
40
47
 
48
+ # 在接口视图设置描述后用 quit 返回系统视图。
41
49
  def change_commands(change)
42
50
  InterfaceDescription.commands(interface: change.interface, description: change.new_description, leave: "quit")
43
51
  end
44
52
 
45
53
  private
46
54
 
55
+ # 过滤表头、分隔线和设备提示符,保留待解析的邻居行。
47
56
  def neighbor_lines(output)
48
57
  output.lines.drop_while { |line| !line.match?(TABLE_HEADER) }.map(&:strip).reject do |line|
49
58
  line.empty? || line.match?(TABLE_HEADER) || line.match?(/\A[-=+ ]+\z/) ||
@@ -6,6 +6,7 @@ module Net
6
6
  module Connector
7
7
  module Hillstone
8
8
  class RunningConfig < Net::Connector::RunningConfig::Strategy
9
+ # 去掉山石 CLI 用退格控制符绘制的分页痕迹。
9
10
  def clean(text) = text.gsub(/\x00?\x08+[ \t]+\x08+/, "")
10
11
  end
11
12
  end