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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -68
- data/CONTRIBUTING.md +40 -0
- data/README.md +140 -95
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -5
- data/docs/architecture.md +321 -248
- data/lib/net/connector/device/base.rb +12 -4
- data/lib/net/connector/device/interface_description.rb +3 -0
- data/lib/net/connector/device/profile/builder.rb +236 -0
- data/lib/net/connector/device/profile.rb +2 -227
- data/lib/net/connector/device/running_config/strategy.rb +5 -0
- data/lib/net/connector/device/running_config.rb +7 -0
- data/lib/net/connector/engine/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +10 -7
- data/lib/net/connector/engine/dialogue.rb +53 -28
- data/lib/net/connector/engine/errors.rb +41 -54
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +23 -17
- data/lib/net/connector/engine/session.rb +39 -7
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- data/lib/net/connector/netdisco/batch.rb +31 -3
- data/lib/net/connector/netdisco/cli.rb +92 -50
- data/lib/net/connector/netdisco/client.rb +206 -73
- data/lib/net/connector/netdisco/config_file.rb +26 -4
- data/lib/net/connector/netdisco/device.rb +4 -8
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +105 -108
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/plan.rb +77 -0
- data/lib/net/connector/netdisco/planner.rb +12 -23
- data/lib/net/connector/netdisco/report.rb +94 -0
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +194 -85
- data/lib/net/connector/netdisco/worker.rb +43 -27
- data/lib/net/connector/operations/backup_lock.rb +116 -0
- data/lib/net/connector/operations/local_backup.rb +49 -9
- data/lib/net/connector/operations/parse_output.rb +20 -3
- data/lib/net/connector/operations/private_file.rb +94 -5
- data/lib/net/connector/operations/safe_file.rb +62 -0
- data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
- data/lib/net/connector/operations/saved_config.rb +40 -10
- data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
- data/lib/net/connector/operations/tftp/strategy.rb +9 -11
- data/lib/net/connector/operations/tftp_backup.rb +78 -15
- data/lib/net/connector/operations/tftp_receipt.rb +73 -0
- data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
- data/lib/net/connector/operations/topology/strategy.rb +34 -0
- data/lib/net/connector/operations/topology.rb +98 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
- data/lib/net/connector/vendor/h3c/topology.rb +17 -3
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
- data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
- data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
- data/lib/net/connector/vendor/radware/topology.rb +3 -0
- data/lib/net/connector/version.rb +1 -1
- 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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
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
|
-
|
|
136
|
-
raise ArgumentError, "plan commands were modified" unless commands == plan.commands
|
|
191
|
+
end
|
|
137
192
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
def execute_stage(script, steps)
|
|
213
|
+
result = @device.execute_script(script)
|
|
214
|
+
steps.concat(result.steps)
|
|
215
|
+
result
|
|
216
|
+
end
|
|
154
217
|
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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/
|
|
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::
|
|
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,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
|
-
|
|
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/
|
|
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::
|
|
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/
|
|
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::
|
|
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,12 +6,24 @@ module Net
|
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
8
|
class TftpBackup < Operations::Tftp::Strategy
|
|
9
|
-
#
|
|
10
|
-
def
|
|
11
|
-
|
|
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/
|
|
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::
|
|
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/
|
|
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::
|
|
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
|
|
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
|
-
|
|
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
|
|
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) =
|
|
43
|
+
def remote_path(_target, _result) = FILENAME
|
|
33
44
|
end
|
|
34
45
|
end
|
|
35
46
|
end
|