net-connector 0.5.0 → 0.7.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 +58 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +153 -42
- data/docs/VERIFICATION.md +42 -1
- data/docs/architecture.md +20 -1
- data/examples/backup.yml +40 -0
- data/examples/inventory_sql.yml +16 -0
- data/lib/net/connector/device/base.rb +30 -38
- data/lib/net/connector/device/profile/builder.rb +8 -8
- data/lib/net/connector/device/profile.rb +2 -2
- data/lib/net/connector/device/running_config.rb +2 -2
- data/lib/net/connector/device/tftp/receipt.rb +12 -3
- data/lib/net/connector/device/tftp.rb +7 -7
- data/lib/net/connector/device/topology.rb +4 -4
- data/lib/net/connector/engine/authentication.rb +1 -1
- data/lib/net/connector/engine/command.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +5 -2
- data/lib/net/connector/engine/dialogue.rb +4 -4
- data/lib/net/connector/engine/execution.rb +27 -16
- data/lib/net/connector/engine/known_hosts.rb +112 -0
- data/lib/net/connector/engine/log.rb +10 -7
- data/lib/net/connector/engine/session.rb +117 -128
- data/lib/net/connector/engine/transport.rb +32 -7
- data/lib/net/connector/netdisco/backup_run.rb +132 -0
- data/lib/net/connector/netdisco/batch.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +139 -0
- data/lib/net/connector/netdisco/cli.rb +20 -24
- data/lib/net/connector/netdisco/client.rb +8 -7
- data/lib/net/connector/netdisco/config_file.rb +26 -22
- data/lib/net/connector/netdisco/connection.rb +38 -0
- data/lib/net/connector/netdisco/database_client.rb +197 -0
- data/lib/net/connector/netdisco/device.rb +11 -1
- data/lib/net/connector/netdisco/fleet.rb +68 -47
- data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
- data/lib/net/connector/netdisco/plan.rb +14 -9
- data/lib/net/connector/netdisco/planner.rb +18 -14
- data/lib/net/connector/netdisco/progress.rb +253 -0
- data/lib/net/connector/netdisco/report/files.rb +41 -0
- data/lib/net/connector/netdisco/report/text.rb +45 -0
- data/lib/net/connector/netdisco/report.rb +53 -31
- data/lib/net/connector/netdisco/result_store.rb +3 -1
- data/lib/net/connector/netdisco/rules.rb +10 -2
- data/lib/net/connector/netdisco/settings.rb +136 -63
- data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
- data/lib/net/connector/netdisco/tftp_history.rb +16 -0
- data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
- data/lib/net/connector/netdisco/worker.rb +8 -3
- data/lib/net/connector/netdisco.rb +11 -0
- data/lib/net/connector/storage/backup_lock.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +29 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c.rb +2 -1
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/running_config.rb +19 -0
- data/lib/net/connector/vendor/radware.rb +4 -3
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +2 -2
- metadata +57 -104
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "text"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
class Report
|
|
10
|
+
# 示例与批次入口共享唯一摘要结构;保存结果先于终端成功提示。
|
|
11
|
+
class Files
|
|
12
|
+
def self.write(report, directory:, plan:, concurrency:)
|
|
13
|
+
destination = File.join(directory, "summary.json")
|
|
14
|
+
finalized = report.with(report_location: destination)
|
|
15
|
+
Text.write(directory: directory, report: finalized, plan: plan, concurrency: concurrency)
|
|
16
|
+
Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
|
|
17
|
+
finalized
|
|
18
|
+
rescue StandardError => error
|
|
19
|
+
location = if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == destination
|
|
20
|
+
destination
|
|
21
|
+
end
|
|
22
|
+
failed = report.with_report_error(error, location: location)
|
|
23
|
+
begin
|
|
24
|
+
saved = failed.with(report_location: destination)
|
|
25
|
+
Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
|
|
26
|
+
failed = saved
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# JSON 仍无法保存时,返回带有已知提交位置的失败报告。
|
|
29
|
+
end
|
|
30
|
+
begin
|
|
31
|
+
Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
# 文本输出不能覆盖首次保存失败。
|
|
34
|
+
end
|
|
35
|
+
failed
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../storage/private_file"
|
|
4
|
+
require_relative "../report"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
class Report
|
|
10
|
+
# 将批次结果写为人类可读的文本摘要。
|
|
11
|
+
class Text
|
|
12
|
+
def self.write(directory:, report:, plan:, concurrency:)
|
|
13
|
+
summary = report.summary
|
|
14
|
+
failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
15
|
+
groups = failed.group_by { |item| item.error_code || item.status }
|
|
16
|
+
lines = ["网络设备备份报告", "=" * 72,
|
|
17
|
+
"开始时间:#{report.started_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
|
|
18
|
+
"结束时间:#{report.finished_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
|
|
19
|
+
"清单:#{plan.inventory.size} 台;本次:#{plan.ready.size} 台;并发:#{concurrency}",
|
|
20
|
+
"成功:#{summary[:succeeded]};部分成功:#{summary[:partial]};失败:#{summary[:failed]};跳过:#{summary[:skipped]}",
|
|
21
|
+
"耗时:#{(report.duration_ms.to_f / 1000).round(1)} 秒;批次状态:#{summary[:status]}",
|
|
22
|
+
"成功策略:#{report.policy};策略通过:#{report.policy_success?}",
|
|
23
|
+
"回调异常:#{report.callback_errors.size};报告错误:#{report.report_error || "无"}",
|
|
24
|
+
"状态计数:#{report.counts.map { |key, value| "#{key}=#{value}" }.join(",")}",
|
|
25
|
+
"未完成分类:#{groups.empty? ? "无" : groups.map { |key, items| "#{key}=#{items.size}" }.join(",")}",
|
|
26
|
+
"-" * 72, "未完成设备:"]
|
|
27
|
+
lines.concat(failure_lines(failed))
|
|
28
|
+
lines << "无" if failed.empty?
|
|
29
|
+
lines << "TFTP 成功仅表示设备报告上传完成;服务器文件核验结果请查看 summary.json。" if report.mode == :tftp
|
|
30
|
+
lines += ["=" * 72, "备份目录:#{directory}", "完整明细:#{File.join(directory, "summary.json")}"]
|
|
31
|
+
Net::Connector::Storage::PrivateFile.write(File.join(directory, "summary.txt"), lines.join("\n") + "\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.failure_lines(failed)
|
|
35
|
+
failed.map do |item|
|
|
36
|
+
label = item.device.name.to_s.gsub(/[[:cntrl:]]/, " ")
|
|
37
|
+
"#{item.device.host} | #{label} | #{item.status} | #{item.error_code || "无错误码"} | #{item.duration_ms} ms"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :failure_lines
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -9,21 +9,21 @@ module Net
|
|
|
9
9
|
# 唯一的批量报告契约,分开呈现成功策略、清单覆盖和受控诊断。
|
|
10
10
|
class Report
|
|
11
11
|
extend Forwardable
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
private_constant :
|
|
12
|
+
SUCCESS_STATUSES = %i[backed_up reported_uploaded].freeze
|
|
13
|
+
ATTEMPTED_STATUSES = [*SUCCESS_STATUSES, :failed, :saved_with_error, :reported_with_error].freeze
|
|
14
|
+
IGNORABLE_STATUSES = %i[filtered sample_limit].freeze
|
|
15
|
+
private_constant :SUCCESS_STATUSES, :ATTEMPTED_STATUSES, :IGNORABLE_STATUSES
|
|
16
16
|
|
|
17
|
-
attr_reader :batch, :policy, :duration_ms, :report_diagnostic
|
|
17
|
+
attr_reader :batch, :policy, :duration_ms, :report_diagnostic, :log_directory
|
|
18
18
|
def_delegators :@batch, *Batch.members, :success?, :status, :counts
|
|
19
19
|
|
|
20
20
|
def self.validate_policy!(policy)
|
|
21
|
-
raise ArgumentError, "success_policy must be strict or
|
|
21
|
+
raise ArgumentError, "success_policy must be strict, selected or verified" unless %i[strict selected verified].include?(policy)
|
|
22
22
|
|
|
23
23
|
policy
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil)
|
|
26
|
+
def initialize(batch, policy: :strict, duration_ms: nil, report_diagnostic: nil, log_directory: nil)
|
|
27
27
|
self.class.validate_policy!(policy)
|
|
28
28
|
raise ArgumentError, "report requires a Batch" unless batch.instance_of?(Batch)
|
|
29
29
|
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
@@ -33,6 +33,8 @@ module Net
|
|
|
33
33
|
raise ArgumentError, "report_diagnostic must be a Diagnostic"
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
raise ArgumentError, "verified policy requires TFTP mode" if policy == :verified && batch.mode != :tftp
|
|
37
|
+
@log_directory = log_directory&.dup&.freeze
|
|
36
38
|
@batch, @policy, @duration_ms, @report_diagnostic = batch, policy, duration_ms, report_diagnostic
|
|
37
39
|
freeze
|
|
38
40
|
end
|
|
@@ -42,32 +44,36 @@ module Net
|
|
|
42
44
|
return batch.success? if policy == :strict
|
|
43
45
|
return false unless callback_errors.empty? && report_error.nil?
|
|
44
46
|
|
|
45
|
-
outcomes.any? { |
|
|
46
|
-
|
|
47
|
+
return false if policy == :verified && outcomes.any? { |outcome| outcome.success? && !server_verified?(outcome) }
|
|
48
|
+
|
|
49
|
+
outcomes.any? { |outcome| SUCCESS_STATUSES.include?(outcome.status) } &&
|
|
50
|
+
outcomes.all? { |outcome| SUCCESS_STATUSES.include?(outcome.status) || IGNORABLE_STATUSES.include?(outcome.status) }
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def with(**attributes)
|
|
50
54
|
self.class.new(batch.with(**attributes), policy: policy, duration_ms: duration_ms,
|
|
51
|
-
report_diagnostic: report_diagnostic)
|
|
55
|
+
report_diagnostic: report_diagnostic, log_directory: log_directory)
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def with_report_error(error, location: nil)
|
|
55
59
|
diagnostic = Diagnostic.from(error, phase: :report)
|
|
56
60
|
self.class.new(batch.with(report_location: location, report_error: diagnostic.error_type),
|
|
57
|
-
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic)
|
|
61
|
+
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic, log_directory: log_directory)
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
# 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
|
|
61
65
|
def summary
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
batch_data = batch_summary
|
|
67
|
+
batch_data.merge(
|
|
64
68
|
schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
|
|
65
|
-
coverage: { complete: !outcomes.empty? && outcomes.all? { |
|
|
66
|
-
attempted: outcomes.count { |
|
|
67
|
-
skipped: outcomes.count { |
|
|
68
|
-
devices: device_summaries(
|
|
69
|
+
coverage: { complete: !outcomes.empty? && outcomes.all? { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
70
|
+
attempted: outcomes.count { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
71
|
+
skipped: outcomes.count { |outcome| !ATTEMPTED_STATUSES.include?(outcome.status) } },
|
|
72
|
+
devices: device_summaries(batch_data.fetch(:devices)),
|
|
69
73
|
callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
|
|
70
74
|
report_location: report_location, report_error: ErrorMetadata.type(report_error),
|
|
75
|
+
verification: mode == :tftp ? { verified: outcomes.count { |outcome| server_verified?(outcome) },
|
|
76
|
+
unverified: outcomes.count { |outcome| outcome.backup.is_a?(TftpReceipt) && !server_verified?(outcome) } } : nil,
|
|
71
77
|
report_diagnostic: report_diagnostic&.to_h
|
|
72
78
|
)
|
|
73
79
|
end
|
|
@@ -83,28 +89,44 @@ module Net
|
|
|
83
89
|
total: outcomes.size,
|
|
84
90
|
succeeded: outcomes.count(&:success?),
|
|
85
91
|
partial: outcomes.count(&:partial?),
|
|
86
|
-
failed: outcomes.count { |
|
|
92
|
+
failed: outcomes.count { |outcome| outcome.status == :failed },
|
|
87
93
|
skipped: outcomes.count(&:skipped?),
|
|
88
94
|
counts: counts,
|
|
89
95
|
callback_errors: callback_errors,
|
|
90
|
-
devices: outcomes.map do |
|
|
91
|
-
{ host:
|
|
92
|
-
vendor:
|
|
93
|
-
status:
|
|
94
|
-
bytes:
|
|
95
|
-
sha256:
|
|
96
|
-
change:
|
|
97
|
-
previous_sha256:
|
|
98
|
-
started_at:
|
|
99
|
-
duration_ms:
|
|
100
|
-
error_code:
|
|
96
|
+
devices: outcomes.map do |outcome|
|
|
97
|
+
{ host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
98
|
+
vendor: outcome.device.vendor,
|
|
99
|
+
status: outcome.status, path: outcome.backup&.path,
|
|
100
|
+
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
101
|
+
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
102
|
+
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
103
|
+
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
104
|
+
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
105
|
+
duration_ms: outcome.duration_ms,
|
|
106
|
+
error_code: outcome.error_code, error_type: outcome.error_type }
|
|
101
107
|
end
|
|
102
108
|
}
|
|
103
109
|
end
|
|
104
110
|
|
|
111
|
+
def server_verified?(outcome)
|
|
112
|
+
outcome.backup.is_a?(TftpReceipt) && outcome.backup.verification == :server_verified
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tftp_summary(outcome)
|
|
116
|
+
receipt = outcome.backup if outcome.backup.is_a?(TftpReceipt)
|
|
117
|
+
{ remote_path: receipt&.path, server: receipt&.server,
|
|
118
|
+
configuration_kind: receipt&.configuration_kind, source_file: receipt&.source_file,
|
|
119
|
+
format: receipt&.format, verification: receipt&.verification,
|
|
120
|
+
server_file_verified: server_verified?(outcome), server_sha256: receipt&.server_sha256,
|
|
121
|
+
local_file: receipt&.local_path, server_archive_file: receipt&.archive_path,
|
|
122
|
+
bytes: receipt&.server_bytes, sha256: receipt&.server_sha256 }
|
|
123
|
+
end
|
|
124
|
+
|
|
105
125
|
def device_summaries(entries)
|
|
106
|
-
entries.zip(outcomes).map do |entry,
|
|
107
|
-
diagnostic =
|
|
126
|
+
entries.zip(outcomes).map do |entry, outcome|
|
|
127
|
+
diagnostic = outcome.diagnostic || Diagnostic.new(error_code: outcome.error_code, error_type: outcome.error_type)
|
|
128
|
+
entry = entry.merge(tftp_summary(outcome)) if mode == :tftp
|
|
129
|
+
entry[:session_log] = File.join(log_directory, "#{outcome.device.host.tr(":", "_")}.log") if log_directory && outcome.device.host && outcome.started_at
|
|
108
130
|
entry.merge(error_code: diagnostic.error_code, error_type: diagnostic.error_type,
|
|
109
131
|
diagnostic: diagnostic.to_h.except(:error_code, :error_type))
|
|
110
132
|
end
|
|
@@ -10,7 +10,7 @@ module Net
|
|
|
10
10
|
module Netdisco
|
|
11
11
|
module ResultStore
|
|
12
12
|
# 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
|
|
13
|
-
class
|
|
13
|
+
class Json
|
|
14
14
|
# 以临时文件和重命名原子写入私有 JSON 报告。
|
|
15
15
|
def write(batch, directory:)
|
|
16
16
|
destination = File.expand_path(directory)
|
|
@@ -21,6 +21,8 @@ module Net
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
Text = Json # Compatibility with the former name of the JSON writer.
|
|
25
|
+
|
|
24
26
|
# 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
|
|
25
27
|
# 仓储须实现 create!,本项目不负责数据库表结构。
|
|
26
28
|
class Database
|
|
@@ -109,11 +109,14 @@ module Net
|
|
|
109
109
|
return match[:connector] if match
|
|
110
110
|
return @vendor_overrides[label] if @vendor_overrides.key?(label)
|
|
111
111
|
|
|
112
|
+
# 厂商标签可能陈旧;仅在操作系统和带品牌型号同时确认时纠正。
|
|
113
|
+
return h3c_connector(os, model) if os == "comware" && model.match?(/\Ah3c\s+\S/)
|
|
114
|
+
|
|
112
115
|
case label
|
|
113
|
-
when /\bh3c\b/ then (os
|
|
116
|
+
when /\bh3c\b/ then h3c_connector(os, model)
|
|
114
117
|
when /\bcisco\b/
|
|
115
118
|
return :cisco_nxos if os.match?(/nx\s*os|nexus/) || model.match?(/\bnexus\b|\bn\s*[3579]k\b/)
|
|
116
|
-
|
|
119
|
+
:cisco_ios if os.match?(/\bios\b|\bios\s*xe\b/)
|
|
117
120
|
when /\bradware\b/ then :radware
|
|
118
121
|
when /palo\s*alto|paloalto/ then :palo_alto
|
|
119
122
|
when /\bhuawei\b/ then :huawei
|
|
@@ -132,6 +135,11 @@ module Net
|
|
|
132
135
|
|
|
133
136
|
private
|
|
134
137
|
|
|
138
|
+
def h3c_connector(os, model)
|
|
139
|
+
wireless = os.match?(/wireless|wlan/) || model.match?(/\A(?:h3c\s+)?(?:wx|ac)\s*\d/)
|
|
140
|
+
wireless ? :h3c_wireless : :h3c
|
|
141
|
+
end
|
|
142
|
+
|
|
135
143
|
# 校验并标准化管理地址列表。
|
|
136
144
|
def string_list(values)
|
|
137
145
|
raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
|
|
@@ -10,6 +10,8 @@ module Net
|
|
|
10
10
|
DEFAULT_BACKUP_DIRECTORY = "backups"
|
|
11
11
|
DEFAULT_CONCURRENCY = 4
|
|
12
12
|
MAX_CONCURRENCY = 50
|
|
13
|
+
ENVIRONMENT_KEYS = %w[NETDISCO_URL NC_BACKUP_DIRECTORY NC_CONCURRENCY NC_SAMPLE_PER_VENDOR
|
|
14
|
+
NC_PROTOCOL NC_KNOWN_HOSTS NC_HOST_KEY_POLICY NC_LOG_DIRECTORY NC_LOG_LEVEL TFTP_HOST].freeze
|
|
13
15
|
DEFAULT_PAGE_SIZE = Client::DEFAULTS.fetch(:page_size)
|
|
14
16
|
UNSET = Object.new.freeze
|
|
15
17
|
private_constant :UNSET
|
|
@@ -18,17 +20,29 @@ module Net
|
|
|
18
20
|
new(env: env, defaults: ConfigFile.load(path))
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
def self.from_env(env: ENV)
|
|
24
|
+
path = env["NC_CONFIG"]
|
|
25
|
+
path && !path.empty? ? from_file(path, env: env) : new(env: env)
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
|
|
22
29
|
@env, @defaults, @overrides = env, defaults, overrides
|
|
23
30
|
@policy, @fixed = policy, fixed
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
# 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
|
|
34
|
+
def with_overrides(values)
|
|
35
|
+
self.class.new(env: @env, defaults: @defaults, overrides: @overrides.merge(values))
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
# 快照自身不保留 ENV、凭据 resolver 或原 Settings 的引用,不能携带秘密进入摘要。
|
|
27
39
|
def snapshot(mode: :backup)
|
|
28
40
|
return @policy.validate!(mode: mode) if @policy
|
|
29
41
|
return validate!(mode: mode) if @fixed
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
validate_environment! unless mode == :export
|
|
44
|
+
|
|
45
|
+
keys = mode == :export ? ["NC_BACKUP_DIRECTORY"] : non_secret_keys
|
|
32
46
|
values = keys.each_with_object({}) do |key, result|
|
|
33
47
|
item = raw(key, UNSET)
|
|
34
48
|
result[key.freeze] = item.is_a?(String) ? item.dup.freeze : item unless item.equal?(UNSET)
|
|
@@ -61,11 +75,25 @@ module Net
|
|
|
61
75
|
self
|
|
62
76
|
end
|
|
63
77
|
|
|
64
|
-
def client(policy: snapshot(mode: :inventory))
|
|
78
|
+
def client(policy: snapshot(mode: :inventory), credentials: nil)
|
|
79
|
+
if credentials && (!credentials.is_a?(Hash) || credentials.keys.sort != %i[password username] ||
|
|
80
|
+
!credentials.values.all? { |value| present?(value) })
|
|
81
|
+
raise ArgumentError, "inventory credentials require nonempty username and password"
|
|
82
|
+
end
|
|
65
83
|
options = policy.client_options
|
|
84
|
+
if policy.inventory_source == :postgres
|
|
85
|
+
connection = DatabaseClient::CONNECTION_ENV.each_with_object({}) do |(key, name), values|
|
|
86
|
+
item = secret_value(name)
|
|
87
|
+
values[key] = item if item
|
|
88
|
+
end
|
|
89
|
+
connection.merge!(user: credentials[:username], password: credentials[:password]) if credentials
|
|
90
|
+
return DatabaseClient.new(connection_options: connection, **options)
|
|
91
|
+
end
|
|
66
92
|
raise ArgumentError, "NETDISCO_URL is required" unless options.fetch(:url)
|
|
67
93
|
|
|
68
|
-
authentication = if
|
|
94
|
+
authentication = if credentials
|
|
95
|
+
credentials
|
|
96
|
+
elsif (token = secret_value("NETDISCO_API_KEY"))
|
|
69
97
|
{ api_key: token }
|
|
70
98
|
else
|
|
71
99
|
{ username: required_secret("NETDISCO_USERNAME"), password: required_secret("NETDISCO_PASSWORD") }
|
|
@@ -73,49 +101,46 @@ module Net
|
|
|
73
101
|
Client.new(**options, **authentication)
|
|
74
102
|
end
|
|
75
103
|
|
|
76
|
-
|
|
104
|
+
def inventory_source
|
|
105
|
+
source = value("NETDISCO_SOURCE") || "http"
|
|
106
|
+
raise ArgumentError, "NETDISCO_SOURCE must be http or postgres" unless %w[http postgres].include?(source)
|
|
107
|
+
|
|
108
|
+
source.to_sym
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 原生客户端和 --show-config 共享预算校验;只验证所选来源的查询设置。
|
|
77
112
|
def client_options
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
when :inventory_timeout then number(key, default)
|
|
82
|
-
when :allow_insecure_http then boolean(key, default)
|
|
83
|
-
else integer(key, default)
|
|
84
|
-
end
|
|
85
|
-
[name, parsed]
|
|
86
|
-
end
|
|
87
|
-
options = Client.options(**options)
|
|
88
|
-
url = value("NETDISCO_URL")
|
|
89
|
-
Client.validate_url!(url, allow_insecure_http: options.fetch(:allow_insecure_http)) if url
|
|
90
|
-
options.merge(url: url).freeze
|
|
113
|
+
defaults = inventory_source == :postgres ? DatabaseClient::DEFAULTS : Client::DEFAULTS
|
|
114
|
+
options = client_budget_options(defaults)
|
|
115
|
+
inventory_source == :postgres ? postgres_options(options) : http_options(options)
|
|
91
116
|
end
|
|
92
117
|
|
|
93
118
|
def rules
|
|
94
|
-
Rules.new(include_hosts: list("
|
|
95
|
-
exclude_hosts: list("
|
|
96
|
-
include_vendors: list("
|
|
97
|
-
vendor_overrides: json("
|
|
98
|
-
host_overrides: json("
|
|
99
|
-
mappings: json("
|
|
119
|
+
Rules.new(include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
120
|
+
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
121
|
+
include_vendors: list("NC_INCLUDE_VENDORS"),
|
|
122
|
+
vendor_overrides: json("NC_VENDOR_OVERRIDES", {}),
|
|
123
|
+
host_overrides: json("NC_HOST_OVERRIDES", {}),
|
|
124
|
+
mappings: json("NC_DEVICE_RULES", []))
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
def backup_directory
|
|
103
|
-
path = raw("
|
|
104
|
-
raise ArgumentError, "
|
|
128
|
+
path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
|
|
129
|
+
raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
|
|
105
130
|
|
|
106
131
|
File.expand_path(path)
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
def concurrency
|
|
110
|
-
count = integer("
|
|
111
|
-
raise ArgumentError, "
|
|
135
|
+
count = integer("NC_CONCURRENCY", DEFAULT_CONCURRENCY)
|
|
136
|
+
raise ArgumentError, "NC_CONCURRENCY must be at most #{MAX_CONCURRENCY}" if count > MAX_CONCURRENCY
|
|
112
137
|
|
|
113
138
|
count
|
|
114
139
|
end
|
|
115
140
|
|
|
116
141
|
def limit_per_vendor(tftp: false)
|
|
117
|
-
count = if configured?("
|
|
118
|
-
integer("
|
|
142
|
+
count = if configured?("NC_SAMPLE_PER_VENDOR")
|
|
143
|
+
integer("NC_SAMPLE_PER_VENDOR", 5) unless raw("NC_SAMPLE_PER_VENDOR").nil?
|
|
119
144
|
elsif tftp
|
|
120
145
|
5
|
|
121
146
|
end
|
|
@@ -124,17 +149,17 @@ module Net
|
|
|
124
149
|
end
|
|
125
150
|
|
|
126
151
|
def tftp_source_files
|
|
127
|
-
files = { huawei: raw("
|
|
152
|
+
files = { huawei: raw("NC_HUAWEI_TFTP_SOURCE_FILE", "flash:/startup.cfg") }
|
|
128
153
|
%i[h3c h3c_wireless].each do |vendor|
|
|
129
|
-
key = "
|
|
154
|
+
key = "NC_#{vendor.to_s.upcase}_TFTP_SOURCE_FILE"
|
|
130
155
|
files[vendor] = raw(key) if configured?(key)
|
|
131
156
|
end
|
|
132
157
|
files
|
|
133
158
|
end
|
|
134
159
|
|
|
135
160
|
def tftp_vrfs
|
|
136
|
-
mapping = json("
|
|
137
|
-
raise ArgumentError, "
|
|
161
|
+
mapping = json("NC_TFTP_VRFS", {})
|
|
162
|
+
raise ArgumentError, "NC_TFTP_VRFS must be a JSON object" unless mapping.is_a?(Hash)
|
|
138
163
|
|
|
139
164
|
self.class.validate_vrfs!(mapping.transform_keys(&:to_sym))
|
|
140
165
|
end
|
|
@@ -152,75 +177,122 @@ module Net
|
|
|
152
177
|
def tftp_server = value("TFTP_HOST")
|
|
153
178
|
|
|
154
179
|
def connection_options(vendor = nil)
|
|
155
|
-
prefix = "NET_CONNECTOR_#{vendor.to_s.upcase}_"
|
|
156
180
|
configuration = Configuration.new(
|
|
157
|
-
protocol:
|
|
158
|
-
log_level: value("
|
|
159
|
-
host_key_policy: value("
|
|
160
|
-
max_script_output_bytes: optional_integer("
|
|
181
|
+
protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
|
|
182
|
+
log_level: value("NC_LOG_LEVEL") || "info", known_hosts: value("NC_KNOWN_HOSTS"),
|
|
183
|
+
host_key_policy: value("NC_HOST_KEY_POLICY") || "strict",
|
|
184
|
+
max_script_output_bytes: optional_integer("NC_MAX_SCRIPT_OUTPUT_BYTES")
|
|
161
185
|
)
|
|
162
186
|
options = %i[protocol log_level known_hosts host_key_policy].to_h { |key| [key, configuration.public_send(key)] }
|
|
163
187
|
options[:max_script_output_bytes] = configuration.max_script_output_bytes if configuration.max_script_output_bytes
|
|
164
188
|
options.freeze
|
|
165
189
|
end
|
|
166
190
|
|
|
191
|
+
def vendor_protocols
|
|
192
|
+
values = json("NC_VENDOR_PROTOCOLS", {})
|
|
193
|
+
unless values.is_a?(Hash) && values.all? { |vendor, protocol| Net::Connector.vendors.map(&:to_s).include?(vendor) && %w[ssh telnet].include?(protocol) }
|
|
194
|
+
raise ArgumentError, "ssh.vendor_protocols must map supported vendors to ssh or telnet"
|
|
195
|
+
end
|
|
196
|
+
values
|
|
197
|
+
end
|
|
198
|
+
|
|
167
199
|
def device_credentials_for(device)
|
|
168
|
-
prefix = "
|
|
169
|
-
username = secret_value(prefix + "USERNAME") || secret_value("
|
|
200
|
+
prefix = "NC_#{device.vendor.to_s.upcase}_"
|
|
201
|
+
username = secret_value(prefix + "USERNAME") || secret_value("NC_DEVICE_USERNAME")
|
|
170
202
|
return nil unless username
|
|
171
203
|
|
|
172
204
|
{ username: username,
|
|
173
|
-
password: secret_value(prefix + "PASSWORD") || secret_value("
|
|
174
|
-
enable_password: secret_value(prefix + "ENABLE_PASSWORD") || secret_value("
|
|
205
|
+
password: secret_value(prefix + "PASSWORD") || secret_value("NC_DEVICE_PASSWORD"),
|
|
206
|
+
enable_password: secret_value(prefix + "ENABLE_PASSWORD") || secret_value("NC_ENABLE_PASSWORD") }
|
|
175
207
|
end
|
|
176
208
|
|
|
177
209
|
def log_directory
|
|
178
|
-
path = value("
|
|
210
|
+
path = value("NC_LOG_DIRECTORY")
|
|
179
211
|
File.expand_path(path) if path
|
|
180
212
|
end
|
|
181
213
|
|
|
182
214
|
def public_config
|
|
183
|
-
snapshot(mode: :show_config).
|
|
215
|
+
snapshot(mode: :show_config).config_hash
|
|
184
216
|
end
|
|
185
217
|
|
|
186
218
|
def inspect = "#<#{self.class}>"
|
|
187
219
|
|
|
188
|
-
|
|
220
|
+
protected
|
|
189
221
|
|
|
190
222
|
def config_hash
|
|
191
223
|
{
|
|
192
|
-
netdisco: client_options,
|
|
224
|
+
netdisco: client_options.merge(source: inventory_source),
|
|
193
225
|
backup: { directory: backup_directory, concurrency: concurrency, limit_per_vendor: limit_per_vendor },
|
|
194
|
-
inventory: { include_hosts: list("
|
|
195
|
-
exclude_hosts: list("
|
|
196
|
-
include_vendors: list("
|
|
197
|
-
vendor_overrides: json("
|
|
198
|
-
host_overrides: json("
|
|
199
|
-
device_rules: json("
|
|
226
|
+
inventory: { include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
227
|
+
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
228
|
+
include_vendors: list("NC_INCLUDE_VENDORS"),
|
|
229
|
+
vendor_overrides: json("NC_VENDOR_OVERRIDES", {}),
|
|
230
|
+
host_overrides: json("NC_HOST_OVERRIDES", {}),
|
|
231
|
+
device_rules: json("NC_DEVICE_RULES", []) },
|
|
200
232
|
ssh: connection_options.transform_values { |item| item.is_a?(Symbol) ? item.to_s : item }.merge(
|
|
201
233
|
log_directory: log_directory,
|
|
202
|
-
vendor_protocols:
|
|
203
|
-
protocol = value("NET_CONNECTOR_#{vendor.to_s.upcase}_PROTOCOL")
|
|
204
|
-
[vendor, protocol] if protocol
|
|
205
|
-
end.to_h
|
|
234
|
+
vendor_protocols: vendor_protocols
|
|
206
235
|
),
|
|
207
236
|
tftp: { server: tftp_server, vrfs: tftp_vrfs.transform_keys(&:to_s), source_files: tftp_source_files }
|
|
208
237
|
}
|
|
209
238
|
end
|
|
210
239
|
|
|
211
|
-
def non_secret_keys
|
|
212
|
-
keys = ConfigFile::FIELDS.values.flat_map { |section| section.values.map(&:first) }
|
|
213
|
-
keys + Net::Connector.vendors.map { |vendor| "NET_CONNECTOR_#{vendor.to_s.upcase}_PROTOCOL" }
|
|
214
|
-
end
|
|
215
|
-
|
|
216
240
|
def raw(key, default = nil)
|
|
217
|
-
return @policy.
|
|
241
|
+
return @policy.raw(key, default) if @policy
|
|
218
242
|
return @overrides.fetch(key) if @overrides.key?(key)
|
|
219
|
-
|
|
243
|
+
if @env.key?(key)
|
|
244
|
+
unless @fixed || ENVIRONMENT_KEYS.include?(key)
|
|
245
|
+
raise ArgumentError, "#{key} is no longer an environment setting; use YAML via NC_CONFIG or CLI options"
|
|
246
|
+
end
|
|
247
|
+
return @env.fetch(key)
|
|
248
|
+
end
|
|
220
249
|
|
|
221
250
|
@defaults.fetch(key, default)
|
|
222
251
|
end
|
|
223
252
|
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def client_budget_options(defaults)
|
|
256
|
+
defaults.to_h do |name, default|
|
|
257
|
+
key = "NETDISCO_#{name.to_s.upcase}"
|
|
258
|
+
parsed = case name
|
|
259
|
+
when :inventory_timeout then number(key, default)
|
|
260
|
+
when :allow_insecure_http then boolean(key, default)
|
|
261
|
+
else integer(key, default)
|
|
262
|
+
end
|
|
263
|
+
[name, parsed]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def postgres_options(options)
|
|
268
|
+
query = value("NETDISCO_QUERY") || (raise ArgumentError, "NETDISCO_QUERY is required for postgres")
|
|
269
|
+
DatabaseClient.options(query: query, query_params: json("NETDISCO_QUERY_PARAMS", []), **options)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def http_options(options)
|
|
273
|
+
if value("NETDISCO_QUERY") || value("NETDISCO_QUERY_PARAMS")
|
|
274
|
+
raise ArgumentError, "SQL query settings require NETDISCO_SOURCE=postgres"
|
|
275
|
+
end
|
|
276
|
+
options = Client.options(**options)
|
|
277
|
+
url = value("NETDISCO_URL")
|
|
278
|
+
Client.validate_url!(url, allow_insecure_http: options.fetch(:allow_insecure_http)) if url
|
|
279
|
+
options.merge(url: url).freeze
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def non_secret_keys
|
|
283
|
+
ConfigFile::FIELDS.values.flat_map { |section| section.values.map(&:first) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def validate_environment!
|
|
287
|
+
legacy = @env.keys.find { |key| key.start_with?("NET_CONNECTOR_") }
|
|
288
|
+
raise ArgumentError, "#{legacy} has been renamed to #{legacy.sub("NET_CONNECTOR_", "NC_")}" if legacy
|
|
289
|
+
|
|
290
|
+
removed = non_secret_keys - ENVIRONMENT_KEYS
|
|
291
|
+
removed += Net::Connector.vendors.map { |vendor| "NC_#{vendor.to_s.upcase}_PROTOCOL" }
|
|
292
|
+
key = removed.find { |name| @env.key?(name) }
|
|
293
|
+
raise ArgumentError, "#{key} is no longer an environment setting; use YAML via NC_CONFIG or CLI options" if key
|
|
294
|
+
end
|
|
295
|
+
|
|
224
296
|
def configured?(key) = !raw(key, UNSET).equal?(UNSET)
|
|
225
297
|
|
|
226
298
|
def json(key, default)
|
|
@@ -279,6 +351,7 @@ module Net
|
|
|
279
351
|
end
|
|
280
352
|
|
|
281
353
|
def required_secret(key) = secret_value(key) || (raise ArgumentError, "#{key} is required")
|
|
354
|
+
|
|
282
355
|
def present?(value) = value.is_a?(String) && !value.strip.empty?
|
|
283
356
|
end
|
|
284
357
|
end
|