net-connector 0.9.0 → 0.10.0
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 +9 -1
- data/docs/VERIFICATION.md +1 -1
- data/docs/architecture.md +16 -5
- data/docs/netdisco.md +54 -1
- data/docs/netdisco.zh-CN.md +46 -2
- data/docs/usage.md +24 -7
- data/docs/usage.zh-CN.md +22 -8
- data/examples/backup.yml +1 -1
- data/lib/net/connector/background_task.rb +69 -0
- data/lib/net/connector/device/topology/interface_description.rb +3 -3
- data/lib/net/connector/device/topology/interface_name.rb +10 -19
- data/lib/net/connector/device/topology/stages.rb +50 -0
- data/lib/net/connector/device/topology.rb +65 -24
- data/lib/net/connector/device_session.rb +38 -0
- data/lib/net/connector/engine/transport.rb +15 -5
- data/lib/net/connector/execution_pool.rb +108 -0
- data/lib/net/connector/netdisco/backup_run.rb +14 -2
- data/lib/net/connector/netdisco/cli/options.rb +1 -0
- data/lib/net/connector/netdisco/cli.rb +18 -7
- data/lib/net/connector/netdisco/client.rb +5 -6
- data/lib/net/connector/netdisco/config_file.rb +5 -2
- data/lib/net/connector/netdisco/database_client.rb +33 -15
- data/lib/net/connector/netdisco/fleet.rb +68 -36
- data/lib/net/connector/netdisco/inventory_cache.rb +218 -0
- data/lib/net/connector/netdisco/operation_batch.rb +154 -0
- data/lib/net/connector/netdisco/operations.rb +268 -0
- data/lib/net/connector/netdisco/planner.rb +26 -17
- data/lib/net/connector/netdisco/progress.rb +22 -10
- data/lib/net/connector/netdisco/settings.rb +36 -6
- data/lib/net/connector/netdisco/tftp_archive.rb +2 -1
- data/lib/net/connector/netdisco/tftp_verification.rb +36 -11
- data/lib/net/connector/netdisco/worker.rb +7 -46
- data/lib/net/connector/vendor/h3c/topology.rb +1 -1
- data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +8 -1
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../storage"
|
|
5
|
+
require_relative "../device_session"
|
|
5
6
|
require_relative "client"
|
|
6
7
|
require_relative "settings"
|
|
7
8
|
require_relative "device"
|
|
8
9
|
require_relative "worker"
|
|
10
|
+
require_relative "operations"
|
|
9
11
|
require_relative "result_store"
|
|
10
12
|
require_relative "planner"
|
|
11
13
|
require_relative "report"
|
|
@@ -17,6 +19,8 @@ module Net
|
|
|
17
19
|
module Netdisco
|
|
18
20
|
# 基于已验证的清单快照组织有上限的并发备份。
|
|
19
21
|
class Fleet
|
|
22
|
+
attr_reader :inventory_status
|
|
23
|
+
|
|
20
24
|
DEFAULT_SETTING = Object.new.freeze
|
|
21
25
|
private_constant :DEFAULT_SETTING
|
|
22
26
|
|
|
@@ -34,19 +38,19 @@ module Net
|
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
# 拉取并标记重复管理地址的设备清单。
|
|
37
|
-
def devices
|
|
38
|
-
inventory(@settings.snapshot(mode: :inventory))
|
|
41
|
+
def devices(force_refresh: false)
|
|
42
|
+
inventory(@settings.snapshot(mode: :inventory), force_refresh: force_refresh)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# 规划本地配置备份的设备集合。
|
|
42
|
-
def plan_backup(limit_per_vendor: nil)
|
|
43
|
-
build_plan(:backup, limit_per_vendor, @settings.snapshot(mode: :backup))
|
|
46
|
+
def plan_backup(limit_per_vendor: nil, force_refresh: false)
|
|
47
|
+
build_plan(:backup, limit_per_vendor, @settings.snapshot(mode: :backup), force_refresh: force_refresh)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
# 规划 TFTP 配置备份的设备集合。
|
|
47
|
-
def plan_tftp_backup(limit_per_vendor: 5, allow_fixed_name_reuse: false)
|
|
51
|
+
def plan_tftp_backup(limit_per_vendor: 5, allow_fixed_name_reuse: false, force_refresh: false)
|
|
48
52
|
build_plan(:tftp, limit_per_vendor, @settings.snapshot(mode: :tftp),
|
|
49
|
-
allow_fixed_name_reuse: allow_fixed_name_reuse)
|
|
53
|
+
allow_fixed_name_reuse: allow_fixed_name_reuse, force_refresh: force_refresh)
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
# 先拉取完整清单,再并发采集并保存设备配置。
|
|
@@ -108,23 +112,59 @@ module Net
|
|
|
108
112
|
end
|
|
109
113
|
archive = TftpArchive.new(directory: report_directory, root: verification_root) if preserve_history
|
|
110
114
|
run_batch(:tftp, plan, concurrency: concurrency, report_directory: report_directory,
|
|
111
|
-
settings_snapshot: settings_snapshot, success_policy: success_policy, on_start: on_start, on_result: on_result,
|
|
112
|
-
verification: preserve_history ? nil : verification) do |device, log_directory|
|
|
115
|
+
settings_snapshot: settings_snapshot, success_policy: success_policy, on_start: on_start, on_result: on_result) do |device, log_directory|
|
|
113
116
|
if archive
|
|
114
117
|
archive.upload_and_archive(device, started_at: Time.now.utc) do |remote_filename|
|
|
115
118
|
tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot, path: remote_filename)
|
|
116
119
|
end
|
|
117
120
|
else
|
|
118
|
-
|
|
121
|
+
verification.upload_and_verify(device) do |remote_filename|
|
|
122
|
+
tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot, path: remote_filename)
|
|
123
|
+
end
|
|
119
124
|
end
|
|
120
125
|
end
|
|
121
126
|
end
|
|
122
127
|
|
|
128
|
+
# 配置批次在任何连接之前验证全部脚本与目标,写操作须显式确认。
|
|
129
|
+
def execute_scripts(**options)
|
|
130
|
+
operations.execute_scripts(**options)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 默认连贯同步,不要求调用方管理计划或重新建立设备连接。
|
|
134
|
+
def sync_interface_descriptions(**options, &)
|
|
135
|
+
operations.sync_interface_descriptions(**options, &)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 并发生成接口描述计划,不执行配置变更。
|
|
139
|
+
def plan_interface_descriptions(**options, &)
|
|
140
|
+
operations.plan_interface_descriptions(**options, &)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# 每台设备独立重验审批证据、回读并保存,不将计划当作普通脚本执行。
|
|
144
|
+
def apply_interface_descriptions(**options)
|
|
145
|
+
operations.apply_interface_descriptions(**options)
|
|
146
|
+
end
|
|
147
|
+
|
|
123
148
|
private
|
|
124
149
|
|
|
150
|
+
def operations
|
|
151
|
+
Operations.new(settings: @settings, inventory: method(:inventory), credentials: @credentials,
|
|
152
|
+
connector_factory: @connector_factory)
|
|
153
|
+
end
|
|
154
|
+
|
|
125
155
|
# 策略在清单请求之前固定;注入的清单客户端仍由调用方负责其自身生命周期。
|
|
126
|
-
def inventory(settings_snapshot)
|
|
127
|
-
|
|
156
|
+
def inventory(settings_snapshot, force_refresh: false)
|
|
157
|
+
@inventory_status = nil
|
|
158
|
+
raise ArgumentError, "force_refresh must be true or false" unless [true, false].include?(force_refresh)
|
|
159
|
+
|
|
160
|
+
client = @client || @settings.client(policy: settings_snapshot)
|
|
161
|
+
rows = client.is_a?(InventoryCache) ? client.devices(force_refresh: force_refresh) : client.devices
|
|
162
|
+
@inventory_status = if client.is_a?(InventoryCache)
|
|
163
|
+
{ source: client.status, fetched_at: client.fetched_at,
|
|
164
|
+
refresh_error_code: client.refresh_error_code, write_error_type: client.write_error_type }.freeze
|
|
165
|
+
else
|
|
166
|
+
{ source: :uncached }.freeze
|
|
167
|
+
end
|
|
128
168
|
raise Client::Error, "Netdisco returned an invalid device inventory" unless rows.is_a?(Array)
|
|
129
169
|
|
|
130
170
|
rules = @rules || settings_snapshot.rules
|
|
@@ -133,14 +173,14 @@ module Net
|
|
|
133
173
|
devices.map { |device| host_counts[device.host] > 1 ? device.with(issue: :duplicate_host) : device }.freeze
|
|
134
174
|
end
|
|
135
175
|
|
|
136
|
-
def build_plan(mode, limit, settings_snapshot, allow_fixed_name_reuse: false)
|
|
176
|
+
def build_plan(mode, limit, settings_snapshot, allow_fixed_name_reuse: false, force_refresh: false)
|
|
137
177
|
Planner.validate_limit!(limit)
|
|
138
|
-
Planner.new(inventory(settings_snapshot)).call(mode: mode, limit_per_vendor: limit,
|
|
178
|
+
Planner.new(inventory(settings_snapshot, force_refresh: force_refresh)).call(mode: mode, limit_per_vendor: limit,
|
|
139
179
|
allow_fixed_name_reuse: allow_fixed_name_reuse)
|
|
140
180
|
end
|
|
141
181
|
|
|
142
182
|
# 统一组织设备任务、日志目录与报告;Worker 保证单台设备失败不影响其他设备。
|
|
143
|
-
def run_batch(mode, plan, concurrency:, report_directory:, settings_snapshot:, success_policy:, on_start:, on_result:, output_directory: nil
|
|
183
|
+
def run_batch(mode, plan, concurrency:, report_directory:, settings_snapshot:, success_policy:, on_start:, on_result:, output_directory: nil)
|
|
144
184
|
worker = Worker.new(concurrency: concurrency)
|
|
145
185
|
started_at = Time.now.utc
|
|
146
186
|
started = monotonic
|
|
@@ -156,7 +196,6 @@ module Net
|
|
|
156
196
|
finished_at: Time.now.utc, callback_errors: callback_errors,
|
|
157
197
|
report_location: nil, report_error: nil)
|
|
158
198
|
report = Report.new(batch, policy: success_policy, duration_ms: ((monotonic - started) * 1000).round, log_directory: log_directory)
|
|
159
|
-
report = verification.call(report) if verification
|
|
160
199
|
save_report(report, directory: report_directory)
|
|
161
200
|
end
|
|
162
201
|
|
|
@@ -218,7 +257,7 @@ module Net
|
|
|
218
257
|
end
|
|
219
258
|
|
|
220
259
|
# 隔离单台设备的凭据、连接、操作和关闭异常。
|
|
221
|
-
def run_device(device, log_directory, settings_snapshot, success_status:, partial_status:, backup_class
|
|
260
|
+
def run_device(device, log_directory, settings_snapshot, success_status:, partial_status:, backup_class:, &operation)
|
|
222
261
|
connection_settings = @credentials ? @credentials.call(device) : @settings.device_credentials_for(device)
|
|
223
262
|
return build_outcome(device, :missing_credentials) if connection_settings.nil?
|
|
224
263
|
unless connection_settings.is_a?(Hash) && connection_settings.keys.all?(Symbol) &&
|
|
@@ -232,8 +271,9 @@ module Net
|
|
|
232
271
|
if log_directory
|
|
233
272
|
connection_settings = connection_settings.merge(log_file: File.join(log_directory, "#{log_name}.log"))
|
|
234
273
|
end
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
backup, failure = perform_one(backup_class, factory: -> { @connector_factory.call(device, connection_settings) }) do |connector|
|
|
275
|
+
operation.call(connector)
|
|
276
|
+
end
|
|
237
277
|
return build_outcome(device, success_status, backup: backup) unless failure
|
|
238
278
|
|
|
239
279
|
build_outcome(device, backup ? partial_status : :failed, backup: backup, error: failure)
|
|
@@ -242,26 +282,18 @@ module Net
|
|
|
242
282
|
end
|
|
243
283
|
|
|
244
284
|
# 仅信任库定义且产物类型匹配的提交后错误;普通第三方异常上的 backup 字段不代表完成。
|
|
245
|
-
def perform_one(
|
|
246
|
-
backup =
|
|
247
|
-
|
|
248
|
-
begin
|
|
249
|
-
result = yield
|
|
285
|
+
def perform_one(backup_class, factory:)
|
|
286
|
+
backup, failure = DeviceSession.call(factory: factory) do |session|
|
|
287
|
+
result = yield session
|
|
250
288
|
raise TypeError, "backup operation did not return #{backup_class}" unless result.is_a?(backup_class)
|
|
251
289
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if backup_class == Backup &&
|
|
256
|
-
backup =
|
|
257
|
-
elsif backup_class == TftpReceipt &&
|
|
258
|
-
backup =
|
|
259
|
-
end
|
|
260
|
-
ensure
|
|
261
|
-
begin
|
|
262
|
-
connector.close
|
|
263
|
-
rescue StandardError => error
|
|
264
|
-
failure ||= error
|
|
290
|
+
result
|
|
291
|
+
end
|
|
292
|
+
if failure && backup.nil?
|
|
293
|
+
if backup_class == Backup && failure.instance_of?(BackupPersistenceError) && failure.backup.instance_of?(Backup)
|
|
294
|
+
backup = failure.backup
|
|
295
|
+
elsif backup_class == TftpReceipt && failure.instance_of?(TftpCompletionError) && failure.receipt.instance_of?(TftpReceipt)
|
|
296
|
+
backup = failure.receipt
|
|
265
297
|
end
|
|
266
298
|
end
|
|
267
299
|
[backup, failure]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "client"
|
|
7
|
+
require_relative "../engine/error_metadata"
|
|
8
|
+
require_relative "../storage/private_file"
|
|
9
|
+
|
|
10
|
+
module Net
|
|
11
|
+
module Connector
|
|
12
|
+
module Netdisco
|
|
13
|
+
# 只缓存完整的原始清单;规则映射和设备凭据始终留在每次执行入口。
|
|
14
|
+
class InventoryCache
|
|
15
|
+
class Error < IOError; end
|
|
16
|
+
class Busy < Error; end
|
|
17
|
+
class InvalidCache < StandardError; end
|
|
18
|
+
private_constant :InvalidCache
|
|
19
|
+
|
|
20
|
+
DEFAULT_TTL = 3 * 24 * 60 * 60
|
|
21
|
+
ERROR_CODES = %i[client_error inventory_timeout request_failed invalid_json connection_failed
|
|
22
|
+
pagination_stalled max_pages max_response_bytes max_inventory_bytes max_devices
|
|
23
|
+
invalid_inventory query_failed].freeze
|
|
24
|
+
private_constant :ERROR_CODES
|
|
25
|
+
attr_reader :status, :refresh_error_code, :write_error_type, :fetched_at
|
|
26
|
+
|
|
27
|
+
def initialize(client:, directory:, identity:, ttl: DEFAULT_TTL,
|
|
28
|
+
max_inventory_bytes: Client::DEFAULTS.fetch(:max_inventory_bytes),
|
|
29
|
+
max_devices: Client::DEFAULTS.fetch(:max_devices), clock: -> { Time.now.utc }, lock_timeout: 5)
|
|
30
|
+
raise ArgumentError, "client must respond to devices" unless client.respond_to?(:devices)
|
|
31
|
+
raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty? && !directory.include?("\0")
|
|
32
|
+
raise ArgumentError, "identity must be a nonempty String" unless identity.is_a?(String) && !identity.empty?
|
|
33
|
+
[ttl, lock_timeout].each do |value|
|
|
34
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0 && value.to_f.finite?
|
|
35
|
+
raise ArgumentError, "cache durations must be nonnegative finite numbers"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
[max_inventory_bytes, max_devices].each do |value|
|
|
39
|
+
raise ArgumentError, "cache limits must be positive integers" unless value.is_a?(Integer) && value.positive?
|
|
40
|
+
end
|
|
41
|
+
raise ArgumentError, "clock must respond to call" unless clock.respond_to?(:call)
|
|
42
|
+
|
|
43
|
+
@client, @directory, @clock = client, File.expand_path(directory).freeze, clock
|
|
44
|
+
@identity = Digest::SHA256.hexdigest(identity).freeze
|
|
45
|
+
@ttl, @lock_timeout = ttl, lock_timeout
|
|
46
|
+
@max_inventory_bytes, @max_devices = max_inventory_bytes, max_devices
|
|
47
|
+
@path = File.join(@directory, "#{@identity}.json").freeze
|
|
48
|
+
@lock_path = File.join(@directory, "#{@identity}.lock").freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 锁内重读避免多个进程同时刷新;强刷仍然允许远端失败时交付历史清单。
|
|
52
|
+
def devices(force_refresh: false)
|
|
53
|
+
raise ArgumentError, "force_refresh must be true or false" unless [true, false].include?(force_refresh)
|
|
54
|
+
|
|
55
|
+
@status = @refresh_error_code = @write_error_type = @fetched_at = nil
|
|
56
|
+
with_directory do
|
|
57
|
+
previous = read_cache
|
|
58
|
+
return deliver(previous, :fresh_cache) if !force_refresh && fresh?(previous)
|
|
59
|
+
|
|
60
|
+
with_lock do
|
|
61
|
+
previous = read_cache
|
|
62
|
+
return deliver(previous, :fresh_cache) if !force_refresh && fresh?(previous)
|
|
63
|
+
|
|
64
|
+
refresh(previous)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect = "#<#{self.class} status=#{@status.inspect}>"
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# 目录的所有者和权限是末级 NOFOLLOW 检查的前提;不修补不可信的既有目录。
|
|
74
|
+
def with_directory
|
|
75
|
+
FileUtils.mkdir_p(@directory, mode: 0o700) unless File.exist?(@directory) || File.symlink?(@directory)
|
|
76
|
+
File.open(@directory, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |directory|
|
|
77
|
+
stat = directory.stat
|
|
78
|
+
unless stat.directory? && stat.uid == Process.euid && (stat.mode & 0o7777) == 0o700
|
|
79
|
+
raise Error, "inventory cache directory must be owned with mode 0700"
|
|
80
|
+
end
|
|
81
|
+
verify_entry!(@directory, stat)
|
|
82
|
+
result = yield
|
|
83
|
+
verify_entry!(@directory, stat)
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
rescue Errno::ELOOP, Errno::ENOTDIR
|
|
87
|
+
raise Error, "inventory cache directory must not be a symlink", cause: nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def with_lock
|
|
91
|
+
File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600) do |lock|
|
|
92
|
+
verify_private_file!(lock, @lock_path)
|
|
93
|
+
deadline = monotonic + @lock_timeout
|
|
94
|
+
until lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
95
|
+
remaining = deadline - monotonic
|
|
96
|
+
raise Busy, "inventory cache refresh lock timed out" if remaining <= 0
|
|
97
|
+
|
|
98
|
+
sleep([remaining, 0.05].min)
|
|
99
|
+
end
|
|
100
|
+
verify_private_file!(lock, @lock_path)
|
|
101
|
+
yield
|
|
102
|
+
end
|
|
103
|
+
rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
|
|
104
|
+
raise Error, "inventory cache lock must be a private regular file", cause: nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 同一 FD 上校验和限量读取,不依赖 stat 的大小来约束持续增长的文件。
|
|
108
|
+
def read_cache
|
|
109
|
+
File.open(@path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
110
|
+
stat = verify_private_file!(file, @path)
|
|
111
|
+
return nil if stat.size > @max_inventory_bytes
|
|
112
|
+
|
|
113
|
+
body = file.read(@max_inventory_bytes + 1)
|
|
114
|
+
return nil if body.bytesize > @max_inventory_bytes
|
|
115
|
+
|
|
116
|
+
entry = JSON.parse(body)
|
|
117
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %w[fetched_at identity rows schema] &&
|
|
118
|
+
entry["schema"] == 1 && entry["identity"] == @identity && valid_time?(entry["fetched_at"])
|
|
119
|
+
return nil
|
|
120
|
+
end
|
|
121
|
+
entry["rows"] = normalize_rows(entry["rows"])
|
|
122
|
+
entry
|
|
123
|
+
end
|
|
124
|
+
rescue Errno::ENOENT, JSON::ParserError, InvalidCache
|
|
125
|
+
nil
|
|
126
|
+
rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
|
|
127
|
+
raise Error, "inventory cache must be a private regular file", cause: nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 清单已取得后,落盘失败不能让调用方退回旧清单或重新执行远端查询。
|
|
131
|
+
def refresh(previous)
|
|
132
|
+
begin
|
|
133
|
+
rows = normalize_rows(@client.devices)
|
|
134
|
+
rescue Client::Error => error
|
|
135
|
+
raise unless previous
|
|
136
|
+
|
|
137
|
+
@refresh_error_code = ERROR_CODES.include?(error.code) ? error.code : :client_error
|
|
138
|
+
return deliver(previous, :stale_cache)
|
|
139
|
+
rescue InvalidCache
|
|
140
|
+
error = Client::Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory)
|
|
141
|
+
raise error, cause: nil unless previous
|
|
142
|
+
|
|
143
|
+
@refresh_error_code = ERROR_CODES.include?(error.code) ? error.code : :client_error
|
|
144
|
+
return deliver(previous, :stale_cache)
|
|
145
|
+
end
|
|
146
|
+
entry = { "schema" => 1, "identity" => @identity, "fetched_at" => now, "rows" => rows }
|
|
147
|
+
begin
|
|
148
|
+
body = JSON.generate(entry)
|
|
149
|
+
raise Error, "inventory cache exceeds max_inventory_bytes" if body.bytesize > @max_inventory_bytes
|
|
150
|
+
|
|
151
|
+
Storage::PrivateFile.write(@path, body)
|
|
152
|
+
rescue StandardError => error
|
|
153
|
+
@write_error_type = ErrorMetadata.type(error.class.name) || "StandardError"
|
|
154
|
+
end
|
|
155
|
+
deliver(entry, :refreshed)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# 校验限额后只复制白名单字段;调用方和缓存不能共享可变的远端响应。
|
|
159
|
+
def normalize_rows(rows)
|
|
160
|
+
raise InvalidCache unless rows.is_a?(Array) && rows.size <= @max_devices
|
|
161
|
+
|
|
162
|
+
bytes = 0
|
|
163
|
+
rows.map do |row|
|
|
164
|
+
unless row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].strip.empty? &&
|
|
165
|
+
Client::FIELDS.all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
166
|
+
raise InvalidCache
|
|
167
|
+
end
|
|
168
|
+
selected = row.slice(*Client::FIELDS)
|
|
169
|
+
bytes += selected.sum { |key, value| key.bytesize + (value&.bytesize || 0) }
|
|
170
|
+
raise InvalidCache if bytes > @max_inventory_bytes
|
|
171
|
+
|
|
172
|
+
selected.transform_values { |value| value&.dup&.freeze }.freeze
|
|
173
|
+
end.freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def verify_private_file!(file, path)
|
|
177
|
+
stat = file.stat
|
|
178
|
+
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
179
|
+
raise Error, "inventory cache files must be owned regular files with mode 0600 and one link"
|
|
180
|
+
end
|
|
181
|
+
verify_entry!(path, stat)
|
|
182
|
+
stat
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def verify_entry!(path, stat)
|
|
186
|
+
current = File.lstat(path)
|
|
187
|
+
return if current.dev == stat.dev && current.ino == stat.ino && !current.symlink?
|
|
188
|
+
|
|
189
|
+
raise Error, "inventory cache path changed during access"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def valid_time?(value)
|
|
193
|
+
value.is_a?(Numeric) && value.real? && value.finite? && value >= 0 && value <= now
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fresh?(entry) = entry && now - entry.fetch("fetched_at") < @ttl
|
|
197
|
+
|
|
198
|
+
def deliver(entry, status)
|
|
199
|
+
@status = status
|
|
200
|
+
@fetched_at = Time.at(entry.fetch("fetched_at")).utc.freeze
|
|
201
|
+
entry.fetch("rows")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def now
|
|
205
|
+
input = @clock.call
|
|
206
|
+
value = input.is_a?(Time) ? input.to_f : input
|
|
207
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value >= 0 && value.to_f.finite?
|
|
208
|
+
raise ArgumentError, "clock must return a finite nonnegative timestamp"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
value.to_f
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "diagnostic"
|
|
4
|
+
require_relative "device"
|
|
5
|
+
require_relative "../engine/result"
|
|
6
|
+
require_relative "../device/topology"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Net
|
|
10
|
+
module Connector
|
|
11
|
+
module Netdisco
|
|
12
|
+
# 设备操作保留领域返回值;公开摘要不展开命令、配置、异常消息或审批证据。
|
|
13
|
+
OperationOutcome = Data.define(:device, :status, :value, :error, :started_at, :finished_at, :duration_ms) do
|
|
14
|
+
# 时间对象也可被调用方修改;领域值由操作边界固定,结果不共享可变时间。
|
|
15
|
+
def initialize(device:, status:, value:, error:, started_at:, finished_at:, duration_ms:)
|
|
16
|
+
raise ArgumentError, "operation outcome requires a Device" unless device.is_a?(Device)
|
|
17
|
+
unless %i[succeeded partial failed missing_credentials].include?(status)
|
|
18
|
+
raise ArgumentError, "operation outcome contains an invalid status"
|
|
19
|
+
end
|
|
20
|
+
unless value.nil? || value.is_a?(Result) || value.is_a?(Topology::Plan)
|
|
21
|
+
raise ArgumentError, "operation outcome contains an invalid domain value"
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "operation error must be a StandardError or nil" unless error.nil? || error.is_a?(StandardError)
|
|
24
|
+
validate_state!(status, value, error)
|
|
25
|
+
unless started_at.is_a?(Time) && finished_at.is_a?(Time)
|
|
26
|
+
raise ArgumentError, "operation outcome timestamps must be Times"
|
|
27
|
+
end
|
|
28
|
+
unless duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0
|
|
29
|
+
raise ArgumentError, "operation duration must be nonnegative and finite"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value = immutable_plan_value(value) if value.is_a?(Topology::Plan)
|
|
33
|
+
super(device: device, status: status, value: value, error: error,
|
|
34
|
+
started_at: started_at.dup.freeze, finished_at: finished_at.dup.freeze, duration_ms: duration_ms)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def with(**attributes)
|
|
38
|
+
return self if attributes.empty?
|
|
39
|
+
|
|
40
|
+
fields = self.class.members.to_h { |name| [name, public_send(name)] }
|
|
41
|
+
self.class.new(**fields.merge(attributes))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def success? = status == :succeeded
|
|
45
|
+
|
|
46
|
+
def partial? = status == :partial
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ host: device.host, vendor: device.vendor, status: status,
|
|
50
|
+
started_at: started_at.iso8601, finished_at: finished_at.iso8601, duration_ms: duration_ms,
|
|
51
|
+
steps: value.is_a?(Result) ? value.steps.size : nil,
|
|
52
|
+
changes: value.is_a?(Topology::Plan) ? value.changes.size : nil,
|
|
53
|
+
diagnostic: Diagnostic.from(error)&.to_h }.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect = "#<#{self.class} host=#{device.host.inspect} status=#{status.inspect}>"
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# 公开更新不能把失败的领域结果改写成成功;首个业务错误与完成步骤仍是权威。
|
|
61
|
+
def validate_state!(status, value, error)
|
|
62
|
+
same_error = !value.is_a?(Result) || value.success? || error.equal?(value.error)
|
|
63
|
+
valid = case status
|
|
64
|
+
when :succeeded then value && error.nil? && (!value.is_a?(Result) || value.success?)
|
|
65
|
+
when :partial then value && error && (!value.is_a?(Result) || value.success? || !value.steps.empty?)
|
|
66
|
+
when :failed then error && (value.nil? || (value.is_a?(Result) && value.failure? && value.steps.empty?))
|
|
67
|
+
when :missing_credentials then value.nil? && error.nil?
|
|
68
|
+
end
|
|
69
|
+
raise ArgumentError, "operation state contradicts its domain value or error" unless valid && same_error
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 外部构造或 with 更新也复制审批树,不仅冻结 Data 的最外层。
|
|
73
|
+
def immutable_plan_value(value)
|
|
74
|
+
case value
|
|
75
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
76
|
+
when Array then value.map { |item| immutable_plan_value(item) }.freeze
|
|
77
|
+
when Hash then value.to_h { |key, item| [immutable_plan_value(key), immutable_plan_value(item)] }.freeze
|
|
78
|
+
when Topology::Plan, Topology::Change, Topology::Neighbor
|
|
79
|
+
fields = value.to_h.transform_values { |item| immutable_plan_value(item) }
|
|
80
|
+
value.class.new(**fields).freeze
|
|
81
|
+
else value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 一次操作的结果按请求顺序返回,回调故障与设备故障分别记录。
|
|
87
|
+
OperationBatch = Data.define(:operation, :outcomes, :callback_errors, :started_at, :finished_at) do
|
|
88
|
+
def initialize(operation:, outcomes:, callback_errors:, started_at:, finished_at:)
|
|
89
|
+
unless %i[execute_scripts sync_interface_descriptions plan_interface_descriptions apply_interface_descriptions].include?(operation)
|
|
90
|
+
raise ArgumentError, "operation batch contains an invalid operation"
|
|
91
|
+
end
|
|
92
|
+
unless outcomes.is_a?(Array) && outcomes.all?(OperationOutcome) && callback_errors.is_a?(Array)
|
|
93
|
+
raise ArgumentError, "operation batch requires outcome and callback error Arrays"
|
|
94
|
+
end
|
|
95
|
+
unless started_at.is_a?(Time) && finished_at.is_a?(Time)
|
|
96
|
+
raise ArgumentError, "operation batch timestamps must be Times"
|
|
97
|
+
end
|
|
98
|
+
super(operation: operation, outcomes: outcomes.dup.freeze,
|
|
99
|
+
callback_errors: callback_errors.map { |entry| copy_callback_error(entry) }.freeze,
|
|
100
|
+
started_at: started_at.dup.freeze, finished_at: finished_at.dup.freeze)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Ruby 3.2 更新同样重新复制容器;摘要 to_h 不参与领域对象的重建。
|
|
104
|
+
def with(**attributes)
|
|
105
|
+
return self if attributes.empty?
|
|
106
|
+
|
|
107
|
+
fields = self.class.members.to_h { |name| [name, public_send(name)] }
|
|
108
|
+
self.class.new(**fields.merge(attributes))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty?
|
|
112
|
+
|
|
113
|
+
def counts = outcomes.map(&:status).tally
|
|
114
|
+
|
|
115
|
+
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
116
|
+
|
|
117
|
+
# 清单总数指本批调度设备;部分完成与缺少凭据均计入失败,通知故障单独计数。
|
|
118
|
+
# 设备成功只在操作与关闭均完成后确认,不把远端已经发生的变更解释成可回滚事务。
|
|
119
|
+
def summary
|
|
120
|
+
statuses = counts
|
|
121
|
+
succeeded = statuses.fetch(:succeeded, 0)
|
|
122
|
+
{ total: outcomes.size, succeeded: succeeded, failed: outcomes.size - succeeded,
|
|
123
|
+
partial: statuses.fetch(:partial, 0), missing_credentials: statuses.fetch(:missing_credentials, 0),
|
|
124
|
+
callback_errors: callback_errors.size }.freeze
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# 只有完整成功的只读规划自动进入下一阶段;关闭失败的证据仍可从 outcome.value 显式检查。
|
|
128
|
+
def plans
|
|
129
|
+
outcomes.each_with_object({}) do |outcome, found|
|
|
130
|
+
found[outcome.device.host] = outcome.value if outcome.success? && outcome.value.is_a?(Topology::Plan)
|
|
131
|
+
end.freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_h
|
|
135
|
+
{ operation: operation, status: status, success: success?, summary: summary, counts: counts,
|
|
136
|
+
started_at: started_at.iso8601, finished_at: finished_at.iso8601,
|
|
137
|
+
outcomes: outcomes.map(&:to_h), callback_errors: callback_errors }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inspect = "#<#{self.class} operation=#{operation.inspect} counts=#{counts.inspect}>"
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def copy_callback_error(entry)
|
|
145
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %i[error_type host] && entry[:host].is_a?(String)
|
|
146
|
+
raise ArgumentError, "callback errors must contain a host and error type"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
{ host: entry[:host].dup.freeze, error_type: ErrorMetadata.type(entry[:error_type]) || "StandardError" }.freeze
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|