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
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "ipaddr"
|
|
5
5
|
require_relative "private_file"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "safe_file"
|
|
7
|
+
require_relative "saved_config/legacy_index"
|
|
7
8
|
|
|
8
9
|
module Net
|
|
9
10
|
module Connector
|
|
@@ -19,34 +20,61 @@ module Net
|
|
|
19
20
|
raise ArgumentError, "host must be an IP address"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
#
|
|
23
|
-
def initialize(directory:)
|
|
23
|
+
# 默认每次实时查找;批次显式 indexed 时共享一份惰性的旧命名文件快照。
|
|
24
|
+
def initialize(directory:, indexed: false)
|
|
24
25
|
raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
|
|
26
|
+
raise ArgumentError, "indexed must be true or false" unless [true, false].include?(indexed)
|
|
25
27
|
|
|
26
|
-
@directory = File.expand_path(directory)
|
|
28
|
+
@directory = File.expand_path(directory).freeze
|
|
29
|
+
@legacy_index = LegacyIndex.new(@directory) if indexed
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# 优先查找固定文件名;仅在不存在时兼容唯一的旧命名文件。
|
|
30
33
|
def find(host, required: true)
|
|
34
|
+
with_file(host, required: required) { |path, _file, _stat| path }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 比较基线也从经过 fstat 的同一 FD 读取,不在 find 后重新按路径打开。
|
|
38
|
+
def fingerprint(host, required: true)
|
|
39
|
+
with_file(host, required: required) { |path, file, stat| SafeFile.fingerprint_io(path, file, stat) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read(host)
|
|
43
|
+
with_file(host) { |_path, file, _stat| file.read }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_path(host, required:)
|
|
31
47
|
filename = self.class.filename(host)
|
|
32
48
|
path = File.join(@directory, filename)
|
|
33
49
|
unless File.exist?(path) || File.symlink?(path)
|
|
34
|
-
entries =
|
|
50
|
+
entries = if @legacy_index
|
|
51
|
+
@legacy_index.candidates(filename)
|
|
52
|
+
else
|
|
53
|
+
Dir.children(@directory).select { |name| name.end_with?("-#{filename}") }
|
|
54
|
+
end
|
|
35
55
|
return nil if entries.empty? && !required
|
|
36
56
|
|
|
37
57
|
raise ArgumentError, "no saved configuration for #{host}" if entries.empty?
|
|
38
58
|
raise ArgumentError, "multiple saved configurations for #{host}" if entries.size > 1
|
|
39
59
|
|
|
40
|
-
path = File.join(@directory, entries.first)
|
|
60
|
+
path = @legacy_index ? entries.first : File.join(@directory, entries.first)
|
|
41
61
|
end
|
|
42
|
-
raise ArgumentError, "saved configuration is not a regular file" unless File.file?(path) && !File.symlink?(path)
|
|
43
|
-
|
|
44
62
|
path
|
|
45
63
|
end
|
|
46
64
|
|
|
65
|
+
def with_file(host, required: true)
|
|
66
|
+
path = resolve_path(host, required: required)
|
|
67
|
+
return nil unless path
|
|
68
|
+
return @legacy_index.open(path) { |name, file, stat| yield name, file, stat } if path.is_a?(LegacyIndex::Entry)
|
|
69
|
+
|
|
70
|
+
SafeFile.open(path) { |file, stat| yield path, file, stat }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private :resolve_path, :with_file
|
|
74
|
+
|
|
47
75
|
# 将已保存配置写入目标文件或标准输出。
|
|
48
76
|
def export(host:, output: nil, io: $stdout)
|
|
49
|
-
contents =
|
|
77
|
+
contents = read(host)
|
|
50
78
|
if output
|
|
51
79
|
raise ArgumentError, "output must be a nonempty String" unless output.is_a?(String) && !output.empty?
|
|
52
80
|
|
|
@@ -63,8 +91,10 @@ module Net
|
|
|
63
91
|
|
|
64
92
|
# 使用指定模板解析已有配置,无需访问设备或 Netdisco。
|
|
65
93
|
def parse(host:, template:, template_dir: nil)
|
|
94
|
+
require_relative "parse_output"
|
|
95
|
+
|
|
66
96
|
parser = ParseOutput.new(template_dir: template_dir)
|
|
67
|
-
parser.call(
|
|
97
|
+
parser.call(read(host), template: template, host: host)
|
|
68
98
|
end
|
|
69
99
|
end
|
|
70
100
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
# H3C 探测源文件之前先拒绝不支持的参数;探测本身不能放进纯校验钩子。
|
|
15
|
+
def validate_options!(_target, source_file:, vrf: nil)
|
|
16
|
+
raise ArgumentError, "TFTP file upload does not use vrf" unless vrf.nil?
|
|
17
|
+
|
|
18
|
+
TftpTarget.validate_source_file!(source_file) unless source_file.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 指定文件的内容与格式未知,不能仅凭 .cfg 后缀声称是当前运行配置。
|
|
22
|
+
def receipt_metadata(_target, source_file:, **)
|
|
23
|
+
{ configuration_kind: :saved_file, source_file: source_file, format: :unknown }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
|
|
27
|
+
def script(target, source_file:, vrf: nil)
|
|
28
|
+
validate_options!(target, source_file: source_file, vrf: vrf)
|
|
29
|
+
TftpTarget.validate_source_file!(source_file)
|
|
30
|
+
|
|
31
|
+
destination = target.explicit_path? ? " #{target.path}" : ""
|
|
32
|
+
Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 只接受明确的成功消息、非零传输字节数或完整进度行。
|
|
36
|
+
def complete?(result)
|
|
37
|
+
completion_lines(result).any? { |line| completed_upload?(line) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# 两个厂商的上传完成消息使用相同的文本及进度格式。
|
|
43
|
+
def completed_upload?(line)
|
|
44
|
+
line.match?(/\A
|
|
45
|
+
(?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
|
|
46
|
+
(?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
|
|
47
|
+
\z/ix) ||
|
|
48
|
+
line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
|
|
49
|
+
completed_progress?(line)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
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) =
|
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "tftp/strategy"
|
|
5
|
+
require_relative "tftp_receipt"
|
|
5
6
|
|
|
6
7
|
module Net
|
|
7
8
|
module Connector
|
|
@@ -38,6 +39,10 @@ module Net
|
|
|
38
39
|
|
|
39
40
|
# 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
|
|
40
41
|
def self.filename(host, extension:, label: nil)
|
|
42
|
+
if label && (!label.is_a?(String) || !label.ascii_only?)
|
|
43
|
+
raise ArgumentError, "TFTP label must be an ASCII String"
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
address = host.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
42
47
|
suffix = "#{address}.#{extension}"
|
|
43
48
|
if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
|
|
@@ -64,42 +69,100 @@ module Net
|
|
|
64
69
|
# 保存执行 TFTP 导出的设备对象。
|
|
65
70
|
def initialize(device) = @device = device
|
|
66
71
|
|
|
67
|
-
#
|
|
72
|
+
# 保留原有返回结构;扩展元数据由 call_receipt 单独提供。
|
|
68
73
|
def call(host:, path: nil, source_file: nil, vrf: nil)
|
|
74
|
+
call_receipt(host: host, path: path, source_file: source_file, vrf: vrf).transfer
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
|
|
78
|
+
def call_receipt(host:, path: nil, source_file: nil, vrf: nil)
|
|
79
|
+
receipt = nil
|
|
69
80
|
strategy_class = @device.profile.tftp_strategy
|
|
70
81
|
unless strategy_class
|
|
71
82
|
raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
|
|
72
83
|
host: @device.host, phase: :tftp_backup)
|
|
73
84
|
end
|
|
74
85
|
strategy = strategy_class.new(@device)
|
|
75
|
-
if vrf && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
|
|
86
|
+
if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
|
|
76
87
|
raise ArgumentError, "vrf must be a single safe name"
|
|
77
88
|
end
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
|
|
90
|
+
vrf = vrf&.dup&.freeze
|
|
91
|
+
target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
|
|
92
|
+
if strategy_hook?(strategy, :validate_options!)
|
|
93
|
+
strategy.validate_options!(target, source_file: source_file, vrf: vrf)
|
|
94
|
+
end
|
|
95
|
+
@device.with_operation(:tftp_backup) do
|
|
96
|
+
source = strategy.source_file(source_file)
|
|
97
|
+
source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
|
|
98
|
+
target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
|
|
99
|
+
result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
|
|
100
|
+
name: :tftp_backup, privilege: false)
|
|
101
|
+
confirm_transfer!(strategy, target, result)
|
|
102
|
+
# 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
|
|
103
|
+
transfer = Net::Connector::TftpBackup.new(server: target.host, path: nil, completed_at: Time.now.utc)
|
|
104
|
+
requested_path = target.explicit_path? ? target.path : nil
|
|
105
|
+
receipt = TftpReceipt.new(transfer: transfer, actual_path: nil, requested_path: requested_path, source_file: source)
|
|
106
|
+
actual_path = remote_path(strategy, target, result, receipt)
|
|
107
|
+
receipt = TftpReceipt.new(transfer: transfer.with(path: actual_path), actual_path: actual_path,
|
|
108
|
+
requested_path: requested_path, source_file: source)
|
|
109
|
+
receipt = receipt_metadata(strategy, target, source, !source_file.nil?, receipt)
|
|
110
|
+
if target.explicit_path? && target.path != receipt.actual_path
|
|
111
|
+
raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_mismatch, host: @device.host), cause: nil
|
|
112
|
+
end
|
|
113
|
+
# 已完成步骤仍可能伴随后处理/清理错误;上面的回执要先构造,再重新抛出。
|
|
114
|
+
result.value! if result.failure?
|
|
115
|
+
@device.record_event("tftp_backup", status: "reported_uploaded", server: target.host, path: receipt.actual_path)
|
|
116
|
+
receipt
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError => error
|
|
119
|
+
raise unless receipt
|
|
120
|
+
raise if error.instance_of?(TftpCompletionError) && error.receipt.equal?(receipt)
|
|
121
|
+
|
|
122
|
+
raise TftpCompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# 旧扩展若重写 script,不继承父厂商新增的参数限制或配置来源声明。
|
|
128
|
+
# 同时重写相应钩子才表示扩展已为自己的脚本实现这项契约。
|
|
129
|
+
def strategy_hook?(strategy, name)
|
|
130
|
+
strategy.respond_to?(name) && !!(strategy.method(name).owner <= strategy.method(:script).owner)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def receipt_metadata(strategy, target, source_file, explicit_source, receipt)
|
|
134
|
+
return receipt unless strategy_hook?(strategy, :receipt_metadata)
|
|
135
|
+
|
|
136
|
+
metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
|
|
137
|
+
unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
|
|
138
|
+
raise ArgumentError, "invalid TFTP strategy receipt metadata"
|
|
139
|
+
end
|
|
140
|
+
TftpReceipt.new(transfer: receipt.transfer, actual_path: receipt.actual_path,
|
|
141
|
+
**{ source_file: source_file, requested_path: target.path }.merge(metadata))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def remote_path(strategy, target, result, receipt)
|
|
145
|
+
TftpTarget.validate_path!(strategy.remote_path(target, result))
|
|
146
|
+
rescue StandardError => error
|
|
147
|
+
raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_unconfirmed,
|
|
148
|
+
host: @device.host, underlying: error), cause: nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
|
|
152
|
+
def confirm_transfer!(strategy, target, result)
|
|
85
153
|
if transfer_failed?(result)
|
|
86
154
|
@device.record_event("tftp_backup", level: :error, status: "transfer_failed", server: target.host)
|
|
87
155
|
raise DeviceError.new("device reported TFTP backup failure",
|
|
88
156
|
code: :transfer_failed, host: @device.host, phase: :tftp_backup)
|
|
89
157
|
end
|
|
90
158
|
unless strategy.complete?(result)
|
|
159
|
+
result.value! if result.failure?
|
|
91
160
|
@device.record_event("tftp_backup", level: :error, status: "transfer_unconfirmed", server: target.host)
|
|
92
161
|
raise DeviceError.new("device did not confirm TFTP backup completion",
|
|
93
162
|
code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
|
|
94
163
|
end
|
|
95
|
-
|
|
96
|
-
remote_path = strategy.remote_path(target, result)
|
|
97
|
-
@device.record_event("tftp_backup", status: "reported_uploaded", server: target.host, path: remote_path)
|
|
98
|
-
Net::Connector::TftpBackup.new(server: target.host, path: remote_path, completed_at: Time.now.utc)
|
|
99
164
|
end
|
|
100
165
|
|
|
101
|
-
private
|
|
102
|
-
|
|
103
166
|
# 识别设备回显中的传输失败信息。
|
|
104
167
|
def transfer_failed?(result)
|
|
105
168
|
result.steps.any? do |step|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../engine/errors"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
# 元数据采用组合,不改变 TftpBackup 的位置参数、成员或解构契约。
|
|
8
|
+
class TftpReceipt
|
|
9
|
+
KINDS = %i[running startup saved_file native_archive unknown].freeze
|
|
10
|
+
FORMATS = %i[cfg xml dat tgz unknown].freeze
|
|
11
|
+
private_constant :KINDS, :FORMATS
|
|
12
|
+
|
|
13
|
+
attr_reader :transfer, :configuration_kind, :source_file, :format, :requested_path, :actual_path,
|
|
14
|
+
:verification, :server_sha256
|
|
15
|
+
|
|
16
|
+
def initialize(transfer:, configuration_kind: :unknown, source_file: nil, format: :unknown,
|
|
17
|
+
requested_path: nil, actual_path:, verification: :device_reported, server_sha256: nil)
|
|
18
|
+
unless transfer.instance_of?(TftpBackup) && transfer.completed_at.is_a?(Time) && transfer.path == actual_path
|
|
19
|
+
raise ArgumentError, "TFTP receipt requires a matching transfer"
|
|
20
|
+
end
|
|
21
|
+
TftpTarget.new(host: transfer.server, path: actual_path.nil? ? "unconfirmed" : actual_path)
|
|
22
|
+
TftpTarget.validate_path!(requested_path) unless requested_path.nil?
|
|
23
|
+
TftpTarget.validate_source_file!(source_file) unless source_file.nil?
|
|
24
|
+
unless KINDS.include?(configuration_kind) && FORMATS.include?(format)
|
|
25
|
+
raise ArgumentError, "invalid TFTP configuration kind or format"
|
|
26
|
+
end
|
|
27
|
+
valid_verification = (verification == :device_reported && server_sha256.nil?) ||
|
|
28
|
+
(verification == :server_verified && !actual_path.nil? && server_sha256.is_a?(String) &&
|
|
29
|
+
server_sha256.match?(/\A[0-9a-f]{64}\z/))
|
|
30
|
+
raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
|
|
31
|
+
|
|
32
|
+
@actual_path, @requested_path = actual_path&.dup&.freeze, requested_path&.dup&.freeze
|
|
33
|
+
@source_file, @server_sha256 = source_file&.dup&.freeze, server_sha256&.dup&.freeze
|
|
34
|
+
@configuration_kind, @format, @verification = configuration_kind, format, verification
|
|
35
|
+
@transfer = transfer.with(server: transfer.server.dup.freeze, path: @actual_path,
|
|
36
|
+
completed_at: transfer.completed_at.dup.freeze)
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 摘要不展开设备输出或调用方的源文件名。
|
|
41
|
+
def inspect = "#<#{self.class} kind=#{configuration_kind} format=#{format} verification=#{verification}>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 上传已确认后的失败不能被解释为“尚未上传”,也不能携带原始诊断正文。
|
|
45
|
+
class TftpCompletionError < Error
|
|
46
|
+
MESSAGES = {
|
|
47
|
+
transfer_finalize_failed: "device reported TFTP completion but finalization failed",
|
|
48
|
+
transfer_path_unconfirmed: "device reported TFTP completion but its remote path is unconfirmed",
|
|
49
|
+
transfer_path_mismatch: "device reported TFTP completion at a different remote path"
|
|
50
|
+
}.freeze
|
|
51
|
+
private_constant :MESSAGES
|
|
52
|
+
|
|
53
|
+
attr_reader :receipt, :underlying_type
|
|
54
|
+
|
|
55
|
+
def initialize(receipt:, code: :transfer_finalize_failed, host: nil, underlying: nil)
|
|
56
|
+
unless receipt.instance_of?(TftpReceipt) && MESSAGES.key?(code)
|
|
57
|
+
raise ArgumentError, "TFTP completion error requires a transfer receipt and a known failure code"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@receipt = receipt
|
|
61
|
+
type = if underlying.is_a?(Error) && underlying.underlying.instance_of?(UnderlyingError)
|
|
62
|
+
underlying.underlying.type
|
|
63
|
+
else
|
|
64
|
+
underlying&.class&.name
|
|
65
|
+
end
|
|
66
|
+
@underlying_type = type&.match?(/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/) && type.bytesize <= 128 ? type.freeze : nil
|
|
67
|
+
super(MESSAGES.fetch(code), code: code, host: host, phase: :tftp_backup)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transfer = receipt.transfer
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|
|
@@ -15,24 +15,58 @@ 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
|
+
|
|
26
|
+
# 将实例级能力查询交给策略类声明。
|
|
23
27
|
def supports?(capability) = self.class.supports?(capability)
|
|
24
28
|
|
|
29
|
+
# 返回读取邻居的命令,未定义时不支持发现。
|
|
25
30
|
def neighbor_command = nil
|
|
31
|
+
|
|
32
|
+
# 根据原始输出选择邻居解析模板。
|
|
26
33
|
def neighbor_template(_output) = nil
|
|
34
|
+
|
|
35
|
+
# 返回当前接口描述所用的解析模板。
|
|
27
36
|
def description_template = nil
|
|
37
|
+
|
|
38
|
+
# 返回输出中应解析出的邻居数量,供完整性校验使用。
|
|
28
39
|
def expected_neighbor_count(_output, _template) = 0
|
|
40
|
+
|
|
41
|
+
# 只有明确的空表证据才允许返回空邻居列表。
|
|
29
42
|
def empty_neighbor_output?(_output) = false
|
|
43
|
+
|
|
44
|
+
# 返回进入设备配置视图的命令。
|
|
30
45
|
def enter_configuration = nil
|
|
46
|
+
|
|
47
|
+
# 返回离开配置视图和保存配置所需的命令。
|
|
31
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
|
+
|
|
60
|
+
# 为一条接口描述变更生成厂商命令。
|
|
32
61
|
def change_commands(_change) = []
|
|
62
|
+
|
|
63
|
+
# 标明邻居发现使用的协议。
|
|
33
64
|
def protocol = :lldp
|
|
65
|
+
|
|
66
|
+
# 在下发前允许厂商调整命令对象及超时。
|
|
34
67
|
def script_command(command) = command
|
|
35
68
|
|
|
69
|
+
# 将发现接口名归一为查找当前配置所用的键。
|
|
36
70
|
def interface_key(name) = InterfaceName.key(name)
|
|
37
71
|
|
|
38
72
|
# 将发现协议常用的接口简称展开为配置视图可识别的名称。
|