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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -1
  3. data/docs/VERIFICATION.md +1 -1
  4. data/docs/architecture.md +16 -5
  5. data/docs/netdisco.md +54 -1
  6. data/docs/netdisco.zh-CN.md +46 -2
  7. data/docs/usage.md +24 -7
  8. data/docs/usage.zh-CN.md +22 -8
  9. data/examples/backup.yml +1 -1
  10. data/lib/net/connector/background_task.rb +69 -0
  11. data/lib/net/connector/device/topology/interface_description.rb +3 -3
  12. data/lib/net/connector/device/topology/interface_name.rb +10 -19
  13. data/lib/net/connector/device/topology/stages.rb +50 -0
  14. data/lib/net/connector/device/topology.rb +65 -24
  15. data/lib/net/connector/device_session.rb +38 -0
  16. data/lib/net/connector/engine/transport.rb +15 -5
  17. data/lib/net/connector/execution_pool.rb +108 -0
  18. data/lib/net/connector/netdisco/backup_run.rb +14 -2
  19. data/lib/net/connector/netdisco/cli/options.rb +1 -0
  20. data/lib/net/connector/netdisco/cli.rb +18 -7
  21. data/lib/net/connector/netdisco/client.rb +5 -6
  22. data/lib/net/connector/netdisco/config_file.rb +5 -2
  23. data/lib/net/connector/netdisco/database_client.rb +33 -15
  24. data/lib/net/connector/netdisco/fleet.rb +68 -36
  25. data/lib/net/connector/netdisco/inventory_cache.rb +218 -0
  26. data/lib/net/connector/netdisco/operation_batch.rb +154 -0
  27. data/lib/net/connector/netdisco/operations.rb +268 -0
  28. data/lib/net/connector/netdisco/planner.rb +26 -17
  29. data/lib/net/connector/netdisco/progress.rb +22 -10
  30. data/lib/net/connector/netdisco/settings.rb +36 -6
  31. data/lib/net/connector/netdisco/tftp_archive.rb +2 -1
  32. data/lib/net/connector/netdisco/tftp_verification.rb +36 -11
  33. data/lib/net/connector/netdisco/worker.rb +7 -46
  34. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  35. data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
  36. data/lib/net/connector/version.rb +1 -1
  37. 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
- tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot)
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
- rows = (@client || @settings.client(policy: settings_snapshot)).devices
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, verification: 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
- connector = @connector_factory.call(device, connection_settings)
236
- backup, failure = perform_one(connector, backup_class) { yield connector }
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(connector, backup_class)
246
- backup = nil
247
- failure = nil
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
- backup = result
253
- rescue StandardError => error
254
- failure = error
255
- if backup_class == Backup && error.instance_of?(BackupPersistenceError) && error.backup.instance_of?(Backup)
256
- backup = error.backup
257
- elsif backup_class == TftpReceipt && error.instance_of?(TftpCompletionError) && error.receipt.instance_of?(TftpReceipt)
258
- backup = error.receipt
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