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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/CONTRIBUTING.md +40 -0
- data/README.md +61 -9
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -6
- data/docs/architecture.md +184 -7
- data/lib/net/connector/device/base.rb +11 -4
- data/lib/net/connector/device/running_config.rb +2 -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 +52 -28
- data/lib/net/connector/engine/errors.rb +40 -55
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +11 -9
- data/lib/net/connector/engine/session.rb +38 -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 +73 -31
- data/lib/net/connector/netdisco/client.rb +206 -73
- data/lib/net/connector/netdisco/config_file.rb +26 -4
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +103 -60
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- 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 +26 -17
- 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 +14 -1
- 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 +20 -0
- data/lib/net/connector/operations/topology.rb +97 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
- data/lib/net/connector/vendor/h3c/topology.rb +9 -4
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
- data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
- data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +1 -1
- metadata +35 -5
|
@@ -21,11 +21,19 @@ module Net
|
|
|
21
21
|
@index = index
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
|
|
24
|
+
# 解析按 UTF-8 字节处理副本,先验证原文,避免回车或转义序列隐藏非法字节。
|
|
25
|
+
# 拓扑选模板和计数也使用这份副本,但仍保留未经终端渲染的完整证据。
|
|
26
|
+
def utf8(text, host: nil)
|
|
26
27
|
raise ArgumentError, "text must be a String" unless text.is_a?(String)
|
|
27
28
|
|
|
28
|
-
text =
|
|
29
|
+
text = text.b.force_encoding(Encoding::UTF_8)
|
|
30
|
+
invalid_encoding!(host) unless text.valid_encoding?
|
|
31
|
+
text
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
|
|
35
|
+
def call(text, template: nil, vendor: nil, command: nil, host: nil)
|
|
36
|
+
text = TerminalRenderer.render(utf8(text, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
|
|
29
37
|
if template
|
|
30
38
|
raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
|
|
31
39
|
|
|
@@ -41,6 +49,8 @@ module Net
|
|
|
41
49
|
end
|
|
42
50
|
table.parse(text, attributes: attributes).to_hashes
|
|
43
51
|
end
|
|
52
|
+
rescue Encoding::InvalidByteSequenceError
|
|
53
|
+
invalid_encoding!(host)
|
|
44
54
|
rescue TextFSM::IndexError
|
|
45
55
|
raise ParsingError.new("TextFSM template index is invalid",
|
|
46
56
|
code: :template_invalid, host: host, phase: :parse), cause: nil
|
|
@@ -54,6 +64,13 @@ module Net
|
|
|
54
64
|
raise ParsingError.new("unable to read TextFSM template",
|
|
55
65
|
code: :template_unreadable, host: host, phase: :parse), cause: nil
|
|
56
66
|
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def invalid_encoding!(host)
|
|
71
|
+
raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
|
|
72
|
+
host: host, phase: :parse), cause: nil
|
|
73
|
+
end
|
|
57
74
|
end
|
|
58
75
|
end
|
|
59
76
|
end
|
|
@@ -5,19 +5,108 @@ require "tempfile"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Operations
|
|
8
|
-
#
|
|
8
|
+
# 文件同步和目录项同步分别记录;原子可见不等于断电后仍能恢复新目录项。
|
|
9
9
|
module PrivateFile
|
|
10
|
-
|
|
10
|
+
Receipt = Data.define(:path, :state, :phase) do
|
|
11
|
+
def initialize(path:, state:, phase:)
|
|
12
|
+
unless path.is_a?(String) && !path.empty? && %i[not_committed committed durable].include?(state) &&
|
|
13
|
+
%i[resolve_parent temporary_file write file_sync rename directory_open directory_sync cleanup complete].include?(phase)
|
|
14
|
+
raise ArgumentError, "invalid file write receipt"
|
|
15
|
+
end
|
|
16
|
+
super(path: path.dup.freeze, state: state, phase: phase)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def committed? = state != :not_committed
|
|
20
|
+
def durable? = state == :durable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class WriteError < IOError
|
|
24
|
+
attr_reader :receipt, :code, :underlying_type
|
|
25
|
+
|
|
26
|
+
def initialize(receipt:, underlying_type:)
|
|
27
|
+
raise ArgumentError, "receipt must be a file write receipt" unless receipt.instance_of?(Receipt)
|
|
28
|
+
|
|
29
|
+
@receipt = receipt
|
|
30
|
+
@underlying_type = underlying_type.dup.freeze
|
|
31
|
+
@code, message = if receipt.durable?
|
|
32
|
+
[:file_finalize_failed, "file replacement is durable; finalization failed"]
|
|
33
|
+
elsif receipt.committed?
|
|
34
|
+
[:file_persistence_unconfirmed, "file replacement is committed; durability is unconfirmed"]
|
|
35
|
+
else
|
|
36
|
+
[:file_write_failed, "file write failed before replacement was committed"]
|
|
37
|
+
end
|
|
38
|
+
super(message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class PersistenceError < WriteError; end
|
|
45
|
+
|
|
46
|
+
class DirectorySyncUnsupported < WriteError
|
|
47
|
+
def initialize(**options)
|
|
48
|
+
super
|
|
49
|
+
@code = :directory_sync_unsupported
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message = "file replacement is committed; directory synchronization is unsupported"
|
|
53
|
+
alias to_s message
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
UNSUPPORTED_DIRECTORY_SYNC = [Errno::EINVAL, Errno::ENOSYS, Errno::ENOTSUP, Errno::EOPNOTSUPP,
|
|
57
|
+
NotImplementedError].uniq.freeze
|
|
58
|
+
private_constant :UNSUPPORTED_DIRECTORY_SYNC
|
|
59
|
+
|
|
60
|
+
# 外部子类可重写消息或回执;只有本写入器定义的具体错误类型可提供完成事实。
|
|
61
|
+
def self.receipt_error?(error)
|
|
62
|
+
[WriteError, PersistenceError, DirectorySyncUnsupported].any? { |type| error.instance_of?(type) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 保留原路径返回值;失败时可从异常 receipt 判断目录项是否已经替换。
|
|
11
66
|
def self.write(path, contents)
|
|
12
|
-
|
|
67
|
+
write_receipt(path, contents)
|
|
68
|
+
path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.write_receipt(path, contents)
|
|
72
|
+
raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
73
|
+
|
|
74
|
+
receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
|
|
75
|
+
directory = File.realpath(File.dirname(receipt.path))
|
|
76
|
+
destination = File.join(directory, File.basename(receipt.path))
|
|
77
|
+
receipt = receipt.with(phase: :temporary_file)
|
|
78
|
+
Tempfile.create([".net-connector-", ".tmp"], directory) do |file|
|
|
13
79
|
file.binmode
|
|
14
80
|
file.chmod(0o600)
|
|
81
|
+
receipt = receipt.with(phase: :write)
|
|
15
82
|
file.write(contents)
|
|
83
|
+
receipt = receipt.with(phase: :file_sync)
|
|
16
84
|
file.flush
|
|
17
85
|
file.fsync
|
|
18
|
-
|
|
86
|
+
receipt = receipt.with(phase: :rename)
|
|
87
|
+
File.rename(file.path, destination)
|
|
88
|
+
receipt = receipt.with(state: :committed, phase: :directory_open)
|
|
89
|
+
File.open(directory, File::RDONLY | File::NOFOLLOW) do |parent|
|
|
90
|
+
raise IOError, "parent is not a directory" unless parent.stat.directory?
|
|
91
|
+
|
|
92
|
+
receipt = receipt.with(phase: :directory_sync)
|
|
93
|
+
parent.fsync
|
|
94
|
+
receipt = receipt.with(state: :durable, phase: :cleanup)
|
|
95
|
+
end
|
|
19
96
|
end
|
|
20
|
-
|
|
97
|
+
receipt.with(phase: :complete)
|
|
98
|
+
rescue StandardError, NotImplementedError => error
|
|
99
|
+
raise unless receipt
|
|
100
|
+
|
|
101
|
+
unsupported = receipt.phase == :directory_sync && UNSUPPORTED_DIRECTORY_SYNC.any? { |type| error.is_a?(type) }
|
|
102
|
+
klass = if unsupported
|
|
103
|
+
DirectorySyncUnsupported
|
|
104
|
+
elsif receipt.committed?
|
|
105
|
+
PersistenceError
|
|
106
|
+
else
|
|
107
|
+
WriteError
|
|
108
|
+
end
|
|
109
|
+
raise klass.new(receipt: receipt, underlying_type: error.class.name.to_s), cause: nil
|
|
21
110
|
end
|
|
22
111
|
end
|
|
23
112
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Operations
|
|
8
|
+
# 对同一 FD 做类型检查和读取;NOFOLLOW 只保护最后一个路径分量,目录由调用方保护。
|
|
9
|
+
module SafeFile
|
|
10
|
+
Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
|
|
11
|
+
|
|
12
|
+
def self.open(path, missing: false, replace_symlink: false)
|
|
13
|
+
file = open_handle(path, missing: missing, replace_symlink: replace_symlink)
|
|
14
|
+
return nil unless file
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
stat = file.stat
|
|
18
|
+
raise ArgumentError, "saved configuration is not a regular file" unless stat.file?
|
|
19
|
+
|
|
20
|
+
file.binmode
|
|
21
|
+
yield file, stat
|
|
22
|
+
ensure
|
|
23
|
+
file.close
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.fingerprint(path, **options)
|
|
28
|
+
self.open(path, **options) { |file, stat| fingerprint_io(path, file, stat) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.fingerprint_io(path, file, stat)
|
|
32
|
+
digest = Digest::SHA256.new
|
|
33
|
+
while (chunk = file.read(64 * 1024))
|
|
34
|
+
digest << chunk
|
|
35
|
+
end
|
|
36
|
+
Fingerprint.new(path: path.dup.freeze, sha256: digest.hexdigest.freeze,
|
|
37
|
+
mode: stat.mode & 0o7777, dev: stat.dev, ino: stat.ino)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 未变内容可保留 mtime,但不能把已替换的目录项误认成刚读取的旧文件。
|
|
41
|
+
def self.same_entry?(path, fingerprint)
|
|
42
|
+
stat = File.lstat(path)
|
|
43
|
+
stat.file? && stat.dev == fingerprint.dev && stat.ino == fingerprint.ino
|
|
44
|
+
rescue Errno::ENOENT
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.open_handle(path, missing:, replace_symlink:)
|
|
49
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
50
|
+
rescue Errno::ENOENT
|
|
51
|
+
raise unless missing
|
|
52
|
+
rescue Errno::ELOOP
|
|
53
|
+
raise ArgumentError, "saved configuration is not a regular file", cause: nil unless replace_symlink
|
|
54
|
+
rescue Errno::ENXIO
|
|
55
|
+
raise ArgumentError, "saved configuration is not a regular file", cause: nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method :open_handle
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../engine/errors"
|
|
4
|
+
require_relative "../safe_file"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class SavedConfigChanged < Error; end
|
|
9
|
+
|
|
10
|
+
module Operations
|
|
11
|
+
class SavedConfig
|
|
12
|
+
# 每批只发布一份只读目录快照。记录文件身份而不读取正文;不跨批次缓存。
|
|
13
|
+
class LegacyIndex
|
|
14
|
+
Entry = Data.define(:name, :identity, :regular)
|
|
15
|
+
EMPTY = [].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(directory)
|
|
18
|
+
@directory = directory
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@entries = nil
|
|
21
|
+
@failed = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def candidates(filename)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
raise IOError, "unable to index saved configurations", cause: nil if @failed
|
|
27
|
+
|
|
28
|
+
@entries ||= snapshot
|
|
29
|
+
@entries.fetch(filename, EMPTY)
|
|
30
|
+
rescue IOError, SystemCallError
|
|
31
|
+
@failed = true
|
|
32
|
+
raise IOError, "unable to index saved configurations", cause: nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 目录快照不是锁:同一 FD 读取前后及最终目录项都必须仍匹配快照。
|
|
37
|
+
# 非合作写入者仍可在检查之后修改文件,调用方必须保护整个备份目录。
|
|
38
|
+
def open(entry)
|
|
39
|
+
path = File.join(@directory, entry.name)
|
|
40
|
+
verify!(entry, File.lstat(path))
|
|
41
|
+
SafeFile.open(path) do |file, stat|
|
|
42
|
+
verify!(entry, stat)
|
|
43
|
+
value = yield path, file, stat
|
|
44
|
+
verify!(entry, file.stat)
|
|
45
|
+
verify!(entry, File.lstat(path))
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
rescue IOError, SystemCallError
|
|
49
|
+
changed!
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
raise unless entry.regular
|
|
52
|
+
|
|
53
|
+
changed!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def snapshot
|
|
59
|
+
entries = {}
|
|
60
|
+
Dir.children(@directory).each do |name|
|
|
61
|
+
filename = canonical_suffix(name)
|
|
62
|
+
next unless filename
|
|
63
|
+
|
|
64
|
+
(entries[filename] ||= []) << snapshot_entry(name)
|
|
65
|
+
end
|
|
66
|
+
entries.each_value(&:freeze)
|
|
67
|
+
entries.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 下划线仅还原地址部分,IPv6 zone 中的下划线保持原义;合法性仍由 IPAddr 判断。
|
|
71
|
+
def canonical_suffix(name)
|
|
72
|
+
suffix = name.b[/-([0-9a-f_.]+(?:%[a-z0-9_.-]+)?)\.txt\z/in, 1]
|
|
73
|
+
return unless suffix
|
|
74
|
+
|
|
75
|
+
address, zone = suffix.split("%", 2)
|
|
76
|
+
host = address.tr("_", ":")
|
|
77
|
+
host += "%#{zone}" if zone
|
|
78
|
+
SavedConfig.filename(host).freeze
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def snapshot_entry(name)
|
|
84
|
+
stat = File.lstat(File.join(@directory, name))
|
|
85
|
+
Entry.new(name: name.freeze, identity: identity(stat), regular: stat.file?)
|
|
86
|
+
rescue SystemCallError
|
|
87
|
+
# 列表与 lstat 之间消失的候选仍占据该身份,不能把后来出现的文件当作旧快照。
|
|
88
|
+
Entry.new(name: name.freeze, identity: nil, regular: false)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def identity(stat)
|
|
92
|
+
[stat.dev, stat.ino, stat.mode, stat.size, stat.mtime.to_r, stat.ctime.to_r].freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def verify!(entry, stat)
|
|
96
|
+
changed! unless entry.identity == identity(stat)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def changed!
|
|
100
|
+
raise SavedConfigChanged.new("legacy saved configuration changed or became unavailable after indexing",
|
|
101
|
+
code: :saved_config_changed, phase: :backup), cause: nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private_constant :LegacyIndex
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -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
|
|
@@ -11,9 +11,22 @@ module Net
|
|
|
11
11
|
# 未指定目标名时,设备使用配置源文件的 basename。
|
|
12
12
|
def default_path(source_file) = File.basename(source_file)
|
|
13
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
|
+
|
|
14
26
|
# 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
|
|
15
27
|
def script(target, source_file:, vrf: nil)
|
|
16
|
-
|
|
28
|
+
validate_options!(target, source_file: source_file, vrf: vrf)
|
|
29
|
+
TftpTarget.validate_source_file!(source_file)
|
|
17
30
|
|
|
18
31
|
destination = target.explicit_path? ? " #{target.path}" : ""
|
|
19
32
|
Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
|
|
@@ -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
|