net-connector 0.4.2 → 0.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +42 -19
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +96 -64
  6. data/lib/net/connector/device/base.rb +27 -107
  7. data/lib/net/connector/device/local_backup.rb +96 -0
  8. data/lib/net/connector/device/profile.rb +8 -3
  9. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  10. data/lib/net/connector/device/running_config.rb +44 -5
  11. data/lib/net/connector/device/save_config.rb +21 -0
  12. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  13. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  14. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  15. data/lib/net/connector/device/tftp/target.rb +63 -0
  16. data/lib/net/connector/device/tftp.rb +117 -0
  17. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  18. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  19. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  20. data/lib/net/connector/device/topology/strategy.rb +75 -0
  21. data/lib/net/connector/device/topology.rb +277 -0
  22. data/lib/net/connector/engine/authentication.rb +1 -1
  23. data/lib/net/connector/engine/configuration.rb +1 -1
  24. data/lib/net/connector/engine/dialogue.rb +17 -17
  25. data/lib/net/connector/engine/error_metadata.rb +50 -0
  26. data/lib/net/connector/engine/errors.rb +15 -1
  27. data/lib/net/connector/engine/execution.rb +15 -8
  28. data/lib/net/connector/engine/log/event.rb +66 -0
  29. data/lib/net/connector/engine/log/formatter.rb +16 -0
  30. data/lib/net/connector/engine/log/messages.rb +52 -0
  31. data/lib/net/connector/engine/log/stream.rb +56 -0
  32. data/lib/net/connector/engine/log.rb +105 -84
  33. data/lib/net/connector/engine/session.rb +83 -42
  34. data/lib/net/connector/engine/terminal_text.rb +33 -0
  35. data/lib/net/connector/engine/transport.rb +2 -2
  36. data/lib/net/connector/netdisco/batch.rb +9 -45
  37. data/lib/net/connector/netdisco/cli.rb +7 -9
  38. data/lib/net/connector/netdisco/client.rb +2 -2
  39. data/lib/net/connector/netdisco/device.rb +4 -5
  40. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  41. data/lib/net/connector/netdisco/fleet.rb +26 -39
  42. data/lib/net/connector/netdisco/report.rb +37 -16
  43. data/lib/net/connector/netdisco/result_store.rb +2 -2
  44. data/lib/net/connector/netdisco/settings.rb +0 -6
  45. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  46. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  47. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  48. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  49. data/lib/net/connector/storage.rb +15 -0
  50. data/lib/net/connector/textfsm.rb +72 -0
  51. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  53. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  55. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  56. data/lib/net/connector/vendor/h3c.rb +3 -3
  57. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  59. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  60. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  61. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  63. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  65. data/lib/net/connector/version.rb +1 -1
  66. data/lib/net/connector.rb +1 -6
  67. metadata +25 -39
  68. data/lib/net/connector/device/interface_description.rb +0 -44
  69. data/lib/net/connector/device/interface_name.rb +0 -56
  70. data/lib/net/connector/engine/base.rb +0 -4
  71. data/lib/net/connector/engine/log_messages.rb +0 -47
  72. data/lib/net/connector/engine/profile.rb +0 -4
  73. data/lib/net/connector/engine.rb +0 -4
  74. data/lib/net/connector/operations/local_backup.rb +0 -89
  75. data/lib/net/connector/operations/parse_output.rb +0 -77
  76. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  77. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  78. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  79. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  80. data/lib/net/connector/operations/running_config.rb +0 -16
  81. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  82. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  83. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  84. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  85. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  86. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  87. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  88. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  89. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  90. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  91. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  92. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  93. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  94. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  96. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/topology/radware.rb +0 -11
  98. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  99. data/lib/net/connector/operations/topology.rb +0 -261
  100. data/lib/net/connector/operations.rb +0 -26
@@ -18,7 +18,7 @@ module Net
18
18
  @client = client
19
19
  @rules = rules
20
20
  @credentials = credentials
21
- @connector_factory = connector_factory || ->(device, connection_settings) { device.connector(**connection_settings) }
21
+ @connector_factory = connector_factory || ->(device, connection_settings) { device.build_connector(**connection_settings) }
22
22
  @result_store = result_store
23
23
  raise ArgumentError, "credentials must respond to call" if @credentials && !@credentials.respond_to?(:call)
24
24
  raise ArgumentError, "result_store must respond to write" if result_store && !result_store.respond_to?(:write)
@@ -42,8 +42,8 @@ module Net
42
42
  # 先拉取完整清单,再并发采集并保存设备配置。
43
43
  def backup_all(directory: DEFAULT_SETTING, concurrency: DEFAULT_SETTING,
44
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)
45
+ success_policy: :strict)
46
+ reporting = Report.validate_policy!(success_policy)
47
47
  policy = @settings.snapshot(mode: :backup)
48
48
  directory = policy.backup_directory if directory.equal?(DEFAULT_SETTING)
49
49
  concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
@@ -59,19 +59,18 @@ module Net
59
59
  callbacks = [on_result]
60
60
  callbacks << ->(item) { on_change.call(item) if item.backup.is_a?(Backup) && item.backup.changed? } if on_change
61
61
  target_directory = File.expand_path(directory)
62
- saved_config = Operations::SavedConfig.new(directory: target_directory, indexed: true)
63
62
  run_batch(:backup, plan, concurrency: concurrency, report_directory: directory,
64
63
  output_directory: target_directory, policy: policy, reporting: reporting,
65
64
  on_start: on_start, on_result: callbacks.compact) do |device, log_directory|
66
- backup_one(device, target_directory, log_directory, policy, saved_config)
65
+ backup_one(device, target_directory, log_directory, policy)
67
66
  end
68
67
  end
69
68
 
70
69
  # 让设备主动导出配置,在执行前检查同批目标文件名冲突。
71
70
  def tftp_backup_all(server:, source_files: {}, concurrency: DEFAULT_SETTING,
72
71
  limit_per_vendor: 5, vrfs: {}, on_start: nil, on_result: nil, plan: nil,
73
- report_directory: DEFAULT_SETTING, success_policy: :strict, report_schema: nil)
74
- reporting = Report.options(policy: success_policy, schema: report_schema)
72
+ report_directory: DEFAULT_SETTING, success_policy: :strict)
73
+ reporting = Report.validate_policy!(success_policy)
75
74
  policy = @settings.snapshot(mode: :tftp)
76
75
  concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
77
76
  report_directory = policy.backup_directory if report_directory.equal?(DEFAULT_SETTING)
@@ -121,15 +120,13 @@ module Net
121
120
  log_directory = policy.log_directory
122
121
  FileUtils.mkdir_p(log_directory, mode: 0o700) if log_directory && !plan.ready.empty?
123
122
  callback_errors = worker.run(plan.ready, outcomes: outcomes, on_start: on_start, on_result: on_result,
124
- on_error: ->(device, error) { outcome(device, :failed, error: error) }) do |device|
123
+ on_error: ->(device, error) { build_outcome(device, :failed, error: error) }) do |device|
125
124
  yield device, log_directory
126
125
  end
127
126
  batch = Batch.new(mode: mode, outcomes: outcomes.freeze, started_at: started_at,
128
127
  finished_at: Time.now.utc, callback_errors: callback_errors,
129
128
  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
129
+ batch = Report.new(batch, policy: reporting, duration_ms: ((monotonic - started) * 1000).round)
133
130
  save_report(batch, directory: report_directory)
134
131
  end
135
132
 
@@ -145,16 +142,12 @@ module Net
145
142
  return batch unless @result_store
146
143
 
147
144
  batch.with(report_location: @result_store.write(batch, directory: directory))
148
- rescue Operations::PrivateFile::WriteError => error
145
+ rescue Storage::PrivateFile::WriteError => error
149
146
  # 报告也可能已经原子替换;保留已知位置,不将持久性失败误写成完全没有产物。
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)
147
+ location = error.receipt.path if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed?
148
+ batch.with_report_error(error, location: location)
154
149
  rescue StandardError => error
155
- return batch.with_report_error(error) if batch.instance_of?(Report)
156
-
157
- batch.with(report_error: error.class.name)
150
+ batch.with_report_error(error)
158
151
  end
159
152
 
160
153
  # 确认传入计划属于当前备份模式。
@@ -165,7 +158,7 @@ module Net
165
158
  end
166
159
 
167
160
  # 构造包含错误类型的单台设备结果。
168
- def outcome(device, status, backup: nil, error: nil)
161
+ def build_outcome(device, status, backup: nil, error: nil)
169
162
  Outcome.new(device: device, status: status, backup: backup,
170
163
  error_code: error.respond_to?(:code) ? error.code : nil,
171
164
  error_type: error&.class&.name, diagnostic: Diagnostic.from(error, backup: backup))
@@ -174,7 +167,7 @@ module Net
174
167
  # 执行单台设备的 TFTP 导出。
175
168
  def tftp_backup_one(device, server, source_files, log_directory, vrfs, policy)
176
169
  run_one(device, log_directory, policy, success_status: :reported_uploaded,
177
- close_error_status: :reported_with_error, backup_class: TftpBackup) do |connector|
170
+ close_error_status: :reported_with_error, backup_class: TftpReceipt) do |connector|
178
171
  source_file = source_files.fetch(device.vendor, nil)
179
172
  transfer_settings = { host: server, path: device.tftp_filename, source_file: source_file }
180
173
  transfer_settings[:vrf] = vrfs.fetch(device.vendor) if vrfs.key?(device.vendor)
@@ -183,27 +176,21 @@ module Net
183
176
  end
184
177
 
185
178
  # 执行单台设备的本地配置采集。
186
- def backup_one(device, directory, log_directory, policy, saved_config)
179
+ def backup_one(device, directory, log_directory, policy)
187
180
  destination = File.join(directory, device.backup_filename)
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) }
181
+ Storage::BackupLock.synchronize(destination, host: device.host) do |path_lock|
182
+ Storage::SafeFile.open(destination, missing: true) { |_file, _stat| nil }
183
+ run_one(device, log_directory, policy, success_status: :backed_up,
184
+ close_error_status: :saved_with_error, backup_class: Backup) do |connector|
185
+ path_lock.with_delegated_lock { connector.backup(path: destination) }
194
186
  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))
200
187
  end
201
188
  end
202
189
 
203
190
  # 隔离单台设备的凭据、连接、操作和关闭异常。
204
191
  def run_one(device, log_directory, policy, success_status:, close_error_status:, backup_class:)
205
192
  connection_settings = @credentials ? @credentials.call(device) : @settings.device_credentials_for(device)
206
- return outcome(device, :missing_credentials) if connection_settings.nil?
193
+ return build_outcome(device, :missing_credentials) if connection_settings.nil?
207
194
  unless connection_settings.is_a?(Hash) && connection_settings.keys.all?(Symbol) &&
208
195
  connection_settings[:username].is_a?(String) && !connection_settings[:username].empty? &&
209
196
  !connection_settings.key?(:host)
@@ -217,11 +204,11 @@ module Net
217
204
  end
218
205
  connector = @connector_factory.call(device, connection_settings)
219
206
  backup, failure = perform_one(connector, backup_class) { yield connector }
220
- return outcome(device, success_status, backup: backup) unless failure
207
+ return build_outcome(device, success_status, backup: backup) unless failure
221
208
 
222
- outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
209
+ build_outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
223
210
  rescue StandardError => error
224
- outcome(device, :failed, error: error)
211
+ build_outcome(device, :failed, error: error)
225
212
  end
226
213
 
227
214
  # 仅信任库定义且产物类型匹配的提交后错误;普通第三方异常上的 backup 字段不代表完成。
@@ -237,8 +224,8 @@ module Net
237
224
  failure = error
238
225
  if backup_class == Backup && error.instance_of?(BackupPersistenceError) && error.backup.instance_of?(Backup)
239
226
  backup = error.backup
240
- elsif backup_class == TftpBackup && error.instance_of?(TftpCompletionError) && error.transfer.instance_of?(TftpBackup)
241
- backup = error.transfer
227
+ elsif backup_class == TftpReceipt && error.instance_of?(TftpCompletionError) && error.receipt.instance_of?(TftpReceipt)
228
+ backup = error.receipt
242
229
  end
243
230
  ensure
244
231
  begin
@@ -6,7 +6,7 @@ require_relative "batch"
6
6
  module Net
7
7
  module Connector
8
8
  module Netdisco
9
- # v2 显式包装旧 Batch;成功策略与清单覆盖分开,默认 Batch JSON 不增加字段。
9
+ # 唯一的批量报告契约,分开呈现成功策略、清单覆盖和受控诊断。
10
10
  class Report
11
11
  extend Forwardable
12
12
  SUCCESS = %i[backed_up reported_uploaded].freeze
@@ -17,19 +17,14 @@ module Net
17
17
  attr_reader :batch, :policy, :duration_ms, :report_diagnostic
18
18
  def_delegators :@batch, *Batch.members, :success?, :status, :counts
19
19
 
20
- # selected 必须使用带策略和覆盖信息的 v2;默认 strict 继续使用旧 schema。
21
- def self.options(policy: :strict, schema: nil)
20
+ def self.validate_policy!(policy)
22
21
  raise ArgumentError, "success_policy must be strict or selected" unless %i[strict selected].include?(policy)
23
22
 
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
23
+ policy
29
24
  end
30
25
 
31
26
  def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil)
32
- self.class.options(policy: policy, schema: 2)
27
+ self.class.validate_policy!(policy)
33
28
  raise ArgumentError, "report requires a Batch" unless batch.instance_of?(Batch)
34
29
  unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
35
30
  raise ArgumentError, "duration_ms must be nonnegative and finite"
@@ -42,7 +37,7 @@ module Net
42
37
  freeze
43
38
  end
44
39
 
45
- # 旧 success?/status 委托给 Batch;新策略绝不把未知状态当作可忽略跳过。
40
+ # 严格完成情况与所选成功策略分别保留,未知状态不能当作可忽略跳过。
46
41
  def policy_success?
47
42
  return batch.success? if policy == :strict
48
43
  return false unless callback_errors.empty? && report_error.nil?
@@ -62,17 +57,17 @@ module Net
62
57
  policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic)
63
58
  end
64
59
 
65
- # 复用旧业务数据,但重新白名单化所有诊断字段,包括手工构造的旧 Batch。
60
+ # 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
66
61
  def summary
67
- legacy = batch.summary
68
- legacy.merge(
62
+ data = batch_summary
63
+ data.merge(
69
64
  schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
70
65
  coverage: { complete: !outcomes.empty? && outcomes.all? { |item| ATTEMPTED.include?(item.status) },
71
66
  attempted: outcomes.count { |item| ATTEMPTED.include?(item.status) },
72
67
  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),
68
+ devices: device_summaries(data.fetch(:devices)),
69
+ callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
70
+ report_location: report_location, report_error: ErrorMetadata.type(report_error),
76
71
  report_diagnostic: report_diagnostic&.to_h
77
72
  )
78
73
  end
@@ -81,6 +76,32 @@ module Net
81
76
 
82
77
  private
83
78
 
79
+ # 生成可写入报告及供命令行展示的结构化摘要。
80
+ def batch_summary
81
+ {
82
+ mode: mode, status: status, started_at: started_at.iso8601, finished_at: finished_at.iso8601,
83
+ total: outcomes.size,
84
+ succeeded: outcomes.count(&:success?),
85
+ partial: outcomes.count(&:partial?),
86
+ failed: outcomes.count { |item| item.status == :failed },
87
+ skipped: outcomes.count(&:skipped?),
88
+ counts: counts,
89
+ callback_errors: callback_errors,
90
+ devices: outcomes.map do |item|
91
+ { host: item.device.host || item.device.source_ip, name: item.device.name,
92
+ vendor: item.device.vendor,
93
+ status: item.status, path: item.backup&.path,
94
+ bytes: item.backup.is_a?(Backup) ? item.backup.bytes : nil,
95
+ sha256: item.backup.is_a?(Backup) ? item.backup.sha256 : nil,
96
+ change: item.backup.is_a?(Backup) ? item.backup.change : nil,
97
+ previous_sha256: item.backup.is_a?(Backup) ? item.backup.previous_sha256 : nil,
98
+ started_at: item.started_at&.iso8601, finished_at: item.finished_at&.iso8601,
99
+ duration_ms: item.duration_ms,
100
+ error_code: item.error_code, error_type: item.error_type }
101
+ end
102
+ }
103
+ end
104
+
84
105
  def device_summaries(entries)
85
106
  entries.zip(outcomes).map do |entry, item|
86
107
  diagnostic = item.diagnostic || Diagnostic.new(error_code: item.error_code, error_type: item.error_type)
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require "securerandom"
6
- require_relative "../operations/private_file"
6
+ require_relative "../storage/private_file"
7
7
 
8
8
  module Net
9
9
  module Connector
@@ -17,7 +17,7 @@ module Net
17
17
  FileUtils.mkdir_p(destination, mode: 0o700)
18
18
  filename = "netdisco-#{batch.mode}-#{batch.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
19
19
  path = File.join(destination, filename)
20
- Operations::PrivateFile.write(path, JSON.pretty_generate(batch.summary))
20
+ Storage::PrivateFile.write(path, JSON.pretty_generate(batch.summary)).path
21
21
  end
22
22
  end
23
23
 
@@ -164,12 +164,6 @@ module Net
164
164
  options.freeze
165
165
  end
166
166
 
167
- # 兼容原入口;Fleet 传入本批策略,独立调用则读取当前策略和当前秘密。
168
- def credentials_for(device, policy: snapshot(mode: :backup))
169
- credentials = device_credentials_for(device)
170
- policy.connection_options(device.vendor).merge(credentials) if credentials
171
- end
172
-
173
167
  def device_credentials_for(device)
174
168
  prefix = "NET_CONNECTOR_#{device.vendor.to_s.upcase}_"
175
169
  username = secret_value(prefix + "USERNAME") || secret_value("NET_CONNECTOR_DEVICE_USERNAME")
@@ -7,7 +7,7 @@ module Net
7
7
  module Connector
8
8
  class BackupBusy < Error; end
9
9
 
10
- module Operations
10
+ module Storage
11
11
  # 路径锁在设备采集之前取得;锁文件长期保留,避免等待者落在已经删除的 inode 上。
12
12
  class BackupLock
13
13
  STORAGE_KEY = :net_connector_backup_delegation
@@ -61,7 +61,7 @@ module Net
61
61
  end
62
62
 
63
63
  # 仅持有实际 flock 的同一 Fiber 可授权一次内层采集,避免把路径锁变成任意可重入锁。
64
- def delegate
64
+ def with_delegated_lock
65
65
  raise ThreadError, "backup path delegation requires lock ownership" unless @owner == [Process.pid, Fiber.current]
66
66
 
67
67
  previous = Thread.current[STORAGE_KEY]
@@ -4,7 +4,7 @@ require "tempfile"
4
4
 
5
5
  module Net
6
6
  module Connector
7
- module Operations
7
+ module Storage
8
8
  # 文件同步和目录项同步分别记录;原子可见不等于断电后仍能恢复新目录项。
9
9
  module PrivateFile
10
10
  Receipt = Data.define(:path, :state, :phase) do
@@ -62,13 +62,8 @@ module Net
62
62
  [WriteError, PersistenceError, DirectorySyncUnsupported].any? { |type| error.instance_of?(type) }
63
63
  end
64
64
 
65
- # 保留原路径返回值;失败时可从异常 receipt 判断目录项是否已经替换。
65
+ # 统一返回写入回执;失败时从异常 receipt 判断目录项是否已经替换。
66
66
  def self.write(path, contents)
67
- write_receipt(path, contents)
68
- path
69
- end
70
-
71
- def self.write_receipt(path, contents)
72
67
  raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
73
68
 
74
69
  receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
@@ -4,7 +4,7 @@ require "digest"
4
4
 
5
5
  module Net
6
6
  module Connector
7
- module Operations
7
+ module Storage
8
8
  # 对同一 FD 做类型检查和读取;NOFOLLOW 只保护最后一个路径分量,目录由调用方保护。
9
9
  module SafeFile
10
10
  Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
@@ -4,11 +4,10 @@ require "fileutils"
4
4
  require "ipaddr"
5
5
  require_relative "private_file"
6
6
  require_relative "safe_file"
7
- require_relative "saved_config/legacy_index"
8
7
 
9
8
  module Net
10
9
  module Connector
11
- module Operations
10
+ module Storage
12
11
  # 直接导出已有本地配置,无需访问 Netdisco。
13
12
  class SavedConfig
14
13
  # 只用规范化的管理地址标识配置,避免设备改名改变备份身份。
@@ -20,16 +19,14 @@ module Net
20
19
  raise ArgumentError, "host must be an IP address"
21
20
  end
22
21
 
23
- # 默认每次实时查找;批次显式 indexed 时共享一份惰性的旧命名文件快照。
24
- def initialize(directory:, indexed: false)
22
+ # 只读取规范设备地址对应的文件,不维护第二套历史文件命名。
23
+ def initialize(directory:)
25
24
  raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
26
- raise ArgumentError, "indexed must be true or false" unless [true, false].include?(indexed)
27
25
 
28
26
  @directory = File.expand_path(directory).freeze
29
- @legacy_index = LegacyIndex.new(@directory) if indexed
30
27
  end
31
28
 
32
- # 优先查找固定文件名;仅在不存在时兼容唯一的旧命名文件。
29
+ # 从同一已验证文件描述符取得路径或内容。
33
30
  def find(host, required: true)
34
31
  with_file(host, required: required) { |path, _file, _stat| path }
35
32
  end
@@ -43,34 +40,12 @@ module Net
43
40
  with_file(host) { |_path, file, _stat| file.read }
44
41
  end
45
42
 
46
- def resolve_path(host, required:)
47
- filename = self.class.filename(host)
48
- path = File.join(@directory, filename)
49
- unless File.exist?(path) || File.symlink?(path)
50
- entries = if @legacy_index
51
- @legacy_index.candidates(filename)
52
- else
53
- Dir.children(@directory).select { |name| name.end_with?("-#{filename}") }
54
- end
55
- return nil if entries.empty? && !required
56
-
57
- raise ArgumentError, "no saved configuration for #{host}" if entries.empty?
58
- raise ArgumentError, "multiple saved configurations for #{host}" if entries.size > 1
59
-
60
- path = @legacy_index ? entries.first : File.join(@directory, entries.first)
61
- end
62
- path
63
- end
64
-
65
43
  def with_file(host, required: true)
66
- path = resolve_path(host, required: required)
67
- return nil unless path
68
- return @legacy_index.open(path) { |name, file, stat| yield name, file, stat } if path.is_a?(LegacyIndex::Entry)
69
-
70
- SafeFile.open(path) { |file, stat| yield path, file, stat }
44
+ path = File.join(@directory, self.class.filename(host))
45
+ SafeFile.open(path, missing: !required) { |file, stat| yield path, file, stat }
71
46
  end
72
47
 
73
- private :resolve_path, :with_file
48
+ private :with_file
74
49
 
75
50
  # 将已保存配置写入目标文件或标准输出。
76
51
  def export(host:, output: nil, io: $stdout)
@@ -81,7 +56,7 @@ module Net
81
56
  destination = File.expand_path(output)
82
57
  directory = File.dirname(destination)
83
58
  FileUtils.mkdir_p(directory, mode: 0o700)
84
- PrivateFile.write(destination, contents)
59
+ PrivateFile.write(destination, contents).path
85
60
  else
86
61
  io.binmode if io.respond_to?(:binmode)
87
62
  io.write(contents)
@@ -91,9 +66,9 @@ module Net
91
66
 
92
67
  # 使用指定模板解析已有配置,无需访问设备或 Netdisco。
93
68
  def parse(host:, template:, template_dir: nil)
94
- require_relative "parse_output"
69
+ require_relative "../textfsm"
95
70
 
96
- parser = ParseOutput.new(template_dir: template_dir)
71
+ parser = TextFSM.new(template_dir: template_dir)
97
72
  parser.call(read(host), template: template, host: host)
98
73
  end
99
74
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 文件存储不依赖设备连接;备份、离线导出和批量报告共享这些原语。
6
+ module Storage
7
+ autoload :BackupLock, File.expand_path("storage/backup_lock", __dir__)
8
+ autoload :PrivateFile, File.expand_path("storage/private_file", __dir__)
9
+ autoload :SafeFile, File.expand_path("storage/safe_file", __dir__)
10
+ autoload :SavedConfig, File.expand_path("storage/saved_config", __dir__)
11
+ end
12
+
13
+ autoload :BackupBusy, File.expand_path("storage/backup_lock", __dir__)
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "engine/errors"
4
+ require_relative "engine/terminal_text"
5
+
6
+ module Net
7
+ module Connector
8
+ # 将设备命令回显或配置文本按 TextFSM 模板转换为结构化记录。
9
+ class TextFSM
10
+ # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
11
+ module Capability
12
+ # 执行命令并按厂商、命令或显式 TextFSM 模板返回结构化记录。
13
+ def parse_command(command, template: nil, template_dir: nil)
14
+ parser = TextFSM.new(template_dir: template_dir)
15
+ parser.call(execute_command(command).value!, template: template, vendor: vendor, command: command, host: host)
16
+ end
17
+
18
+ # 采集运行配置并使用指定 TextFSM 模板提取结构化记录。
19
+ def parse_config(template:, template_dir: nil)
20
+ parser = TextFSM.new(template_dir: template_dir)
21
+ parser.call(running_config.value!, template: template, host: host)
22
+ end
23
+ end
24
+
25
+ DEFAULT_TEMPLATE_DIR = File.expand_path("templates", __dir__).freeze
26
+
27
+ # 配置模板目录和 Netmiko 风格的命令索引文件。
28
+ def initialize(template_dir: nil, index: "index")
29
+ template_dir ||= DEFAULT_TEMPLATE_DIR
30
+ raise ArgumentError, "template_dir must be a nonempty String" unless template_dir.is_a?(String) && !template_dir.empty?
31
+ raise ArgumentError, "index must be a nonempty String" unless index.is_a?(String) && !index.empty?
32
+
33
+ @template_dir = File.expand_path(template_dir)
34
+ @index = index
35
+ end
36
+
37
+ # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
38
+ def call(text, template: nil, vendor: nil, command: nil, host: nil)
39
+ require "textfsm"
40
+
41
+ text = TerminalText.render(text, host: host)
42
+ if template
43
+ raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
44
+
45
+ ::TextFSM::Parser.from_file(File.expand_path(template, @template_dir)).parse_hashes(text)
46
+ else
47
+ raise ArgumentError, "vendor and command are required for template lookup" unless vendor && command.is_a?(String) && !command.empty?
48
+
49
+ attributes = { "Vendor" => vendor.to_s, "Command" => command }
50
+ table = ::TextFSM::CliTable.new(index: @index, template_dir: @template_dir)
51
+ unless table.index.match(attributes)
52
+ raise ParsingError.new("no TextFSM template matches this vendor and command",
53
+ code: :template_missing, host: host, phase: :parse)
54
+ end
55
+ table.parse(text, attributes: attributes).to_hashes
56
+ end
57
+ rescue ::TextFSM::IndexError
58
+ raise ParsingError.new("TextFSM template index is invalid",
59
+ code: :template_invalid, host: host, phase: :parse), cause: nil
60
+ rescue ::TextFSM::TemplateError
61
+ raise ParsingError.new("TextFSM template is invalid",
62
+ code: :template_invalid, host: host, phase: :parse), cause: nil
63
+ rescue ::TextFSM::ParseError
64
+ raise ParsingError.new("TextFSM could not parse the device output",
65
+ code: :parse_failed, host: host, phase: :parse), cause: nil
66
+ rescue IOError, SystemCallError
67
+ raise ParsingError.new("unable to read TextFSM template",
68
+ code: :template_unreadable, host: host, phase: :parse), cause: nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/tftp/strategy"
3
+ require_relative "../../device/tftp/strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module CiscoIos
8
- class TftpBackup < Operations::Tftp::Strategy
8
+ class TftpBackup < Tftp::Strategy
9
9
  # 在连接前拒绝当前厂商不支持的参数组合。
10
10
  def validate_options!(_target, source_file:, vrf: nil)
11
11
  raise ArgumentError, "Cisco IOS does not use source_file" unless source_file.nil?
@@ -28,7 +28,7 @@ module Net
28
28
  end
29
29
 
30
30
  # 检查设备回显是否确认 TFTP 传输完成。
31
- def complete?(result)
31
+ def device_reported_complete?(result)
32
32
  completion_lines(result).any? do |line|
33
33
  copied_bytes?(line) || line.match?(/\A\[OK(?:\s*-\s*[1-9]\d*\s+bytes)?\]\z/i)
34
34
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/topology/immediate_strategy"
3
+ require_relative "../../device/topology/immediate_strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module CiscoIos
8
- class Topology < Operations::Topology::ImmediateStrategy
8
+ class Topology < Topology::ImmediateStrategy
9
9
  # IOS 提供 CDP 邻居、配置描述读取和描述变更。
10
10
  def self.supports?(capability)
11
11
  %i[neighbors interface_descriptions interface_description_changes].include?(capability)
@@ -21,10 +21,10 @@ module Net
21
21
  def protocol = :cdp
22
22
 
23
23
  # 进入全局配置视图。
24
- def enter_configuration = "configure terminal"
24
+ def enter_configuration_command = "configure terminal"
25
25
 
26
26
  # 先回到执行视图读回,随后才执行档案中的保存命令。
27
- def leave_configuration = ["end"]
27
+ def leave_configuration_commands = ["end"]
28
28
 
29
29
  def persistence_pattern = /\A(?:\[OK\]|Copy complete\.)\z/i
30
30
 
@@ -40,7 +40,7 @@ module Net
40
40
 
41
41
  # 复用公共接口描述命令,保留本机接口原名。
42
42
  def change_commands(change)
43
- InterfaceDescription.commands(interface: change.interface, description: change.new_description)
43
+ Net::Connector::Topology::InterfaceDescription.commands(interface: change.interface, description: change.new_description)
44
44
  end
45
45
  end
46
46
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/tftp/strategy"
3
+ require_relative "../../device/tftp/strategy"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module CiscoNxos
8
- class TftpBackup < Operations::Tftp::Strategy
8
+ class TftpBackup < Tftp::Strategy
9
9
  # 在连接前拒绝当前厂商不支持的参数组合。
10
10
  def validate_options!(_target, source_file:, **)
11
11
  raise ArgumentError, "Cisco NX-OS does not use source_file" unless source_file.nil?
@@ -27,7 +27,7 @@ module Net
27
27
  end
28
28
 
29
29
  # 检查设备回显是否确认 TFTP 传输完成。
30
- def complete?(result)
30
+ def device_reported_complete?(result)
31
31
  completion_lines(result).any? do |line|
32
32
  line.match?(/\A
33
33
  (?:transfer|copy)(?:\s+operation)?\s+
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../operations/tftp/file_upload"
3
+ require_relative "../../device/tftp/file_upload"
4
4
 
5
5
  module Net
6
6
  module Connector
7
7
  module H3c
8
- class TftpBackup < Operations::Tftp::FileUpload
8
+ class TftpBackup < Tftp::FileUpload
9
9
  # 自动探测选择的是启动配置;显式文件仍不能推断为运行或启动配置。
10
10
  def receipt_metadata(target, source_file:, explicit_source:)
11
11
  super.merge(configuration_kind: explicit_source ? :saved_file : :startup)
12
12
  end
13
13
 
14
14
  # 选取并校验 H3C 启动配置文件路径。
15
- def source_file(value)
15
+ def resolve_source_file(value)
16
16
  return TftpTarget.validate_source_file!(value) if value
17
17
 
18
- output = @device.execute("display startup").value!
18
+ output = @device.execute_command("display startup").value!
19
19
  rendered = TerminalRenderer.render(output)
20
20
  path = rendered[/^\s*Next main startup saved-configuration file:\s*(\S+)/i, 1]
21
21
  path ||= rendered[/^\s*Current startup saved-configuration file:\s*(\S+)/i, 1]