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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +61 -9
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -6
  7. data/docs/architecture.md +184 -7
  8. data/lib/net/connector/device/base.rb +11 -4
  9. data/lib/net/connector/device/running_config.rb +2 -0
  10. data/lib/net/connector/engine/command.rb +14 -2
  11. data/lib/net/connector/engine/configuration.rb +10 -7
  12. data/lib/net/connector/engine/dialogue.rb +52 -28
  13. data/lib/net/connector/engine/errors.rb +40 -55
  14. data/lib/net/connector/engine/execution.rb +32 -5
  15. data/lib/net/connector/engine/log.rb +11 -9
  16. data/lib/net/connector/engine/session.rb +38 -7
  17. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  18. data/lib/net/connector/netdisco/batch.rb +31 -3
  19. data/lib/net/connector/netdisco/cli.rb +73 -31
  20. data/lib/net/connector/netdisco/client.rb +206 -73
  21. data/lib/net/connector/netdisco/config_file.rb +26 -4
  22. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  23. data/lib/net/connector/netdisco/fleet.rb +103 -60
  24. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  25. data/lib/net/connector/netdisco/report.rb +94 -0
  26. data/lib/net/connector/netdisco/rules.rb +28 -5
  27. data/lib/net/connector/netdisco/settings.rb +194 -85
  28. data/lib/net/connector/netdisco/worker.rb +26 -17
  29. data/lib/net/connector/operations/backup_lock.rb +116 -0
  30. data/lib/net/connector/operations/local_backup.rb +49 -9
  31. data/lib/net/connector/operations/parse_output.rb +20 -3
  32. data/lib/net/connector/operations/private_file.rb +94 -5
  33. data/lib/net/connector/operations/safe_file.rb +62 -0
  34. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  35. data/lib/net/connector/operations/saved_config.rb +40 -10
  36. data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
  37. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  38. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  39. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  40. data/lib/net/connector/operations/topology/strategy.rb +20 -0
  41. data/lib/net/connector/operations/topology.rb +97 -29
  42. data/lib/net/connector/operations.rb +6 -0
  43. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  44. data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
  45. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  46. data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
  47. data/lib/net/connector/vendor/h3c/topology.rb +9 -4
  48. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
  49. data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
  50. data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
  51. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
  52. data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
  53. data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
  54. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  55. data/lib/net/connector/version.rb +1 -1
  56. metadata +35 -5
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Netdisco
6
+ # 报告是新的诊断通道:只接受固定词表,不展开异常消息、调用栈、命令、source 或 line。
7
+ Diagnostic = Data.define(:error_code, :error_type, :phase, :underlying_type,
8
+ :artifact_state, :artifact_phase, :verification)
9
+
10
+ class Diagnostic
11
+ CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
12
+ PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
13
+ OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
14
+ BackupPersistenceError TftpCompletionError SavedConfigChanged].freeze
15
+ TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
16
+ %w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
17
+ RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
18
+ Timeout::Error IO::TimeoutError Errno::EIO Errno::EACCES Errno::EPERM Errno::ENOSPC Errno::EROFS
19
+ Errno::ENOENT Errno::ENOTDIR Errno::EISDIR Errno::ELOOP Errno::EINVAL Errno::ENOSYS
20
+ Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
21
+ Net::Connector::Operations::PrivateFile::WriteError
22
+ Net::Connector::Operations::PrivateFile::PersistenceError
23
+ Net::Connector::Operations::PrivateFile::DirectorySyncUnsupported]).freeze
24
+ CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
25
+ %i[authentication_failed connection_failed connection_refused no_route connection_reset
26
+ connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
27
+ ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
28
+ description_unconfirmed description_unsupported incomplete_configuration interface_missing
29
+ neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
30
+ template_invalid template_missing template_unreadable transfer_failed transfer_finalize_failed
31
+ transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
32
+ unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
33
+ backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
34
+ file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported]).freeze
35
+ PHASES = %i[connect login enable command script read write close logging backup collect discover parse
36
+ plan apply verify persist tftp_backup report callback].freeze
37
+ ARTIFACT_STATES = %i[not_committed committed durable reported_uploaded].freeze
38
+ ARTIFACT_PHASES = %i[resolve_parent temporary_file write file_sync rename directory_open directory_sync
39
+ cleanup complete path finalize].freeze
40
+ private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES, :ARTIFACT_STATES, :ARTIFACT_PHASES
41
+
42
+ # 包括手工构造的结果在内,v2 都不能携带自由文本诊断值。
43
+ def initialize(error_code: nil, error_type: nil, phase: nil, underlying_type: nil,
44
+ artifact_state: nil, artifact_phase: nil, verification: nil)
45
+ super(error_code: CODES.include?(error_code) ? error_code : nil,
46
+ error_type: self.class.type(error_type), phase: PHASES.include?(phase) ? phase : nil,
47
+ underlying_type: self.class.type(underlying_type),
48
+ artifact_state: ARTIFACT_STATES.include?(artifact_state) ? artifact_state : nil,
49
+ artifact_phase: ARTIFACT_PHASES.include?(artifact_phase) ? artifact_phase : nil,
50
+ verification: verification == :device_reported ? verification : nil)
51
+ end
52
+
53
+ def self.type(name)
54
+ return if name.nil?
55
+
56
+ name.instance_of?(String) && TYPES.include?(name) ? name.dup.freeze : "StandardError"
57
+ end
58
+
59
+ # 读取已知错误的白名单属性后立即丢弃异常引用,不把回调对象保留进批次。
60
+ def self.from(error, backup: nil, phase: nil)
61
+ return unless error
62
+
63
+ fields = { error_type: type(error.class.name) || "StandardError", phase: phase }
64
+ if error.is_a?(Net::Connector::Error)
65
+ fields.merge!(error_code: error.code, phase: phase || error.phase)
66
+ fields[:underlying_type] = error.underlying.type if error.underlying.instance_of?(UnderlyingError)
67
+ end
68
+ new(**fields.merge(completion_fields(error, backup, phase)))
69
+ rescue StandardError
70
+ new(error_type: "StandardError", phase: phase)
71
+ end
72
+
73
+ def self.completion_fields(error, backup, phase)
74
+ if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
75
+ { artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
76
+ underlying_type: error.underlying_type }
77
+ elsif error.instance_of?(TftpCompletionError) && backup.instance_of?(TftpBackup) && error.transfer.equal?(backup)
78
+ { artifact_state: :reported_uploaded, artifact_phase: error.code == :transfer_finalize_failed ? :finalize : :path,
79
+ verification: :device_reported, underlying_type: error.underlying_type }
80
+ elsif phase == :report && Operations::PrivateFile.receipt_error?(error)
81
+ { error_code: error.code, artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
82
+ underlying_type: error.underlying_type }
83
+ else
84
+ {}
85
+ end
86
+ end
87
+ private_class_method :completion_fields
88
+ end
89
+ end
90
+ end
91
+ end
@@ -8,90 +8,117 @@ module Net
8
8
  module Netdisco
9
9
  # 基于已验证的清单快照组织有上限的并发备份。
10
10
  class Fleet
11
+ DEFAULT_SETTING = Object.new.freeze
12
+ private_constant :DEFAULT_SETTING
13
+
11
14
  # 组装清单客户端、规则、凭据与结果存储。
12
15
  def initialize(client: nil, settings: Settings.new, rules: nil, credentials: nil, connector_factory: nil,
13
16
  result_store: ResultStore::Text.new)
14
17
  @settings = settings
15
- @client = client || settings.client
16
- @rules = rules || settings.rules
17
- @credentials = credentials || settings.method(:credentials_for)
18
+ @client = client
19
+ @rules = rules
20
+ @credentials = credentials
18
21
  @connector_factory = connector_factory || ->(device, connection_settings) { device.connector(**connection_settings) }
19
22
  @result_store = result_store
20
- raise ArgumentError, "credentials must respond to call" unless @credentials.respond_to?(:call)
23
+ raise ArgumentError, "credentials must respond to call" if @credentials && !@credentials.respond_to?(:call)
21
24
  raise ArgumentError, "result_store must respond to write" if result_store && !result_store.respond_to?(:write)
22
25
  end
23
26
 
24
27
  # 拉取并标记重复管理地址的设备清单。
25
28
  def devices
26
- rows = @client.devices
27
- raise Client::Error, "Netdisco returned an invalid device inventory" unless rows.is_a?(Array)
28
-
29
- devices = rows.map { |row| Device.from_row(row, rules: @rules) }
30
- duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
31
- devices.map do |device|
32
- duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device
33
- end.freeze
29
+ inventory(@settings.snapshot(mode: :inventory))
34
30
  end
35
31
 
36
32
  # 规划本地配置备份的设备集合。
37
33
  def plan_backup(limit_per_vendor: nil)
38
- Planner.new(devices).call(mode: :backup, limit_per_vendor: limit_per_vendor)
34
+ build_plan(:backup, limit_per_vendor, @settings.snapshot(mode: :backup))
39
35
  end
40
36
 
41
37
  # 规划 TFTP 配置备份的设备集合。
42
38
  def plan_tftp_backup(limit_per_vendor: 5)
43
- Planner.new(devices).call(mode: :tftp, limit_per_vendor: limit_per_vendor)
39
+ build_plan(:tftp, limit_per_vendor, @settings.snapshot(mode: :tftp))
44
40
  end
45
41
 
46
42
  # 先拉取完整清单,再并发采集并保存设备配置。
47
- def backup_all(directory: @settings.backup_directory, concurrency: @settings.concurrency,
48
- limit_per_vendor: nil, plan: nil, on_start: nil, on_result: nil, on_change: nil)
43
+ def backup_all(directory: DEFAULT_SETTING, concurrency: DEFAULT_SETTING,
44
+ limit_per_vendor: nil, plan: nil, on_start: nil, on_result: nil, on_change: nil,
45
+ success_policy: :strict, report_schema: nil)
46
+ reporting = Report.options(policy: success_policy, schema: report_schema)
47
+ policy = @settings.snapshot(mode: :backup)
48
+ directory = policy.backup_directory if directory.equal?(DEFAULT_SETTING)
49
+ concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
49
50
  raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
51
+ Worker.new(concurrency: concurrency)
50
52
  Planner.validate_limit!(limit_per_vendor)
51
53
  validate_callback!(on_result, :on_result)
52
54
  validate_callback!(on_start, :on_start)
53
55
  validate_callback!(on_change, :on_change)
54
56
 
55
- plan ||= plan_backup(limit_per_vendor: limit_per_vendor)
57
+ plan ||= build_plan(:backup, limit_per_vendor, policy)
56
58
  validate_plan!(plan, :backup)
57
59
  callbacks = [on_result]
58
60
  callbacks << ->(item) { on_change.call(item) if item.backup.is_a?(Backup) && item.backup.changed? } if on_change
59
61
  target_directory = File.expand_path(directory)
62
+ saved_config = Operations::SavedConfig.new(directory: target_directory, indexed: true)
60
63
  run_batch(:backup, plan, concurrency: concurrency, report_directory: directory,
61
- output_directory: target_directory, on_start: on_start, on_result: callbacks.compact) do |device, log_directory|
62
- backup_one(device, target_directory, log_directory)
64
+ output_directory: target_directory, policy: policy, reporting: reporting,
65
+ on_start: on_start, on_result: callbacks.compact) do |device, log_directory|
66
+ backup_one(device, target_directory, log_directory, policy, saved_config)
63
67
  end
64
68
  end
65
69
 
66
70
  # 让设备主动导出配置,在执行前检查同批目标文件名冲突。
67
- def tftp_backup_all(server:, source_files: {}, concurrency: @settings.concurrency,
71
+ def tftp_backup_all(server:, source_files: {}, concurrency: DEFAULT_SETTING,
68
72
  limit_per_vendor: 5, vrfs: {}, on_start: nil, on_result: nil, plan: nil,
69
- report_directory: @settings.backup_directory)
70
- TftpTarget.new(host: server, path: "preflight.cfg")
73
+ report_directory: DEFAULT_SETTING, success_policy: :strict, report_schema: nil)
74
+ reporting = Report.options(policy: success_policy, schema: report_schema)
75
+ policy = @settings.snapshot(mode: :tftp)
76
+ concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
77
+ report_directory = policy.backup_directory if report_directory.equal?(DEFAULT_SETTING)
78
+ Worker.new(concurrency: concurrency)
79
+ server = TftpTarget.new(host: server, path: "preflight.cfg").host
71
80
  raise ArgumentError, "source_files must be a Hash" unless source_files.is_a?(Hash)
72
81
  source_files.each_value { |source_file| TftpTarget.validate_source_file!(source_file) }
73
- validate_vrfs!(vrfs)
82
+ source_files = source_files.transform_values { |value| value.dup.freeze }.freeze
83
+ vrfs = Settings.validate_vrfs!(vrfs).transform_values { |value| value.dup.freeze }.freeze
74
84
  Planner.validate_limit!(limit_per_vendor)
75
85
  validate_callback!(on_result, :on_result)
76
86
  validate_callback!(on_start, :on_start)
77
87
 
78
- plan ||= plan_tftp_backup(limit_per_vendor: limit_per_vendor)
88
+ plan ||= build_plan(:tftp, limit_per_vendor, policy)
79
89
  validate_plan!(plan, :tftp)
80
90
  run_batch(:tftp, plan, concurrency: concurrency, report_directory: report_directory,
81
- on_start: on_start, on_result: on_result) do |device, log_directory|
82
- tftp_backup_one(device, server, source_files, log_directory, vrfs)
91
+ policy: policy, reporting: reporting, on_start: on_start, on_result: on_result) do |device, log_directory|
92
+ tftp_backup_one(device, server, source_files, log_directory, vrfs, policy)
83
93
  end
84
94
  end
85
95
 
86
96
  private
87
97
 
98
+ # 策略在清单请求之前固定;注入的清单客户端仍由调用方负责其自身生命周期。
99
+ def inventory(policy)
100
+ rows = (@client || @settings.client(policy: policy)).devices
101
+ raise Client::Error, "Netdisco returned an invalid device inventory" unless rows.is_a?(Array)
102
+
103
+ rules = @rules || policy.rules
104
+ devices = rows.map { |row| Device.from_row(row, rules: rules) }
105
+ duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
106
+ devices.map { |device| duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device }.freeze
107
+ end
108
+
109
+ def build_plan(mode, limit, policy)
110
+ Planner.validate_limit!(limit)
111
+ Planner.new(inventory(policy)).call(mode: mode, limit_per_vendor: limit)
112
+ end
113
+
88
114
  # 统一组织设备任务、日志目录与报告;Worker 保证单台设备失败不影响其他设备。
89
- def run_batch(mode, plan, concurrency:, report_directory:, on_start:, on_result:, output_directory: nil)
115
+ def run_batch(mode, plan, concurrency:, report_directory:, policy:, reporting:, on_start:, on_result:, output_directory: nil)
90
116
  worker = Worker.new(concurrency: concurrency)
91
117
  started_at = Time.now.utc
118
+ started = monotonic
92
119
  outcomes = plan.outcomes.dup
93
120
  FileUtils.mkdir_p(output_directory, mode: 0o700) if output_directory && !plan.ready.empty?
94
- log_directory = @settings.log_directory
121
+ log_directory = policy.log_directory
95
122
  FileUtils.mkdir_p(log_directory, mode: 0o700) if log_directory && !plan.ready.empty?
96
123
  callback_errors = worker.run(plan.ready, outcomes: outcomes, on_start: on_start, on_result: on_result,
97
124
  on_error: ->(device, error) { outcome(device, :failed, error: error) }) do |device|
@@ -100,9 +127,14 @@ module Net
100
127
  batch = Batch.new(mode: mode, outcomes: outcomes.freeze, started_at: started_at,
101
128
  finished_at: Time.now.utc, callback_errors: callback_errors,
102
129
  report_location: nil, report_error: nil)
130
+ if reporting.fetch(:schema) == 2
131
+ batch = Report.new(batch, policy: reporting.fetch(:policy), duration_ms: ((monotonic - started) * 1000).round)
132
+ end
103
133
  save_report(batch, directory: report_directory)
104
134
  end
105
135
 
136
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+
106
138
  # 校验批量任务回调接口。
107
139
  def validate_callback!(callback, name)
108
140
  raise ArgumentError, "#{name} must respond to call" if callback && !callback.respond_to?(:call)
@@ -113,18 +145,16 @@ module Net
113
145
  return batch unless @result_store
114
146
 
115
147
  batch.with(report_location: @result_store.write(batch, directory: directory))
148
+ rescue Operations::PrivateFile::WriteError => error
149
+ # 报告也可能已经原子替换;保留已知位置,不将持久性失败误写成完全没有产物。
150
+ location = error.receipt.path if Operations::PrivateFile.receipt_error?(error) && error.receipt.committed?
151
+ return batch.with_report_error(error, location: location) if batch.instance_of?(Report)
152
+
153
+ batch.with(report_location: location, report_error: error.class.name)
116
154
  rescue StandardError => error
117
- batch.with(report_error: error.class.name)
118
- end
155
+ return batch.with_report_error(error) if batch.instance_of?(Report)
119
156
 
120
- # 校验厂商 VRF 映射和名称。
121
- def validate_vrfs!(vrfs)
122
- unless vrfs.is_a?(Hash) && vrfs.all? { |vendor, value|
123
- %i[cisco_nxos hillstone].include?(vendor) && value.is_a?(String) &&
124
- value.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/)
125
- }
126
- raise ArgumentError, "vrfs must map supported vendor names to safe VRF names"
127
- end
157
+ batch.with(report_error: error.class.name)
128
158
  end
129
159
 
130
160
  # 确认传入计划属于当前备份模式。
@@ -138,12 +168,12 @@ module Net
138
168
  def outcome(device, status, backup: nil, error: nil)
139
169
  Outcome.new(device: device, status: status, backup: backup,
140
170
  error_code: error.respond_to?(:code) ? error.code : nil,
141
- error_type: error&.class&.name)
171
+ error_type: error&.class&.name, diagnostic: Diagnostic.from(error, backup: backup))
142
172
  end
143
173
 
144
174
  # 执行单台设备的 TFTP 导出。
145
- def tftp_backup_one(device, server, source_files, log_directory, vrfs)
146
- run_one(device, log_directory, success_status: :reported_uploaded,
175
+ def tftp_backup_one(device, server, source_files, log_directory, vrfs, policy)
176
+ run_one(device, log_directory, policy, success_status: :reported_uploaded,
147
177
  close_error_status: :reported_with_error, backup_class: TftpBackup) do |connector|
148
178
  source_file = source_files.fetch(device.vendor, nil)
149
179
  transfer_settings = { host: server, path: device.tftp_filename, source_file: source_file }
@@ -153,25 +183,26 @@ module Net
153
183
  end
154
184
 
155
185
  # 执行单台设备的本地配置采集。
156
- def backup_one(device, directory, log_directory)
186
+ def backup_one(device, directory, log_directory, policy, saved_config)
157
187
  destination = File.join(directory, device.backup_filename)
158
- previous = Operations::SavedConfig.new(directory: directory).find(device.host, required: false)
159
- previous_digest = Digest::SHA256.file(previous).hexdigest if previous && previous != destination
160
- run_one(device, log_directory, success_status: :backed_up,
161
- close_error_status: :saved_with_error, backup_class: Backup) do |connector|
162
- backup = connector.backup(path: destination)
163
- if backup.is_a?(Backup) && backup.change == :created && previous_digest
164
- backup.with(previous_sha256: previous_digest,
165
- change: backup.sha256 == previous_digest ? :unchanged : :changed)
166
- else
167
- backup
188
+ Operations::BackupLock.synchronize(destination, host: device.host) do |path_lock|
189
+ previous = saved_config.fingerprint(device.host, required: false)
190
+ previous_digest = previous.sha256 if previous && previous.path != destination
191
+ result = run_one(device, log_directory, policy, success_status: :backed_up,
192
+ close_error_status: :saved_with_error, backup_class: Backup) do |connector|
193
+ path_lock.delegate { connector.backup(path: destination) }
168
194
  end
195
+ backup = result.backup
196
+ next result unless backup.is_a?(Backup) && backup.change == :created && previous_digest
197
+
198
+ result.with(backup: backup.with(previous_sha256: previous_digest,
199
+ change: backup.sha256 == previous_digest ? :unchanged : :changed))
169
200
  end
170
201
  end
171
202
 
172
203
  # 隔离单台设备的凭据、连接、操作和关闭异常。
173
- def run_one(device, log_directory, success_status:, close_error_status:, backup_class:)
174
- connection_settings = @credentials.call(device)
204
+ def run_one(device, log_directory, policy, success_status:, close_error_status:, backup_class:)
205
+ connection_settings = @credentials ? @credentials.call(device) : @settings.device_credentials_for(device)
175
206
  return outcome(device, :missing_credentials) if connection_settings.nil?
176
207
  unless connection_settings.is_a?(Hash) && connection_settings.keys.all?(Symbol) &&
177
208
  connection_settings[:username].is_a?(String) && !connection_settings[:username].empty? &&
@@ -179,20 +210,36 @@ module Net
179
210
  raise ArgumentError, "credential resolver must return settings with username and without host"
180
211
  end
181
212
 
213
+ connection_settings = policy.connection_options(device.vendor).merge(connection_settings)
182
214
  log_name = device.host.tr(":", "_")
183
215
  if log_directory
184
216
  connection_settings = connection_settings.merge(log_file: File.join(log_directory, "#{log_name}.log"))
185
217
  end
186
218
  connector = @connector_factory.call(device, connection_settings)
219
+ backup, failure = perform_one(connector, backup_class) { yield connector }
220
+ return outcome(device, success_status, backup: backup) unless failure
221
+
222
+ outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
223
+ rescue StandardError => error
224
+ outcome(device, :failed, error: error)
225
+ end
226
+
227
+ # 仅信任库定义且产物类型匹配的提交后错误;普通第三方异常上的 backup 字段不代表完成。
228
+ def perform_one(connector, backup_class)
187
229
  backup = nil
188
230
  failure = nil
189
231
  begin
190
- result = yield connector
232
+ result = yield
191
233
  raise TypeError, "backup operation did not return #{backup_class}" unless result.is_a?(backup_class)
192
234
 
193
235
  backup = result
194
236
  rescue StandardError => error
195
237
  failure = error
238
+ if backup_class == Backup && error.instance_of?(BackupPersistenceError) && error.backup.instance_of?(Backup)
239
+ backup = error.backup
240
+ elsif backup_class == TftpBackup && error.instance_of?(TftpCompletionError) && error.transfer.instance_of?(TftpBackup)
241
+ backup = error.transfer
242
+ end
196
243
  ensure
197
244
  begin
198
245
  connector.close
@@ -200,11 +247,7 @@ module Net
200
247
  failure ||= error
201
248
  end
202
249
  end
203
- return outcome(device, success_status, backup: backup) unless failure
204
-
205
- outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
206
- rescue StandardError => error
207
- outcome(device, :failed, error: error)
250
+ [backup, failure]
208
251
  end
209
252
  end
210
253
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Netdisco
6
+ # 每次 devices 调用独占一个预算,认证、分页和兼容查询不得重置已用资源或期限。
7
+ class InventoryBudget
8
+ def initialize(options, clock:)
9
+ @options = options
10
+ @clock = clock
11
+ @deadline = @clock.call + options.fetch(:inventory_timeout)
12
+ @bytes = 0
13
+ @devices = 0
14
+ end
15
+
16
+ def remaining
17
+ seconds = @deadline - @clock.call
18
+ raise Client::InventoryTimeout, cause: nil if seconds <= 0
19
+
20
+ seconds
21
+ end
22
+
23
+ # 在追加正文前计数;不能靠 Content-Length 或 JSON 解析后的大小限制内存。
24
+ def consume_bytes(size, response_bytes:)
25
+ remaining
26
+ check_limit!(:max_response_bytes, response_bytes + size)
27
+ check_limit!(:max_inventory_bytes, @bytes + size)
28
+ @bytes += size
29
+ end
30
+
31
+ # 去重前的记录同样占用清单内存;兼容查询也使用这个累计计数。
32
+ def consume_devices(size)
33
+ remaining
34
+ check_limit!(:max_devices, @devices + size)
35
+ @devices += size
36
+ end
37
+
38
+ private
39
+
40
+ def check_limit!(name, value)
41
+ return if value <= @options.fetch(name)
42
+
43
+ raise Client::Error.new("Netdisco inventory exceeded #{name}", code: name), cause: nil
44
+ end
45
+ end
46
+ private_constant :InventoryBudget
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "batch"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ # v2 显式包装旧 Batch;成功策略与清单覆盖分开,默认 Batch JSON 不增加字段。
10
+ class Report
11
+ extend Forwardable
12
+ SUCCESS = %i[backed_up reported_uploaded].freeze
13
+ ATTEMPTED = [*SUCCESS, :failed, :saved_with_error, :reported_with_error].freeze
14
+ IGNORABLE = %i[filtered sample_limit].freeze
15
+ private_constant :SUCCESS, :ATTEMPTED, :IGNORABLE
16
+
17
+ attr_reader :batch, :policy, :duration_ms, :report_diagnostic
18
+ def_delegators :@batch, *Batch.members, :success?, :status, :counts
19
+
20
+ # selected 必须使用带策略和覆盖信息的 v2;默认 strict 继续使用旧 schema。
21
+ def self.options(policy: :strict, schema: nil)
22
+ raise ArgumentError, "success_policy must be strict or selected" unless %i[strict selected].include?(policy)
23
+
24
+ schema = policy == :selected ? 2 : 1 if schema.nil?
25
+ raise ArgumentError, "report_schema must be 1 or 2" unless schema.is_a?(Integer) && [1, 2].include?(schema)
26
+ raise ArgumentError, "selected success policy requires report schema 2" if policy == :selected && schema != 2
27
+
28
+ { policy: policy, schema: schema }.freeze
29
+ end
30
+
31
+ def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil)
32
+ self.class.options(policy: policy, schema: 2)
33
+ raise ArgumentError, "report requires a Batch" unless batch.instance_of?(Batch)
34
+ unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
35
+ raise ArgumentError, "duration_ms must be nonnegative and finite"
36
+ end
37
+ unless report_diagnostic.nil? || report_diagnostic.instance_of?(Diagnostic)
38
+ raise ArgumentError, "report_diagnostic must be a Diagnostic"
39
+ end
40
+
41
+ @batch, @policy, @duration_ms, @report_diagnostic = batch, policy, duration_ms, report_diagnostic
42
+ freeze
43
+ end
44
+
45
+ # 旧 success?/status 委托给 Batch;新策略绝不把未知状态当作可忽略跳过。
46
+ def policy_success?
47
+ return batch.success? if policy == :strict
48
+ return false unless callback_errors.empty? && report_error.nil?
49
+
50
+ outcomes.any? { |item| SUCCESS.include?(item.status) } &&
51
+ outcomes.all? { |item| SUCCESS.include?(item.status) || IGNORABLE.include?(item.status) }
52
+ end
53
+
54
+ def with(**attributes)
55
+ self.class.new(batch.with(**attributes), policy: policy, duration_ms: duration_ms,
56
+ report_diagnostic: report_diagnostic)
57
+ end
58
+
59
+ def with_report_error(error, location: nil)
60
+ diagnostic = Diagnostic.from(error, phase: :report)
61
+ self.class.new(batch.with(report_location: location, report_error: diagnostic.error_type),
62
+ policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic)
63
+ end
64
+
65
+ # 复用旧业务数据,但重新白名单化所有诊断字段,包括手工构造的旧 Batch。
66
+ def summary
67
+ legacy = batch.summary
68
+ legacy.merge(
69
+ schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
70
+ coverage: { complete: !outcomes.empty? && outcomes.all? { |item| ATTEMPTED.include?(item.status) },
71
+ attempted: outcomes.count { |item| ATTEMPTED.include?(item.status) },
72
+ skipped: outcomes.count { |item| !ATTEMPTED.include?(item.status) } },
73
+ devices: device_summaries(legacy.fetch(:devices)),
74
+ callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: Diagnostic.type(entry[:error_type]) } },
75
+ report_location: report_location, report_error: Diagnostic.type(report_error),
76
+ report_diagnostic: report_diagnostic&.to_h
77
+ )
78
+ end
79
+
80
+ def inspect = "#<#{self.class} policy=#{policy} status=#{status} policy_success=#{policy_success?}>"
81
+
82
+ private
83
+
84
+ def device_summaries(entries)
85
+ entries.zip(outcomes).map do |entry, item|
86
+ diagnostic = item.diagnostic || Diagnostic.new(error_code: item.error_code, error_type: item.error_type)
87
+ entry.merge(error_code: diagnostic.error_code, error_type: diagnostic.error_type,
88
+ diagnostic: diagnostic.to_h.except(:error_code, :error_type))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -12,20 +12,33 @@ module Net
12
12
  host_overrides: {}, mappings: [])
13
13
  @include_hosts = string_list(include_hosts)
14
14
  @exclude_hosts = string_list(exclude_hosts)
15
+ @include_vendors = vendor_list(include_vendors)
16
+ @vendor_overrides = vendor_mapping(vendor_overrides)
17
+ @host_overrides = host_mapping(host_overrides)
18
+ @mappings = device_mappings(mappings)
19
+ end
20
+
21
+ # 校验允许使用的厂商列表。
22
+ def vendor_list(include_vendors)
15
23
  raise ArgumentError, "include_vendors must be an Array" unless include_vendors.is_a?(Array)
16
24
 
17
- @include_vendors = include_vendors.map do |vendor|
25
+ vendors = include_vendors.map do |vendor|
18
26
  raise ArgumentError, "include_vendors contains an invalid value" unless vendor.is_a?(String) || vendor.is_a?(Symbol)
19
27
 
20
28
  vendor.to_sym
21
29
  end.freeze
22
- unless @include_vendors.all? { |vendor| Net::Connector.vendors.include?(vendor) }
30
+ unless vendors.all? { |vendor| Net::Connector.vendors.include?(vendor) }
23
31
  raise ArgumentError, "include_vendors contains an unsupported connector"
24
32
  end
33
+ vendors
34
+ end
35
+
36
+ # 将清单厂商标签映射为受支持的连接器。
37
+ def vendor_mapping(vendor_overrides)
25
38
  unless vendor_overrides.is_a?(Hash)
26
39
  raise ArgumentError, "vendor_overrides must be a Hash"
27
40
  end
28
- @vendor_overrides = vendor_overrides.to_h do |label, vendor|
41
+ vendor_overrides.to_h do |label, vendor|
29
42
  unless label.is_a?(String) && vendor.is_a?(String) && !label.empty?
30
43
  raise ArgumentError, "vendor_overrides must map labels to connector names"
31
44
  end
@@ -35,9 +48,13 @@ module Net
35
48
 
36
49
  [key, value]
37
50
  end.freeze
51
+ end
52
+
53
+ # 按规范化管理地址覆盖厂商,拒绝网段和无效地址。
54
+ def host_mapping(host_overrides)
38
55
  raise ArgumentError, "host_overrides must be a Hash" unless host_overrides.is_a?(Hash)
39
56
 
40
- @host_overrides = host_overrides.to_h do |host, vendor|
57
+ host_overrides.to_h do |host, vendor|
41
58
  unless host.is_a?(String) && !host.include?("/") && vendor.is_a?(String)
42
59
  raise ArgumentError, "host_overrides must map IP addresses to connector names"
43
60
  end
@@ -51,9 +68,13 @@ module Net
51
68
 
52
69
  [address, connector]
53
70
  end.freeze
71
+ end
72
+
73
+ # 型号映射仅接受已声明字段,并冻结每条规则。
74
+ def device_mappings(mappings)
54
75
  raise ArgumentError, "mappings must be an Array" unless mappings.is_a?(Array)
55
76
 
56
- @mappings = mappings.map do |rule|
77
+ mappings.map do |rule|
57
78
  unless rule.is_a?(Hash) && rule["vendor"].is_a?(String) && !rule["vendor"].empty? &&
58
79
  rule["connector"].is_a?(String) &&
59
80
  (rule.keys - %w[vendor os model_prefix connector]).empty? &&
@@ -68,6 +89,8 @@ module Net
68
89
  end.freeze
69
90
  end
70
91
 
92
+ private :vendor_list, :vendor_mapping, :host_mapping, :device_mappings
93
+
71
94
  # 根据地址、显式规则和厂商信息识别连接器。
72
95
  def resolve(row)
73
96
  begin