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
|
@@ -21,37 +21,29 @@ module Net
|
|
|
21
21
|
worker_count = [tasks.size, @concurrency].min
|
|
22
22
|
worker_count.times { queue << nil }
|
|
23
23
|
callback_errors = Queue.new
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
on_error.call(device, error)
|
|
39
|
-
end
|
|
40
|
-
result = result.with(started_at: started_at, finished_at: Time.now.utc)
|
|
41
|
-
outcomes[index] = result
|
|
42
|
-
Array(on_result).each do |callback|
|
|
43
|
-
begin
|
|
44
|
-
callback.call(result)
|
|
45
|
-
rescue StandardError => error
|
|
46
|
-
callback_errors << { host: device.host, error_type: error.class.name }.freeze
|
|
24
|
+
finished = Queue.new
|
|
25
|
+
threads = []
|
|
26
|
+
completed = false
|
|
27
|
+
begin
|
|
28
|
+
# 创建线程也属于批次生命周期;后续创建失败时要关闭已有任务。
|
|
29
|
+
worker_count.times do
|
|
30
|
+
threads << Thread.new do
|
|
31
|
+
Thread.current.report_on_exception = false
|
|
32
|
+
while (task = queue.pop)
|
|
33
|
+
index, device = task
|
|
34
|
+
result = run_task(device, on_error, on_start, callback_errors) { yield device }
|
|
35
|
+
outcomes[index] = result
|
|
36
|
+
Array(on_result).each do |callback|
|
|
37
|
+
notify(callback, result, host: device.host, errors: callback_errors)
|
|
47
38
|
end
|
|
48
39
|
end
|
|
40
|
+
ensure
|
|
41
|
+
finished << Thread.current
|
|
49
42
|
end
|
|
50
43
|
end
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
threads.each(&:value)
|
|
44
|
+
# 按完成顺序观察失败,避免后启动线程的中断被前面的慢设备阻塞。
|
|
45
|
+
# 结果仍写回清单槽位,完成通知不会改变公开结果的顺序。
|
|
46
|
+
worker_count.times { finished.pop.value }
|
|
55
47
|
completed = true
|
|
56
48
|
ensure
|
|
57
49
|
# 中断或某个 worker 抛出非 StandardError 时,不让其他设备任务
|
|
@@ -69,6 +61,30 @@ module Net
|
|
|
69
61
|
end
|
|
70
62
|
Array.new(callback_errors.size) { callback_errors.pop }.freeze
|
|
71
63
|
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# 普通设备故障转换为结果,中断仍交给批次统一停止其余线程。
|
|
68
|
+
def run_task(device, on_error, on_start, callback_errors)
|
|
69
|
+
started_at = Time.now.utc
|
|
70
|
+
started = monotonic
|
|
71
|
+
notify(on_start, device, host: device.host, errors: callback_errors) unless on_start.nil?
|
|
72
|
+
result = begin
|
|
73
|
+
yield
|
|
74
|
+
rescue StandardError => error
|
|
75
|
+
on_error.call(device, error)
|
|
76
|
+
end
|
|
77
|
+
result.with(started_at: started_at, finished_at: Time.now.utc, duration_ms: ((monotonic - started) * 1000).round)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
81
|
+
|
|
82
|
+
# 回调故障不覆盖设备结果,也不把异常消息中的凭据写入报告。
|
|
83
|
+
def notify(callback, value, host:, errors:)
|
|
84
|
+
callback.call(value)
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
errors << { host: host, error_type: error.class.name }.freeze
|
|
87
|
+
end
|
|
72
88
|
end
|
|
73
89
|
end
|
|
74
90
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../engine/errors"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class BackupBusy < Error; end
|
|
9
|
+
|
|
10
|
+
module Operations
|
|
11
|
+
# 路径锁在设备采集之前取得;锁文件长期保留,避免等待者落在已经删除的 inode 上。
|
|
12
|
+
class BackupLock
|
|
13
|
+
STORAGE_KEY = :net_connector_backup_delegation
|
|
14
|
+
private_constant :STORAGE_KEY
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def self.lock_path(path)
|
|
19
|
+
destination = File.expand_path(path)
|
|
20
|
+
directory = File.realpath(File.dirname(destination))
|
|
21
|
+
# 锁名先折叠 Unicode 与大小写,避免大小写不敏感文件系统上的别名绕过互斥。
|
|
22
|
+
# 在区分大小写的文件系统上可能保守地合并两个锁,但不会改写目标文件名。
|
|
23
|
+
filename = File.basename(destination).b.force_encoding(Encoding::UTF_8)
|
|
24
|
+
filename = filename.valid_encoding? ? filename.unicode_normalize(:nfc).downcase(:fold).unicode_normalize(:nfc) : filename.b.downcase
|
|
25
|
+
File.join(directory, ".net-connector-#{Digest::SHA256.hexdigest(filename)}.lock")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.synchronize(path, **options, &block) = new(path, **options).synchronize(&block)
|
|
29
|
+
|
|
30
|
+
def initialize(path, timeout: 0, host: nil)
|
|
31
|
+
raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
32
|
+
unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0 && timeout.to_f.finite?
|
|
33
|
+
raise ArgumentError, "lock_timeout must be a nonnegative finite number"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@path = File.expand_path(path).freeze
|
|
37
|
+
@lock_path = self.class.lock_path(@path).freeze
|
|
38
|
+
@timeout, @host = timeout.to_f, host
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def synchronize
|
|
42
|
+
# Fleet 只向下层备份借用一次;消费后清除,采集回调中的递归备份仍须竞争锁。
|
|
43
|
+
# Thread#[] 是 Fiber 局部存储;fork 后不能消费父进程的借用记录。
|
|
44
|
+
if Thread.current[STORAGE_KEY] == [@lock_path, Process.pid]
|
|
45
|
+
Thread.current[STORAGE_KEY] = nil
|
|
46
|
+
return yield self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
file = nil
|
|
50
|
+
begin
|
|
51
|
+
file = open_lock
|
|
52
|
+
verify_lock!(file)
|
|
53
|
+
acquire(file)
|
|
54
|
+
verify_lock!(file)
|
|
55
|
+
@owner = [Process.pid, Fiber.current]
|
|
56
|
+
yield self
|
|
57
|
+
ensure
|
|
58
|
+
@owner = nil
|
|
59
|
+
file&.close
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 仅持有实际 flock 的同一 Fiber 可授权一次内层采集,避免把路径锁变成任意可重入锁。
|
|
64
|
+
def delegate
|
|
65
|
+
raise ThreadError, "backup path delegation requires lock ownership" unless @owner == [Process.pid, Fiber.current]
|
|
66
|
+
|
|
67
|
+
previous = Thread.current[STORAGE_KEY]
|
|
68
|
+
begin
|
|
69
|
+
Thread.current[STORAGE_KEY] = [@lock_path, Process.pid]
|
|
70
|
+
yield
|
|
71
|
+
ensure
|
|
72
|
+
Thread.current[STORAGE_KEY] = previous
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def open_lock
|
|
79
|
+
File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
80
|
+
rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
|
|
81
|
+
raise ArgumentError, "backup lock must be a private regular file", cause: nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verify_lock!(file)
|
|
85
|
+
stat = file.stat
|
|
86
|
+
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
87
|
+
raise ArgumentError, "backup lock must be an owned regular file with mode 0600 and one link"
|
|
88
|
+
end
|
|
89
|
+
current = File.lstat(@lock_path)
|
|
90
|
+
unless current.file? && current.dev == stat.dev && current.ino == stat.ino
|
|
91
|
+
raise ArgumentError, "backup lock path changed while acquiring ownership"
|
|
92
|
+
end
|
|
93
|
+
rescue Errno::ENOENT
|
|
94
|
+
raise ArgumentError, "backup lock path changed while acquiring ownership", cause: nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def acquire(file)
|
|
98
|
+
deadline = monotonic + @timeout
|
|
99
|
+
loop do
|
|
100
|
+
acquired = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
101
|
+
return if acquired && (@timeout.zero? || monotonic < deadline)
|
|
102
|
+
|
|
103
|
+
remaining = deadline - monotonic
|
|
104
|
+
if remaining <= 0
|
|
105
|
+
raise BackupBusy.new("backup path already belongs to another operation", host: @host, phase: :backup), cause: nil
|
|
106
|
+
end
|
|
107
|
+
wait([remaining, 0.05].min)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
def wait(seconds) = sleep(seconds)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "private_file"
|
|
5
|
+
require_relative "backup_lock"
|
|
6
|
+
require_relative "safe_file"
|
|
5
7
|
|
|
6
8
|
module Net
|
|
7
9
|
module Connector
|
|
@@ -15,19 +17,47 @@ module Net
|
|
|
15
17
|
def changed? = [:created, :changed].include?(change)
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# 只携带库自身的提交回执和配置元数据,不携带正文或原始文件系统异常。
|
|
21
|
+
class BackupPersistenceError < Error
|
|
22
|
+
attr_reader :backup, :receipt, :underlying_type
|
|
23
|
+
|
|
24
|
+
def initialize(backup:, write_error:, host: nil)
|
|
25
|
+
unless backup.instance_of?(Backup) && Operations::PrivateFile.receipt_error?(write_error) &&
|
|
26
|
+
write_error.receipt.committed? && write_error.receipt.path == backup.path
|
|
27
|
+
raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@backup, @receipt = backup, write_error.receipt
|
|
31
|
+
@underlying_type = write_error.underlying_type
|
|
32
|
+
code = if write_error.instance_of?(Operations::PrivateFile::DirectorySyncUnsupported)
|
|
33
|
+
:backup_durability_unsupported
|
|
34
|
+
elsif receipt.durable?
|
|
35
|
+
:backup_finalize_failed
|
|
36
|
+
else
|
|
37
|
+
:backup_persistence_unconfirmed
|
|
38
|
+
end
|
|
39
|
+
super(write_error.message, code: code, host: host, phase: :backup)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
18
43
|
module Operations
|
|
19
44
|
class LocalBackup
|
|
20
45
|
# 保存执行配置采集的设备对象。
|
|
21
46
|
def initialize(device) = @device = device
|
|
22
47
|
|
|
23
48
|
# 采集配置、识别变更并原子写入私有文件。
|
|
24
|
-
def call(path:)
|
|
25
|
-
|
|
49
|
+
def call(path:, lock_timeout: 0)
|
|
50
|
+
host = @device.host if @device.respond_to?(:host)
|
|
51
|
+
BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
26
55
|
|
|
56
|
+
def collect_and_write(destination)
|
|
27
57
|
contents = @device.running_config.value!
|
|
28
|
-
destination = File.expand_path(path)
|
|
29
58
|
digest = Digest::SHA256.hexdigest(contents)
|
|
30
|
-
|
|
59
|
+
previous = SafeFile.fingerprint(destination, missing: true, replace_symlink: true)
|
|
60
|
+
previous_digest = previous&.sha256
|
|
31
61
|
change = if previous_digest.nil?
|
|
32
62
|
:created
|
|
33
63
|
elsif previous_digest == digest
|
|
@@ -36,12 +66,22 @@ module Net
|
|
|
36
66
|
:changed
|
|
37
67
|
end
|
|
38
68
|
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
backup = Backup.new(path: destination, bytes: contents.bytesize,
|
|
70
|
+
sha256: digest, collected_at: Time.now.utc, change: change,
|
|
71
|
+
previous_sha256: previous_digest)
|
|
72
|
+
if change != :unchanged || previous.mode != 0o600 || !SafeFile.same_entry?(destination, previous)
|
|
73
|
+
write_backup(backup, contents)
|
|
41
74
|
end
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
backup
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_backup(backup, contents)
|
|
79
|
+
PrivateFile.write_receipt(backup.path, contents)
|
|
80
|
+
rescue PrivateFile::WriteError => error
|
|
81
|
+
raise unless PrivateFile.receipt_error?(error) && error.receipt.committed?
|
|
82
|
+
|
|
83
|
+
host = @device.host if @device.respond_to?(:host)
|
|
84
|
+
raise BackupPersistenceError.new(backup: backup, write_error: error, host: host), cause: nil
|
|
45
85
|
end
|
|
46
86
|
end
|
|
47
87
|
end
|
|
@@ -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
|