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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/CONTRIBUTING.md +40 -0
- data/README.md +61 -9
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -6
- data/docs/architecture.md +184 -7
- data/lib/net/connector/device/base.rb +11 -4
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/engine/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +10 -7
- data/lib/net/connector/engine/dialogue.rb +52 -28
- data/lib/net/connector/engine/errors.rb +40 -55
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +11 -9
- data/lib/net/connector/engine/session.rb +38 -7
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- data/lib/net/connector/netdisco/batch.rb +31 -3
- data/lib/net/connector/netdisco/cli.rb +73 -31
- data/lib/net/connector/netdisco/client.rb +206 -73
- data/lib/net/connector/netdisco/config_file.rb +26 -4
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +103 -60
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/report.rb +94 -0
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +194 -85
- data/lib/net/connector/netdisco/worker.rb +26 -17
- data/lib/net/connector/operations/backup_lock.rb +116 -0
- data/lib/net/connector/operations/local_backup.rb +49 -9
- data/lib/net/connector/operations/parse_output.rb +20 -3
- data/lib/net/connector/operations/private_file.rb +94 -5
- data/lib/net/connector/operations/safe_file.rb +62 -0
- data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
- data/lib/net/connector/operations/saved_config.rb +40 -10
- data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
- data/lib/net/connector/operations/tftp_backup.rb +78 -15
- data/lib/net/connector/operations/tftp_receipt.rb +73 -0
- data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
- data/lib/net/connector/operations/topology/strategy.rb +20 -0
- data/lib/net/connector/operations/topology.rb +97 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
- data/lib/net/connector/vendor/h3c/topology.rb +9 -4
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
- data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
- data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +1 -1
- 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
|
|
16
|
-
@rules = rules
|
|
17
|
-
@credentials = credentials
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
build_plan(:tftp, limit_per_vendor, @settings.snapshot(mode: :tftp))
|
|
44
40
|
end
|
|
45
41
|
|
|
46
42
|
# 先拉取完整清单,再并发采集并保存设备配置。
|
|
47
|
-
def backup_all(directory:
|
|
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 ||=
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
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:
|
|
70
|
-
|
|
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
|
-
|
|
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 ||=
|
|
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 =
|
|
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.
|
|
118
|
-
end
|
|
155
|
+
return batch.with_report_error(error) if batch.instance_of?(Report)
|
|
119
156
|
|
|
120
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|