net-connector 0.4.0 → 0.4.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -68
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +140 -95
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -5
  7. data/docs/architecture.md +321 -248
  8. data/lib/net/connector/device/base.rb +12 -4
  9. data/lib/net/connector/device/interface_description.rb +3 -0
  10. data/lib/net/connector/device/profile/builder.rb +236 -0
  11. data/lib/net/connector/device/profile.rb +2 -227
  12. data/lib/net/connector/device/running_config/strategy.rb +5 -0
  13. data/lib/net/connector/device/running_config.rb +7 -0
  14. data/lib/net/connector/engine/command.rb +14 -2
  15. data/lib/net/connector/engine/configuration.rb +10 -7
  16. data/lib/net/connector/engine/dialogue.rb +53 -28
  17. data/lib/net/connector/engine/errors.rb +41 -54
  18. data/lib/net/connector/engine/execution.rb +32 -5
  19. data/lib/net/connector/engine/log.rb +23 -17
  20. data/lib/net/connector/engine/session.rb +39 -7
  21. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  22. data/lib/net/connector/netdisco/batch.rb +31 -3
  23. data/lib/net/connector/netdisco/cli.rb +92 -50
  24. data/lib/net/connector/netdisco/client.rb +206 -73
  25. data/lib/net/connector/netdisco/config_file.rb +26 -4
  26. data/lib/net/connector/netdisco/device.rb +4 -8
  27. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  28. data/lib/net/connector/netdisco/fleet.rb +105 -108
  29. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  30. data/lib/net/connector/netdisco/plan.rb +77 -0
  31. data/lib/net/connector/netdisco/planner.rb +12 -23
  32. data/lib/net/connector/netdisco/report.rb +94 -0
  33. data/lib/net/connector/netdisco/rules.rb +28 -5
  34. data/lib/net/connector/netdisco/settings.rb +194 -85
  35. data/lib/net/connector/netdisco/worker.rb +43 -27
  36. data/lib/net/connector/operations/backup_lock.rb +116 -0
  37. data/lib/net/connector/operations/local_backup.rb +49 -9
  38. data/lib/net/connector/operations/parse_output.rb +20 -3
  39. data/lib/net/connector/operations/private_file.rb +94 -5
  40. data/lib/net/connector/operations/safe_file.rb +62 -0
  41. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  42. data/lib/net/connector/operations/saved_config.rb +40 -10
  43. data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
  44. data/lib/net/connector/operations/tftp/strategy.rb +9 -11
  45. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  46. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  47. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  48. data/lib/net/connector/operations/topology/strategy.rb +34 -0
  49. data/lib/net/connector/operations/topology.rb +98 -29
  50. data/lib/net/connector/operations.rb +6 -0
  51. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  52. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  53. data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
  54. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
  55. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  56. data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
  57. data/lib/net/connector/vendor/h3c/topology.rb +17 -3
  58. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  59. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
  60. data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
  61. data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
  62. data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
  63. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
  64. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
  65. data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
  66. data/lib/net/connector/vendor/radware/topology.rb +3 -0
  67. data/lib/net/connector/version.rb +1 -1
  68. metadata +60 -28
@@ -28,7 +28,7 @@ module Net
28
28
  code: :neighbor_discovery_unsupported, host: @device.host, phase: :discover)
29
29
  end
30
30
 
31
- output = @device.execute(command).value!
31
+ output = @parser.utf8(@device.execute(command).value!, host: @device.host)
32
32
  template = @strategy.neighbor_template(output)
33
33
  rows = @parser.call(output, template: template, vendor: @device.vendor, command: command, host: @device.host)
34
34
  if rows.size != @strategy.expected_neighbor_count(output, template) || (rows.empty? && !@strategy.empty_neighbor_output?(output))
@@ -46,14 +46,21 @@ module Net
46
46
  end
47
47
 
48
48
  # 从运行配置读取已设置的接口描述或端口名称。
49
- def descriptions
49
+ def descriptions = read_descriptions
50
+
51
+ # 内部读取先交付执行步骤,再处理解析;解析失败也不能抹去已经完成的采集命令。
52
+ def read_descriptions
50
53
  template = @strategy.description_template
51
54
  unless @strategy.supports?(:interface_descriptions) && template
52
55
  raise UnsupportedOperation.new("interface descriptions are unsupported for this vendor",
53
56
  code: :description_unsupported, host: @device.host, phase: :discover)
54
57
  end
55
- config = @device.running_config.value!
56
- @parser.call(config, template: template, host: @device.host).each_with_object({}) do |row, found|
58
+ result = @device.running_config
59
+ yield result if block_given?
60
+ config = result.value!
61
+ rows = @parser.call(config, template: template, host: @device.host)
62
+ @strategy.validate_descriptions!(config, rows) if @strategy.respond_to?(:validate_descriptions!)
63
+ rows.each_with_object({}) do |row, found|
57
64
  interface = @strategy.interface_key(row.fetch("INTERFACE"))
58
65
  description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
59
66
  previous = found[interface]
@@ -65,6 +72,8 @@ module Net
65
72
  end
66
73
  end
67
74
 
75
+ private :read_descriptions
76
+
68
77
  # 比对邻居和现有描述,生成包含确切命令的只读变更计划。
69
78
  def plan_descriptions(abbreviate: true, lowercase: false, &formatter)
70
79
  check_change_support!(:plan) if @strategy.supports?(:neighbors)
@@ -88,7 +97,7 @@ module Net
88
97
  commands = if changes.empty?
89
98
  []
90
99
  else
91
- [@strategy.enter_configuration] + changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
100
+ stage_scripts(changes).values.flat_map { |script| script.map(&:text) }
92
101
  end
93
102
  commands.each(&:freeze)
94
103
  Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
@@ -102,18 +111,65 @@ module Net
102
111
  host: @device.host, phase: :apply)
103
112
  end
104
113
  raise ArgumentError, "plan must be a topology plan for this device" unless plan.is_a?(Plan) &&
105
- plan.host == @device.host &&
106
- plan.vendor == @device.vendor
107
- return Result.new if plan.changes.empty?
114
+ plan.host == @device.host &&
115
+ plan.vendor == @device.vendor
116
+ if plan.changes.empty?
117
+ raise ArgumentError, "empty topology plan cannot contain commands" unless plan.commands.empty?
118
+
119
+ return Result.new
120
+ end
108
121
 
109
- check_change_support!(:apply) if @strategy.supports?(:neighbors)
122
+ @strategy.supports?(:neighbors) ? check_change_support!(:apply) : neighbors
110
123
  @device.with_operation(:apply) { apply_plan(plan) }
111
124
  end
112
125
 
113
126
  private
114
127
 
115
- # 现场复核、修改和回读属于同一次独占操作。
128
+ # 同一租约覆盖重验、修改、读回与保存;有完成步骤后发生的错误必须带回这些步骤。
116
129
  def apply_plan(plan)
130
+ stages = stage_scripts(plan.changes)
131
+ unless stages.values.flat_map { |script| script.map(&:text) } == plan.commands
132
+ raise ArgumentError, "plan commands were modified or use an older execution sequence; regenerate the plan"
133
+ end
134
+ revalidate_plan!(plan)
135
+ steps = []
136
+ result = execute_stage(stages.fetch(:change), steps)
137
+ return Result.new(steps: steps, error: result.error) if result.failure?
138
+
139
+ verify_descriptions!(plan.changes, stages.fetch(:verify), steps)
140
+ persisting = true
141
+ result = execute_stage(stages.fetch(:persist), steps)
142
+ if result.failure? || !@strategy.persistence_confirmed?(result)
143
+ return Result.new(steps: steps, error: persistence_error(result.error))
144
+ end
145
+ Result.new(steps: steps)
146
+ rescue Error => error
147
+ raise unless steps
148
+
149
+ Result.new(steps: steps, error: persisting ? persistence_error(error) : error)
150
+ end
151
+
152
+ # 读回命令与审批一致,并且每个目标描述均得到确认,才允许进入保存阶段。
153
+ def verify_descriptions!(changes, script, steps)
154
+ unless Script.new(@device.config_commands).map(&:text) == script.map(&:text)
155
+ raise DeviceError.new("configuration readback commands changed after approval",
156
+ code: :verification_plan_changed, host: @device.host, phase: :verify)
157
+ end
158
+ updated = read_descriptions do |readback|
159
+ steps.concat(readback.steps)
160
+ if readback.success? && readback.steps.map { |step| step.command.text } != script.map(&:text)
161
+ raise DeviceError.new("configuration readback did not execute the approved commands",
162
+ code: :verification_plan_changed, host: @device.host, phase: :verify)
163
+ end
164
+ end
165
+ unless changes.all? { |change| updated[@strategy.interface_key(change.interface)] == change.new_description }
166
+ raise DeviceError.new("interface descriptions were not confirmed by readback",
167
+ code: :description_unconfirmed, host: @device.host, phase: :verify)
168
+ end
169
+ end
170
+
171
+ # 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
172
+ def revalidate_plan!(plan)
117
173
  discovered = neighbors
118
174
  evidence = evidence_for(discovered, descriptions)
119
175
  current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
@@ -132,41 +188,54 @@ module Net
132
188
  raise ArgumentError, "plan changes do not match its discovery evidence"
133
189
  end
134
190
  end
135
- commands = [@strategy.enter_configuration] + plan.changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
136
- raise ArgumentError, "plan commands were modified" unless commands == plan.commands
191
+ end
137
192
 
138
- script = commands.map do |command|
139
- @strategy.script_command(command)
193
+ # 审批列表保留真正执行的读回命令,使旧版“先保存后读回”计划无法静默复用。
194
+ def stage_scripts(changes)
195
+ phases = %i[leave_configuration verification_commands persistence_commands].map do |method|
196
+ @strategy.public_send(method) if @strategy.respond_to?(method)
140
197
  end
141
- result = @device.execute_script(Script.new(script, name: "interface descriptions"))
142
- return result if result.failure?
143
-
144
- updated = descriptions
145
- return result if plan.changes.all? do |change|
146
- updated.fetch(@strategy.interface_key(change.interface), "") == change.new_description
198
+ unless phases.all? { |commands| commands.is_a?(Array) && !commands.empty? }
199
+ raise UnsupportedOperation.new("topology strategy must declare separate readback and persistence stages",
200
+ code: :description_stages_unsupported, host: @device.host, phase: :plan)
201
+ end
202
+ leave, verify, persist = phases
203
+ sequences = {
204
+ change: [@strategy.enter_configuration] + changes.flat_map { |change| change_commands(change) } + leave,
205
+ verify: verify, persist: persist
206
+ }
207
+ sequences.transform_values do |commands|
208
+ Script.new(commands.map { |command| @strategy.script_command(command) }, name: "interface descriptions")
147
209
  end
210
+ end
148
211
 
149
- Result.new(steps: result.steps,
150
- error: DeviceError.new("interface descriptions were not confirmed by readback",
151
- code: :description_unconfirmed, host: @device.host, phase: :verify))
152
- rescue Error => error
153
- raise unless result&.success?
212
+ def execute_stage(script, steps)
213
+ result = @device.execute_script(script)
214
+ steps.concat(result.steps)
215
+ result
216
+ end
154
217
 
155
- Result.new(steps: result.steps, error: error)
218
+ # 保存可能已执行,超时、失败或缺少完成行均不自动重放;诊断不附带回显正文。
219
+ def persistence_error(error = nil)
220
+ underlying = UnderlyingError.new(error, nil, sensitive: true) if error
221
+ DeviceError.new("interface descriptions were verified; persistence was not confirmed",
222
+ code: :persistence_unconfirmed, host: @device.host, phase: :persist, underlying: underlying)
156
223
  end
157
224
 
225
+ # 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
158
226
  def check_change_support!(phase)
159
227
  return if @strategy.supports?(:interface_description_changes)
160
228
 
161
- raise UnsupportedOperation.new("interface description changes are unsupported for this vendor",
162
- code: :description_unsupported, host: @device.host, phase: phase)
229
+ code = @strategy.respond_to?(:change_error_code) ? @strategy.change_error_code : :description_unsupported
230
+ raise UnsupportedOperation.new("automatic interface description changes are unavailable; use a verified manual workflow",
231
+ code: code, host: @device.host, phase: phase)
163
232
  end
164
233
 
165
234
  # 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
166
235
  def evidence_for(discovered, current)
167
236
  interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
168
237
  if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
169
- peers.first.neighbor_interface.empty? }
238
+ peers.first.neighbor_interface.empty? }
170
239
  raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
171
240
  host: @device.host, phase: :plan)
172
241
  end
@@ -10,11 +10,17 @@ module Net
10
10
  autoload :RunningConfig, File.expand_path("operations/running_config", __dir__)
11
11
  autoload :ParseOutput, File.expand_path("operations/parse_output", __dir__)
12
12
  autoload :LocalBackup, File.expand_path("operations/local_backup", __dir__)
13
+ autoload :BackupLock, File.expand_path("operations/backup_lock", __dir__)
13
14
  autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
14
15
  autoload :Tftp, File.expand_path("operations/tftp/strategy", __dir__)
15
16
  end
16
17
  autoload :TftpTarget, File.expand_path("operations/tftp_backup", __dir__)
17
18
  autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
19
+ autoload :TftpReceipt, File.expand_path("operations/tftp_receipt", __dir__)
20
+ autoload :TftpCompletionError, File.expand_path("operations/tftp_receipt", __dir__)
18
21
  autoload :Backup, File.expand_path("operations/local_backup", __dir__)
22
+ autoload :BackupPersistenceError, File.expand_path("operations/local_backup", __dir__)
23
+ autoload :BackupBusy, File.expand_path("operations/backup_lock", __dir__)
24
+ autoload :SavedConfigChanged, File.expand_path("operations/saved_config/legacy_index", __dir__)
19
25
  end
20
26
  end
@@ -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,10 +6,17 @@ module Net
6
6
  module Connector
7
7
  module CiscoIos
8
8
  class TftpBackup < Operations::Tftp::Strategy
9
+ # 在连接前拒绝当前厂商不支持的参数组合。
10
+ def validate_options!(_target, source_file:, vrf: nil)
11
+ raise ArgumentError, "Cisco IOS does not use source_file" unless source_file.nil?
12
+ raise ArgumentError, "Cisco IOS TFTP backup does not use vrf" unless vrf.nil?
13
+ end
14
+
15
+ def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
16
+
9
17
  # 构造当前厂商的 TFTP 导出交互脚本。
10
18
  def script(target, source_file:, vrf: nil)
11
- raise ArgumentError, "Cisco IOS does not use source_file" if source_file
12
- raise ArgumentError, "Cisco IOS TFTP backup does not use vrf" if vrf
19
+ validate_options!(target, source_file: source_file, vrf: vrf)
13
20
 
14
21
  Script.new([Command.new("copy running-config tftp:", timeout: 180,
15
22
  interactions: [
@@ -1,29 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/topology/strategy"
3
+ require_relative "../../operations/topology/immediate_strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module CiscoIos
8
- class Topology < Operations::Topology::Strategy
8
+ class Topology < Operations::Topology::ImmediateStrategy
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
+
17
+ # 从运行配置解析接口描述。
14
18
  def description_template = "cisco_ios_running_config_interfaces.textfsm"
19
+
20
+ # 标记发现协议,供邻居记录使用。
15
21
  def protocol = :cdp
22
+
23
+ # 进入全局配置视图。
16
24
  def enter_configuration = "configure terminal"
17
- def finish_commands = ["end"] + @device.profile.save_commands
18
25
 
26
+ # 先回到执行视图读回,随后才执行档案中的保存命令。
27
+ def leave_configuration = ["end"]
28
+
29
+ def persistence_pattern = /\A(?:\[OK\]|Copy complete\.)\z/i
30
+
31
+ # 每个 Device ID 表示一条应由模板解析的邻居记录。
19
32
  def expected_neighbor_count(output, _template)
20
33
  output.scan(/^\s*Device ID:\s*\S+/i).size
21
34
  end
22
35
 
36
+ # 只有设备明确报告零 CDP 邻居时才认可空表。
23
37
  def empty_neighbor_output?(output)
24
38
  output.match?(/Total cdp entries displayed\s*:\s*0|no cdp neighbors/i)
25
39
  end
26
40
 
41
+ # 复用公共接口描述命令,保留本机接口原名。
27
42
  def change_commands(change)
28
43
  InterfaceDescription.commands(interface: change.interface, description: change.new_description)
29
44
  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--
@@ -6,9 +6,16 @@ module Net
6
6
  module Connector
7
7
  module CiscoNxos
8
8
  class TftpBackup < Operations::Tftp::Strategy
9
+ # 在连接前拒绝当前厂商不支持的参数组合。
10
+ def validate_options!(_target, source_file:, **)
11
+ raise ArgumentError, "Cisco NX-OS does not use source_file" unless source_file.nil?
12
+ end
13
+
14
+ def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
15
+
9
16
  # 构造当前厂商的 TFTP 导出交互脚本。
10
17
  def script(target, source_file:, vrf: nil)
11
- raise ArgumentError, "Cisco NX-OS does not use source_file" if source_file
18
+ validate_options!(target, source_file: source_file, vrf: vrf)
12
19
 
13
20
  vrf ||= "management"
14
21
  command = "copy running-config tftp://#{target.host}/#{target.path} vrf #{vrf}"
@@ -1,11 +1,16 @@
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
+ # 自动探测选择的是启动配置;显式文件仍不能推断为运行或启动配置。
10
+ def receipt_metadata(target, source_file:, explicit_source:)
11
+ super.merge(configuration_kind: explicit_source ? :saved_file : :startup)
12
+ end
13
+
9
14
  # 选取并校验 H3C 启动配置文件路径。
10
15
  def source_file(value)
11
16
  return TftpTarget.validate_source_file!(value) if value
@@ -21,22 +26,6 @@ module Net
21
26
 
22
27
  TftpTarget.validate_source_file!(path.sub(/\(\*\)\z/, ""))
23
28
  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
29
  end
41
30
  end
42
31
  end
@@ -1,21 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/topology/strategy"
3
+ require_relative "../../operations/topology/immediate_strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module H3c
8
- class Topology < Operations::Topology::Strategy
8
+ class Topology < Operations::Topology::ImmediateStrategy
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
+
18
+ # 从运行配置解析当前接口描述。
15
19
  def description_template = "h3c_interface_descriptions.textfsm"
20
+
21
+ # 进入系统视图以修改接口描述。
16
22
  def enter_configuration = "system-view"
17
- def finish_commands = ["return"] + @device.profile.save_commands
18
23
 
24
+ # 返回用户视图读回;保存交给后续独立阶段。
25
+ def leave_configuration = ["return"]
26
+
27
+ def persistence_pattern = /\ASaved the current configuration to mainboard device successfully\.\z/i
28
+
29
+ # H3C 不同版本的列顺序不同,按表头选对应模板。
19
30
  def neighbor_template(output)
20
31
  return "h3c_lldp_name_first.textfsm" if output.match?(/^\s*System Name\s+Local Interface\b/im)
21
32
  return "h3c_lldp_local_first.textfsm" if output.match?(/^\s*(?:Local Interface|LocalIf)\s+/im)
@@ -24,6 +35,7 @@ module Net
24
35
  host: @device.host, phase: :discover)
25
36
  end
26
37
 
38
+ # 按表头后的有效数据行计数,未知行会让完整性校验失败。
27
39
  def expected_neighbor_count(output, template)
28
40
  local_column = template == "h3c_lldp_name_first.textfsm" ? 1 : 0
29
41
  lines = neighbor_lines(output)
@@ -38,12 +50,14 @@ module Net
38
50
  # 表头之后的未知行不能作为零邻居证据。
39
51
  def empty_neighbor_output?(output) = neighbor_lines(output).empty?
40
52
 
53
+ # 在接口视图设置描述后用 quit 返回系统视图。
41
54
  def change_commands(change)
42
55
  InterfaceDescription.commands(interface: change.interface, description: change.new_description, leave: "quit")
43
56
  end
44
57
 
45
58
  private
46
59
 
60
+ # 过滤表头、分隔线和设备提示符,保留待解析的邻居行。
47
61
  def neighbor_lines(output)
48
62
  output.lines.drop_while { |line| !line.match?(TABLE_HEADER) }.map(&:strip).reject do |line|
49
63
  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
@@ -6,12 +6,24 @@ module Net
6
6
  module Connector
7
7
  module Hillstone
8
8
  class TftpBackup < Operations::Tftp::Strategy
9
- # 构造当前厂商的 TFTP 导出交互脚本。
10
- def script(target, source_file:, vrf: nil)
11
- raise ArgumentError, "Hillstone TFTP export does not use source_file" if source_file
9
+ # 山石原生导出的配置文件使用 dat 扩展名。
10
+ def self.file_extension = "dat"
11
+
12
+ # 在连接前拒绝源文件及远端目录;未指定文件名时仍由设备生成。
13
+ def validate_options!(target, source_file:, **)
14
+ raise ArgumentError, "Hillstone TFTP export does not use source_file" unless source_file.nil?
12
15
  if target.explicit_path? && target.path.include?("/")
13
16
  raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
14
17
  end
18
+ end
19
+
20
+ def receipt_metadata(target, **)
21
+ { configuration_kind: :startup, format: :dat, requested_path: target.explicit_path? ? target.path : nil }
22
+ end
23
+
24
+ # 构造当前厂商的 TFTP 导出交互脚本。
25
+ def script(target, source_file:, vrf: nil)
26
+ validate_options!(target, source_file: source_file, vrf: vrf)
15
27
 
16
28
  command = "export configuration startup to tftp server #{target.host} vrouter #{vrf || "mgt-vr"}"
17
29
  command += " #{target.path}" if target.explicit_path?
@@ -31,6 +43,7 @@ module Net
31
43
 
32
44
  private
33
45
 
46
+ # 仅认可设备明确给出目标文件名的 Export ok 回显。
34
47
  def completion_line(result)
35
48
  completion_lines(result).find { |line| line.match?(/\AExport\s+ok\s*,\s*target\s+file\s+name\s+\S+\z/i) }
36
49
  end
@@ -1,20 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/topology/strategy"
3
+ require_relative "../../operations/topology/immediate_strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module Hillstone
8
- class Topology < Operations::Topology::Strategy
8
+ class Topology < Operations::Topology::ImmediateStrategy
9
+ # 山石提供 LLDP 邻居、配置描述读取和描述变更。
9
10
  def self.supports?(capability)
10
11
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
11
12
  end
12
13
 
14
+ # 读取 LLDP 邻居信息。
13
15
  def neighbor_command = "show lldp neighbor-information"
16
+
17
+ # 从运行配置解析接口描述。
14
18
  def description_template = "hillstone_interface_descriptions.textfsm"
19
+
20
+ # 进入配置视图。
15
21
  def enter_configuration = "configure"
16
- def finish_commands = ["exit"] + @device.profile.save_commands
17
22
 
23
+ # 退出配置视图后先读回,保存动作随后单独执行。
24
+ def leave_configuration = ["exit"]
25
+
26
+ def persistence_pattern = /\ASaving configuration is finished\.?\z/i
27
+
28
+ # 用数据行和设备报告的总数交叉核对,避免遗漏邻居。
18
29
  def expected_neighbor_count(output, _template)
19
30
  reported = output[/Total lldp neighbor number:\s*(\d+)/i, 1]
20
31
  lines = neighbor_lines(output)
@@ -24,17 +35,20 @@ module Net
24
35
  lines.size
25
36
  end
26
37
 
38
+ # 仅在列表为空且有明确零邻居证据时认可空表。
27
39
  def empty_neighbor_output?(output)
28
40
  neighbor_lines(output).empty? && (output.match?(/Total lldp neighbor number:\s*0\b/i) ||
29
41
  output.match?(/System Name\s+Local Interface/i))
30
42
  end
31
43
 
44
+ # 复用公共接口描述命令。
32
45
  def change_commands(change)
33
46
  InterfaceDescription.commands(interface: change.interface, description: change.new_description)
34
47
  end
35
48
 
36
49
  private
37
50
 
51
+ # 去掉命令、表头、总数和提示符,只保留邻居数据行。
38
52
  def neighbor_lines(output)
39
53
  output.lines.map(&:strip).reject do |line|
40
54
  line.empty? || line == neighbor_command || line.match?(/\ASystem Name\s+Local Interface\b/i) ||
@@ -1,33 +1,23 @@
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 Huawei
8
- class TftpBackup < Operations::Tftp::Strategy
8
+ class TftpBackup < Operations::Tftp::FileUpload
9
+ # 缺少源文件也必须在首次设备 I/O 之前拒绝。
10
+ def validate_options!(target, source_file:, vrf: nil)
11
+ super
12
+ raise ArgumentError, "Huawei TFTP backup requires source_file" if source_file.nil?
13
+ end
14
+
9
15
  # 要求调用方提供合法的华为启动配置文件路径。
10
16
  def source_file(value)
11
17
  raise ArgumentError, "Huawei TFTP backup requires source_file" unless value
12
18
 
13
19
  TftpTarget.validate_source_file!(value)
14
20
  end
15
-
16
- # 根据华为配置源文件确定默认导出名称。
17
- def default_path(source_file) = File.basename(source_file)
18
-
19
- # 构造当前厂商的 TFTP 导出交互脚本。
20
- def script(target, source_file:, vrf: nil)
21
- raise ArgumentError, "Huawei TFTP backup does not use vrf" if vrf
22
-
23
- destination = target.explicit_path? ? " #{target.path}" : ""
24
- Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
25
- end
26
-
27
- # 检查设备回显是否确认 TFTP 传输完成。
28
- def complete?(result)
29
- completion_lines(result).any? { |line| completed_upload?(line) }
30
- end
31
21
  end
32
22
  end
33
23
  end
@@ -7,6 +7,7 @@ module Net
7
7
  module Connector
8
8
  module PaloAlto
9
9
  class RunningConfig < Net::Connector::RunningConfig::Strategy
10
+ # 配置位于 show 步骤,最后的 exit 只负责离开候选视图。
10
11
  def result_step(result)
11
12
  result.steps.find { |step| step.command.text == "show" }
12
13
  end
@@ -21,6 +22,7 @@ module Net
21
22
  end
22
23
  end
23
24
 
25
+ # 采集前后都检查候选差异,拒绝把未提交配置误作运行配置。
24
26
  def check_response(command, response, _execution)
25
27
  return unless command.text == "show config diff"
26
28
  return if body(response.output, command.text).empty?
@@ -29,6 +31,7 @@ module Net
29
31
  code: :uncommitted_configuration, host: @device.host, phase: :collect)
30
32
  end
31
33
 
34
+ # 只接受完整的 set 命令文本,排除 XML 或截断的引号配置。
32
35
  def clean(text)
33
36
  normalized = body(text, "show").sub(/(?:\A|\n)[ \t]*\[edit\][ \t]*\z/, "").strip
34
37
  unless set_commands?(normalized)
@@ -60,6 +63,7 @@ module Net
60
63
  pending.empty?
61
64
  end
62
65
 
66
+ # 去掉命令回显和提示符,保留真正需要校验的设备响应。
63
67
  def body(text, command)
64
68
  text.gsub("\r\n", "\n").sub(/\A[ \t]*#{Regexp.escape(command)}[ \t]*\n/, "")
65
69
  .sub(@device.profile.command_prompt, "").strip
@@ -6,18 +6,29 @@ module Net
6
6
  module Connector
7
7
  module PaloAlto
8
8
  class TftpBackup < Operations::Tftp::Strategy
9
+ FILENAME = "running-config.xml"
10
+
9
11
  # 返回 PAN-OS 固定使用的运行配置文件名。
10
- def default_path(_source_file) = "running-config.xml"
12
+ def self.filename(_host, **) = FILENAME
13
+
14
+ # 默认文件名尚未生成时只校验显式目标;固定名冲突仍由批量计划拒绝。
15
+ def validate_options!(target, source_file:, vrf: nil)
16
+ raise ArgumentError, "PAN-OS does not use source_file" unless source_file.nil?
17
+ raise ArgumentError, "PAN-OS TFTP backup does not use vrf" unless vrf.nil?
18
+ if target.explicit_path? && target.path != FILENAME
19
+ raise ArgumentError, "PAN-OS TFTP export uses the fixed filename #{FILENAME}"
20
+ end
21
+ end
22
+
23
+ def receipt_metadata(_target, **)
24
+ { configuration_kind: :running, format: :xml, source_file: FILENAME }
25
+ end
11
26
 
12
27
  # 构造当前厂商的 TFTP 导出交互脚本。
13
28
  def script(target, source_file:, vrf: nil)
14
- raise ArgumentError, "PAN-OS does not use source_file" if source_file
15
- raise ArgumentError, "PAN-OS TFTP backup does not use vrf" if vrf
16
- unless target.path == "running-config.xml"
17
- raise ArgumentError, "PAN-OS TFTP export uses the fixed filename running-config.xml"
18
- end
29
+ validate_options!(target, source_file: source_file, vrf: vrf)
19
30
 
20
- Script.new([Command.new("tftp export configuration to #{target.host} from running-config.xml",
31
+ Script.new([Command.new("tftp export configuration to #{target.host} from #{FILENAME}",
21
32
  timeout: 180)])
22
33
  end
23
34
 
@@ -29,7 +40,7 @@ module Net
29
40
  end
30
41
 
31
42
  # 返回 PAN-OS 实际上传的固定文件名。
32
- def remote_path(_target, _result) = "running-config.xml"
43
+ def remote_path(_target, _result) = FILENAME
33
44
  end
34
45
  end
35
46
  end