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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -1
- data/README.md +42 -19
- data/docs/VERIFICATION.md +17 -7
- data/docs/architecture.md +96 -64
- data/lib/net/connector/device/base.rb +27 -107
- data/lib/net/connector/device/local_backup.rb +96 -0
- data/lib/net/connector/device/profile.rb +8 -3
- data/lib/net/connector/device/running_config/strategy.rb +1 -1
- data/lib/net/connector/device/running_config.rb +44 -5
- data/lib/net/connector/device/save_config.rb +21 -0
- data/lib/net/connector/device/tftp/file_upload.rb +53 -0
- data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
- data/lib/net/connector/device/tftp/strategy.rb +67 -0
- data/lib/net/connector/device/tftp/target.rb +63 -0
- data/lib/net/connector/device/tftp.rb +117 -0
- data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
- data/lib/net/connector/device/topology/interface_description.rb +46 -0
- data/lib/net/connector/device/topology/interface_name.rb +58 -0
- data/lib/net/connector/device/topology/strategy.rb +75 -0
- data/lib/net/connector/device/topology.rb +277 -0
- data/lib/net/connector/engine/authentication.rb +1 -1
- data/lib/net/connector/engine/configuration.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +17 -17
- data/lib/net/connector/engine/error_metadata.rb +50 -0
- data/lib/net/connector/engine/errors.rb +15 -1
- data/lib/net/connector/engine/execution.rb +15 -8
- data/lib/net/connector/engine/log/event.rb +66 -0
- data/lib/net/connector/engine/log/formatter.rb +16 -0
- data/lib/net/connector/engine/log/messages.rb +52 -0
- data/lib/net/connector/engine/log/stream.rb +56 -0
- data/lib/net/connector/engine/log.rb +105 -84
- data/lib/net/connector/engine/session.rb +83 -42
- data/lib/net/connector/engine/terminal_text.rb +33 -0
- data/lib/net/connector/engine/transport.rb +2 -2
- data/lib/net/connector/netdisco/batch.rb +9 -45
- data/lib/net/connector/netdisco/cli.rb +7 -9
- data/lib/net/connector/netdisco/client.rb +2 -2
- data/lib/net/connector/netdisco/device.rb +4 -5
- data/lib/net/connector/netdisco/diagnostic.rb +9 -39
- data/lib/net/connector/netdisco/fleet.rb +26 -39
- data/lib/net/connector/netdisco/report.rb +37 -16
- data/lib/net/connector/netdisco/result_store.rb +2 -2
- data/lib/net/connector/netdisco/settings.rb +0 -6
- data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
- data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
- data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
- data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
- data/lib/net/connector/storage.rb +15 -0
- data/lib/net/connector/textfsm.rb +72 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
- data/lib/net/connector/vendor/h3c/topology.rb +5 -5
- data/lib/net/connector/vendor/h3c.rb +3 -3
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
- data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/topology.rb +2 -2
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +1 -6
- metadata +25 -39
- data/lib/net/connector/device/interface_description.rb +0 -44
- data/lib/net/connector/device/interface_name.rb +0 -56
- data/lib/net/connector/engine/base.rb +0 -4
- data/lib/net/connector/engine/log_messages.rb +0 -47
- data/lib/net/connector/engine/profile.rb +0 -4
- data/lib/net/connector/engine.rb +0 -4
- data/lib/net/connector/operations/local_backup.rb +0 -89
- data/lib/net/connector/operations/parse_output.rb +0 -77
- data/lib/net/connector/operations/running_config/cisco.rb +0 -14
- data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
- data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
- data/lib/net/connector/operations/running_config/strategy.rb +0 -3
- data/lib/net/connector/operations/running_config.rb +0 -16
- data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
- data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
- data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
- data/lib/net/connector/operations/tftp/h3c.rb +0 -11
- data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
- data/lib/net/connector/operations/tftp/huawei.rb +0 -11
- data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
- data/lib/net/connector/operations/tftp/radware.rb +0 -11
- data/lib/net/connector/operations/tftp/strategy.rb +0 -75
- data/lib/net/connector/operations/tftp_backup.rb +0 -183
- data/lib/net/connector/operations/topology/cisco.rb +0 -11
- data/lib/net/connector/operations/topology/h3c.rb +0 -11
- data/lib/net/connector/operations/topology/hillstone.rb +0 -11
- data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
- data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
- data/lib/net/connector/operations/topology/radware.rb +0 -11
- data/lib/net/connector/operations/topology/strategy.rb +0 -87
- data/lib/net/connector/operations/topology.rb +0 -261
- 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.
|
|
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
|
|
46
|
-
reporting = Report.
|
|
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
|
|
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
|
|
74
|
-
reporting = Report.
|
|
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) {
|
|
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
|
-
|
|
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
|
|
145
|
+
rescue Storage::PrivateFile::WriteError => error
|
|
149
146
|
# 报告也可能已经原子替换;保留已知位置,不将持久性失败误写成完全没有产物。
|
|
150
|
-
location = error.receipt.path if
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
179
|
+
def backup_one(device, directory, log_directory, policy)
|
|
187
180
|
destination = File.join(directory, device.backup_filename)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
207
|
+
return build_outcome(device, success_status, backup: backup) unless failure
|
|
221
208
|
|
|
222
|
-
|
|
209
|
+
build_outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
|
|
223
210
|
rescue StandardError => error
|
|
224
|
-
|
|
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 ==
|
|
241
|
-
backup = error.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
60
|
+
# 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
|
|
66
61
|
def summary
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
74
|
-
callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type:
|
|
75
|
-
report_location: report_location, 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 "../
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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,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
|
|
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
|
-
#
|
|
24
|
-
def initialize(directory
|
|
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 =
|
|
67
|
-
|
|
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 :
|
|
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 "
|
|
69
|
+
require_relative "../textfsm"
|
|
95
70
|
|
|
96
|
-
parser =
|
|
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 "../../
|
|
3
|
+
require_relative "../../device/tftp/strategy"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
-
class TftpBackup <
|
|
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
|
|
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 "../../
|
|
3
|
+
require_relative "../../device/topology/immediate_strategy"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
-
class Topology <
|
|
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
|
|
24
|
+
def enter_configuration_command = "configure terminal"
|
|
25
25
|
|
|
26
26
|
# 先回到执行视图读回,随后才执行档案中的保存命令。
|
|
27
|
-
def
|
|
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 "../../
|
|
3
|
+
require_relative "../../device/tftp/strategy"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoNxos
|
|
8
|
-
class TftpBackup <
|
|
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
|
|
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 "../../
|
|
3
|
+
require_relative "../../device/tftp/file_upload"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module H3c
|
|
8
|
-
class TftpBackup <
|
|
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
|
|
15
|
+
def resolve_source_file(value)
|
|
16
16
|
return TftpTarget.validate_source_file!(value) if value
|
|
17
17
|
|
|
18
|
-
output = @device.
|
|
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]
|