net-connector 0.4.1 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +61 -9
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -6
  7. data/docs/architecture.md +184 -7
  8. data/lib/net/connector/device/base.rb +11 -4
  9. data/lib/net/connector/device/running_config.rb +2 -0
  10. data/lib/net/connector/engine/command.rb +14 -2
  11. data/lib/net/connector/engine/configuration.rb +10 -7
  12. data/lib/net/connector/engine/dialogue.rb +52 -28
  13. data/lib/net/connector/engine/errors.rb +40 -55
  14. data/lib/net/connector/engine/execution.rb +32 -5
  15. data/lib/net/connector/engine/log.rb +11 -9
  16. data/lib/net/connector/engine/session.rb +38 -7
  17. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  18. data/lib/net/connector/netdisco/batch.rb +31 -3
  19. data/lib/net/connector/netdisco/cli.rb +73 -31
  20. data/lib/net/connector/netdisco/client.rb +206 -73
  21. data/lib/net/connector/netdisco/config_file.rb +26 -4
  22. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  23. data/lib/net/connector/netdisco/fleet.rb +103 -60
  24. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  25. data/lib/net/connector/netdisco/report.rb +94 -0
  26. data/lib/net/connector/netdisco/rules.rb +28 -5
  27. data/lib/net/connector/netdisco/settings.rb +194 -85
  28. data/lib/net/connector/netdisco/worker.rb +26 -17
  29. data/lib/net/connector/operations/backup_lock.rb +116 -0
  30. data/lib/net/connector/operations/local_backup.rb +49 -9
  31. data/lib/net/connector/operations/parse_output.rb +20 -3
  32. data/lib/net/connector/operations/private_file.rb +94 -5
  33. data/lib/net/connector/operations/safe_file.rb +62 -0
  34. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  35. data/lib/net/connector/operations/saved_config.rb +40 -10
  36. data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
  37. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  38. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  39. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  40. data/lib/net/connector/operations/topology/strategy.rb +20 -0
  41. data/lib/net/connector/operations/topology.rb +97 -29
  42. data/lib/net/connector/operations.rb +6 -0
  43. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  44. data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
  45. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  46. data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
  47. data/lib/net/connector/vendor/h3c/topology.rb +9 -4
  48. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
  49. data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
  50. data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
  51. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
  52. data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
  53. data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
  54. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  55. data/lib/net/connector/version.rb +1 -1
  56. metadata +35 -5
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+ require_relative "../../engine/terminal_renderer"
5
+
6
+ module Net
7
+ module Connector
8
+ module Operations
9
+ class Topology
10
+ # 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
11
+ class ImmediateStrategy < Strategy
12
+ def verification_commands = @device.config_commands
13
+ def persistence_commands = @device.profile.save_commands
14
+
15
+ # 保留旧扩展查询入口;公共执行流程使用独立阶段,不再执行这份合并列表。
16
+ def finish_commands = leave_configuration + persistence_commands
17
+
18
+ # TextFSM 不能静默跳过未知接口头;否则目标行正确也不足以证明读回证据完整。
19
+ def validate_descriptions!(config, rows)
20
+ expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
21
+ return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
22
+
23
+ raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
24
+ host: @device.host, phase: :verify)
25
+ end
26
+
27
+ # 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
28
+ def persistence_confirmed?(result)
29
+ !result.steps.empty? && result.steps.all? do |step|
30
+ raw = step.output.delete_suffix(step.prompt.to_s)
31
+ rendered = TerminalRenderer.render(raw)
32
+ failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
33
+ next false if raw.match?(failure) || rendered.match?(failure)
34
+
35
+ rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
36
+ end
37
+ end
38
+
39
+ # 没有明确完成证据的策略不能借用一个泛化的成功关键词。
40
+ def persistence_pattern = /\A\b\B\z/
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -22,27 +22,47 @@ module Net
22
22
 
23
23
  # 声明策略类支持的拓扑能力,默认全部关闭。
24
24
  def self.supports?(_capability) = false
25
+
25
26
  # 将实例级能力查询交给策略类声明。
26
27
  def supports?(capability) = self.class.supports?(capability)
27
28
 
28
29
  # 返回读取邻居的命令,未定义时不支持发现。
29
30
  def neighbor_command = nil
31
+
30
32
  # 根据原始输出选择邻居解析模板。
31
33
  def neighbor_template(_output) = nil
34
+
32
35
  # 返回当前接口描述所用的解析模板。
33
36
  def description_template = nil
37
+
34
38
  # 返回输出中应解析出的邻居数量,供完整性校验使用。
35
39
  def expected_neighbor_count(_output, _template) = 0
40
+
36
41
  # 只有明确的空表证据才允许返回空邻居列表。
37
42
  def empty_neighbor_output?(_output) = false
43
+
38
44
  # 返回进入设备配置视图的命令。
39
45
  def enter_configuration = nil
46
+
40
47
  # 返回离开配置视图和保存配置所需的命令。
41
48
  def finish_commands = []
49
+
50
+ # 未声明分阶段契约的旧策略仍可读取,但不能推断其退出、验证和保存的安全边界。
51
+ def leave_configuration = nil
52
+ def verification_commands = nil
53
+ def persistence_commands = nil
54
+ def persistence_confirmed?(_result) = false
55
+ def change_error_code = :description_unsupported
56
+
57
+ # 只读策略可以没有完整性扩展;立即生效的接口块模型会进一步校验解析数量。
58
+ def validate_descriptions!(_config, _rows) end
59
+
42
60
  # 为一条接口描述变更生成厂商命令。
43
61
  def change_commands(_change) = []
62
+
44
63
  # 标明邻居发现使用的协议。
45
64
  def protocol = :lldp
65
+
46
66
  # 在下发前允许厂商调整命令对象及超时。
47
67
  def script_command(command) = command
48
68
 
@@ -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,42 +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
 
158
225
  # 下发描述前确认厂商提供配置能力,避免把只读发现当成可写能力。
159
226
  def check_change_support!(phase)
160
227
  return if @strategy.supports?(:interface_description_changes)
161
228
 
162
- raise UnsupportedOperation.new("interface description changes are unsupported for this vendor",
163
- 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)
164
232
  end
165
233
 
166
234
  # 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
167
235
  def evidence_for(discovered, current)
168
236
  interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
169
237
  if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
170
- peers.first.neighbor_interface.empty? }
238
+ peers.first.neighbor_interface.empty? }
171
239
  raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
172
240
  host: @device.host, phase: :plan)
173
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,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,11 +1,11 @@
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
9
  # IOS 提供 CDP 邻居、配置描述读取和描述变更。
10
10
  def self.supports?(capability)
11
11
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
@@ -13,14 +13,20 @@ module Net
13
13
 
14
14
  # 使用详细 CDP 输出获取对端设备与端口。
15
15
  def neighbor_command = "show cdp neighbors detail"
16
+
16
17
  # 从运行配置解析接口描述。
17
18
  def description_template = "cisco_ios_running_config_interfaces.textfsm"
19
+
18
20
  # 标记发现协议,供邻居记录使用。
19
21
  def protocol = :cdp
22
+
20
23
  # 进入全局配置视图。
21
24
  def enter_configuration = "configure terminal"
22
- # 退出配置视图并执行档案定义的保存命令。
23
- def finish_commands = ["end"] + @device.profile.save_commands
25
+
26
+ # 先回到执行视图读回,随后才执行档案中的保存命令。
27
+ def leave_configuration = ["end"]
28
+
29
+ def persistence_pattern = /\A(?:\[OK\]|Copy complete\.)\z/i
24
30
 
25
31
  # 每个 Device ID 表示一条应由模板解析的邻居记录。
26
32
  def expected_neighbor_count(output, _template)
@@ -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}"
@@ -6,6 +6,11 @@ module Net
6
6
  module Connector
7
7
  module H3c
8
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
@@ -1,11 +1,11 @@
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
10
  # H3C 提供 LLDP 邻居、配置描述读取和描述变更。
11
11
  def self.supports?(capability)
@@ -14,12 +14,17 @@ module Net
14
14
 
15
15
  # 读取设备的 LLDP 邻居列表。
16
16
  def neighbor_command = "display lldp neighbor-information list"
17
+
17
18
  # 从运行配置解析当前接口描述。
18
19
  def description_template = "h3c_interface_descriptions.textfsm"
20
+
19
21
  # 进入系统视图以修改接口描述。
20
22
  def enter_configuration = "system-view"
21
- # 返回用户视图并执行保存命令。
22
- def finish_commands = ["return"] + @device.profile.save_commands
23
+
24
+ # 返回用户视图读回;保存交给后续独立阶段。
25
+ def leave_configuration = ["return"]
26
+
27
+ def persistence_pattern = /\ASaved the current configuration to mainboard device successfully\.\z/i
23
28
 
24
29
  # H3C 不同版本的列顺序不同,按表头选对应模板。
25
30
  def neighbor_template(output)
@@ -9,12 +9,21 @@ module Net
9
9
  # 山石原生导出的配置文件使用 dat 扩展名。
10
10
  def self.file_extension = "dat"
11
11
 
12
- # 构造当前厂商的 TFTP 导出交互脚本。
13
- def script(target, source_file:, vrf: nil)
14
- raise ArgumentError, "Hillstone TFTP export does not use source_file" if source_file
12
+ # 在连接前拒绝源文件及远端目录;未指定文件名时仍由设备生成。
13
+ def validate_options!(target, source_file:, **)
14
+ raise ArgumentError, "Hillstone TFTP export does not use source_file" unless source_file.nil?
15
15
  if target.explicit_path? && target.path.include?("/")
16
16
  raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
17
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)
18
27
 
19
28
  command = "export configuration startup to tftp server #{target.host} vrouter #{vrf || "mgt-vr"}"
20
29
  command += " #{target.path}" if target.explicit_path?
@@ -1,11 +1,11 @@
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
9
  # 山石提供 LLDP 邻居、配置描述读取和描述变更。
10
10
  def self.supports?(capability)
11
11
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
@@ -13,12 +13,17 @@ module Net
13
13
 
14
14
  # 读取 LLDP 邻居信息。
15
15
  def neighbor_command = "show lldp neighbor-information"
16
+
16
17
  # 从运行配置解析接口描述。
17
18
  def description_template = "hillstone_interface_descriptions.textfsm"
19
+
18
20
  # 进入配置视图。
19
21
  def enter_configuration = "configure"
20
- # 退出配置视图并保存设备配置。
21
- def finish_commands = ["exit"] + @device.profile.save_commands
22
+
23
+ # 退出配置视图后先读回,保存动作随后单独执行。
24
+ def leave_configuration = ["exit"]
25
+
26
+ def persistence_pattern = /\ASaving configuration is finished\.?\z/i
22
27
 
23
28
  # 用数据行和设备报告的总数交叉核对,避免遗漏邻居。
24
29
  def expected_neighbor_count(output, _template)
@@ -6,6 +6,12 @@ module Net
6
6
  module Connector
7
7
  module Huawei
8
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
@@ -11,13 +11,22 @@ module Net
11
11
  # 返回 PAN-OS 固定使用的运行配置文件名。
12
12
  def self.filename(_host, **) = FILENAME
13
13
 
14
- # 构造当前厂商的 TFTP 导出交互脚本。
15
- def script(target, source_file:, vrf: nil)
16
- raise ArgumentError, "PAN-OS does not use source_file" if source_file
17
- raise ArgumentError, "PAN-OS TFTP backup does not use vrf" if vrf
18
- unless target.path == FILENAME
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
19
  raise ArgumentError, "PAN-OS TFTP export uses the fixed filename #{FILENAME}"
20
20
  end
21
+ end
22
+
23
+ def receipt_metadata(_target, **)
24
+ { configuration_kind: :running, format: :xml, source_file: FILENAME }
25
+ end
26
+
27
+ # 构造当前厂商的 TFTP 导出交互脚本。
28
+ def script(target, source_file:, vrf: nil)
29
+ validate_options!(target, source_file: source_file, vrf: vrf)
21
30
 
22
31
  Script.new([Command.new("tftp export configuration to #{target.host} from #{FILENAME}",
23
32
  timeout: 180)])
@@ -6,17 +6,22 @@ module Net
6
6
  module Connector
7
7
  module PaloAlto
8
8
  class Topology < Operations::Topology::Strategy
9
- # PAN-OS 提供 LLDP 邻居、接口备注读取和备注变更。
9
+ # 候选配置缺少经固件实验确认的所有权/commit 协议,暂只提供读取能力。
10
10
  def self.supports?(capability)
11
- %i[neighbors interface_descriptions interface_description_changes].include?(capability)
11
+ %i[neighbors interface_descriptions].include?(capability)
12
12
  end
13
13
 
14
+ def change_error_code = :candidate_isolation_unavailable
15
+
14
16
  # 读取各本机接口的 LLDP 邻居详情。
15
17
  def neighbor_command = "show lldp neighbors all"
18
+
16
19
  # 从 set 格式配置解析接口备注。
17
20
  def description_template = "palo_alto_interface_descriptions.textfsm"
21
+
18
22
  # 进入候选配置视图。
19
23
  def enter_configuration = "configure"
24
+
20
25
  # 提交候选配置后退出配置视图。
21
26
  def finish_commands = ["commit", "exit"]
22
27
 
@@ -9,10 +9,17 @@ module Net
9
9
  # Radware 原生导出生成压缩配置归档。
10
10
  def self.file_extension = "tgz"
11
11
 
12
+ # 在连接前拒绝当前厂商不支持的参数组合。
13
+ def validate_options!(_target, source_file:, vrf: nil)
14
+ raise ArgumentError, "Radware Alteon does not use source_file" unless source_file.nil?
15
+ raise ArgumentError, "Radware TFTP backup does not use vrf" unless vrf.nil?
16
+ end
17
+
18
+ def receipt_metadata(_target, **) = { configuration_kind: :native_archive, format: :tgz }
19
+
12
20
  # 构造当前厂商的 TFTP 导出交互脚本。
13
21
  def script(target, source_file:, vrf: nil)
14
- raise ArgumentError, "Radware Alteon does not use source_file" if source_file
15
- raise ArgumentError, "Radware TFTP backup does not use vrf" if vrf
22
+ validate_options!(target, source_file: source_file, vrf: vrf)
16
23
 
17
24
  Script.new([Command.new("/cfg/ptcfg #{target.host} -tftp", timeout: 180, interactions: [
18
25
  interaction(/Enter\s+(?:hostname|IP address)[^\r\n]*:\s*\z/i, "#{target.host}\n"),
@@ -8,6 +8,7 @@ module Net
8
8
  class Topology < Operations::Topology::Strategy
9
9
  # Alteon 只支持读取端口描述,不提供邻居发现或自动改写。
10
10
  def self.supports?(capability) = capability == :interface_descriptions
11
+
11
12
  # 从设备配置转储解析端口名称。
12
13
  def description_template = "radware_port_names.textfsm"
13
14
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Connector
5
- VERSION = "0.4.1"
5
+ VERSION = "0.4.2"
6
6
  end
7
7
  end