net-connector 0.4.1 → 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 +50 -0
- data/CONTRIBUTING.md +40 -0
- data/README.md +93 -18
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +139 -6
- data/docs/architecture.md +248 -39
- data/lib/net/connector/device/base.rb +28 -101
- 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 +46 -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/device/tftp/receipt.rb +71 -0
- 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/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +11 -8
- data/lib/net/connector/engine/dialogue.rb +59 -35
- data/lib/net/connector/engine/error_metadata.rb +50 -0
- data/lib/net/connector/engine/errors.rb +55 -56
- data/lib/net/connector/engine/execution.rb +44 -10
- 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 +107 -84
- data/lib/net/connector/engine/session.rb +115 -43
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- 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 +25 -33
- data/lib/net/connector/netdisco/cli.rb +71 -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/device.rb +4 -5
- data/lib/net/connector/netdisco/diagnostic.rb +61 -0
- data/lib/net/connector/netdisco/fleet.rb +96 -66
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/report.rb +115 -0
- data/lib/net/connector/netdisco/result_store.rb +2 -2
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +188 -85
- data/lib/net/connector/netdisco/worker.rb +26 -17
- data/lib/net/connector/storage/backup_lock.rb +116 -0
- data/lib/net/connector/storage/private_file.rb +109 -0
- data/lib/net/connector/storage/safe_file.rb +62 -0
- data/lib/net/connector/{operations → storage}/saved_config.rb +25 -20
- 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 +12 -5
- data/lib/net/connector/vendor/cisco_ios/topology.rb +12 -6
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +11 -4
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +9 -4
- data/lib/net/connector/vendor/h3c/topology.rb +11 -6
- data/lib/net/connector/vendor/h3c.rb +3 -3
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +15 -6
- data/lib/net/connector/vendor/hillstone/topology.rb +11 -6
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +9 -3
- data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +17 -8
- data/lib/net/connector/vendor/palo_alto/topology.rb +9 -7
- data/lib/net/connector/vendor/radware/tftp_backup.rb +12 -5
- data/lib/net/connector/vendor/radware/topology.rb +3 -2
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +1 -6
- metadata +54 -38
- 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 -49
- data/lib/net/connector/operations/parse_output.rb +0 -60
- data/lib/net/connector/operations/private_file.rb +0 -25
- 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/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 -42
- 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 -120
- 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/palo_alto.rb +0 -11
- data/lib/net/connector/operations/topology/radware.rb +0 -11
- data/lib/net/connector/operations/topology/strategy.rb +0 -67
- data/lib/net/connector/operations/topology.rb +0 -193
- data/lib/net/connector/operations.rb +0 -20
|
@@ -46,7 +46,7 @@ module Net
|
|
|
46
46
|
end
|
|
47
47
|
# Profile 使用宽、高;PTY 使用行数、列数。
|
|
48
48
|
@channel.slave.winsize = @terminal_size.reverse if @terminal_size
|
|
49
|
-
@channel.log_output = method(:
|
|
49
|
+
@channel.log_output = method(:write_log_output)
|
|
50
50
|
@channel.spawn(*argv)
|
|
51
51
|
self
|
|
52
52
|
rescue Exception # rubocop:disable Lint/RescueException -- A half-open PTY must be released on interrupts.
|
|
@@ -104,7 +104,7 @@ module Net
|
|
|
104
104
|
private
|
|
105
105
|
|
|
106
106
|
# 检查缓冲上限并把设备输出写入当前日志目标。
|
|
107
|
-
def
|
|
107
|
+
def write_log_output(bytes)
|
|
108
108
|
if @channel.buffer.bytesize > configuration.max_output_bytes
|
|
109
109
|
raise OutputLimitExceeded.new("transport buffer exceeded max_output_bytes",
|
|
110
110
|
phase: :read, output: @channel.buffer.dup), cause: nil
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "diagnostic"
|
|
4
5
|
|
|
5
6
|
module Net
|
|
6
7
|
module Connector
|
|
7
8
|
module Netdisco
|
|
8
9
|
# 单台设备的最终结果,保留状态、配置产物和错误类型。
|
|
9
10
|
Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
|
|
10
|
-
:started_at, :finished_at) do
|
|
11
|
+
:started_at, :finished_at, :duration_ms, :diagnostic) do
|
|
11
12
|
# 创建结果,允许计划阶段尚无执行时间。
|
|
12
|
-
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil
|
|
13
|
-
|
|
13
|
+
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil,
|
|
14
|
+
duration_ms: nil, diagnostic: nil)
|
|
15
|
+
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
16
|
+
raise ArgumentError, "duration_ms must be nonnegative and finite"
|
|
17
|
+
end
|
|
18
|
+
raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
|
|
19
|
+
|
|
20
|
+
super(device: device, status: status, backup: backup, error_code: error_code, error_type: error_type,
|
|
21
|
+
started_at: started_at, finished_at: finished_at, duration_ms: duration_ms, diagnostic: diagnostic)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 修改诊断或时刻时释放不再匹配的派生元数据。
|
|
25
|
+
def with(**attributes)
|
|
26
|
+
return self if attributes.empty?
|
|
27
|
+
|
|
28
|
+
values = to_h
|
|
29
|
+
values[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
|
|
30
|
+
values[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
|
|
31
|
+
self.class.new(**values.merge(attributes))
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# 判断设备是否完成配置保存或上报上传成功。
|
|
@@ -21,11 +39,6 @@ module Net
|
|
|
21
39
|
|
|
22
40
|
# 判断设备是否因为清单或采样规则未执行。
|
|
23
41
|
def skipped? = !success? && !partial? && status != :failed
|
|
24
|
-
|
|
25
|
-
# 计算设备任务的耗时,计划阶段没有时间时返回空值。
|
|
26
|
-
def duration_ms
|
|
27
|
-
((finished_at - started_at) * 1000).round if started_at && finished_at
|
|
28
|
-
end
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
# 汇总一次批量任务的设备结果、回调故障和报告写入状态。
|
|
@@ -40,32 +53,11 @@ module Net
|
|
|
40
53
|
# 空清单与已尝试但未全部成功的批次分别标记。
|
|
41
54
|
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
42
55
|
|
|
43
|
-
#
|
|
44
|
-
def
|
|
45
|
-
{
|
|
46
|
-
mode: mode, status: status, started_at: started_at.iso8601, finished_at: finished_at.iso8601,
|
|
47
|
-
total: outcomes.size,
|
|
48
|
-
succeeded: outcomes.count(&:success?),
|
|
49
|
-
partial: outcomes.count(&:partial?),
|
|
50
|
-
failed: outcomes.count { |item| item.status == :failed },
|
|
51
|
-
skipped: outcomes.count(&:skipped?),
|
|
52
|
-
counts: counts,
|
|
53
|
-
callback_errors: callback_errors,
|
|
54
|
-
devices: outcomes.map do |item|
|
|
55
|
-
{ host: item.device.host || item.device.source_ip, name: item.device.name,
|
|
56
|
-
vendor: item.device.vendor,
|
|
57
|
-
status: item.status, path: item.backup&.path,
|
|
58
|
-
bytes: item.backup.is_a?(Backup) ? item.backup.bytes : nil,
|
|
59
|
-
sha256: item.backup.is_a?(Backup) ? item.backup.sha256 : nil,
|
|
60
|
-
change: item.backup.is_a?(Backup) ? item.backup.change : nil,
|
|
61
|
-
previous_sha256: item.backup.is_a?(Backup) ? item.backup.previous_sha256 : nil,
|
|
62
|
-
started_at: item.started_at&.iso8601, finished_at: item.finished_at&.iso8601,
|
|
63
|
-
duration_ms: item.duration_ms,
|
|
64
|
-
error_code: item.error_code, error_type: item.error_type }
|
|
65
|
-
end
|
|
66
|
-
}
|
|
67
|
-
end
|
|
56
|
+
# 显式从原始执行结果构造报告。
|
|
57
|
+
def build_report(policy: :strict) = Report.new(self, policy: policy)
|
|
68
58
|
end
|
|
59
|
+
|
|
60
|
+
autoload :Report, File.expand_path("report", __dir__)
|
|
69
61
|
end
|
|
70
62
|
end
|
|
71
63
|
end
|
|
@@ -24,51 +24,36 @@ module Net
|
|
|
24
24
|
return 0 if options[:done]
|
|
25
25
|
|
|
26
26
|
validate_options!(options)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
|
|
30
|
-
values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
|
|
31
|
-
settings = Settings.new(env: values)
|
|
27
|
+
mode = options[:export] ? :export : (options[:tftp] ? :tftp : :backup)
|
|
28
|
+
settings = settings_for(options).for_run(mode: mode)
|
|
32
29
|
|
|
33
30
|
if options[:show_config]
|
|
34
31
|
@output.puts JSON.pretty_generate(settings.public_config)
|
|
35
32
|
return 0
|
|
36
33
|
end
|
|
37
34
|
if options[:export]
|
|
38
|
-
|
|
39
|
-
.export(host: options[:export], output: options[:output], io: @output)
|
|
40
|
-
@output.puts JSON.generate(host: options[:export], output: path) if path
|
|
35
|
+
export_config(settings, options)
|
|
41
36
|
return 0
|
|
42
37
|
end
|
|
38
|
+
if options[:tftp] && !options[:plan] && !settings.tftp_server
|
|
39
|
+
raise ArgumentError, "TFTP_HOST is required"
|
|
40
|
+
end
|
|
43
41
|
|
|
44
42
|
fleet = @fleet_factory.call(settings)
|
|
45
|
-
|
|
46
|
-
plan = if options[:tftp]
|
|
47
|
-
fleet.plan_tftp_backup(limit_per_vendor: limit)
|
|
48
|
-
else
|
|
49
|
-
fleet.plan_backup(limit_per_vendor: limit)
|
|
50
|
-
end
|
|
51
|
-
if options[:host]
|
|
52
|
-
host = IPAddr.new(options[:host]).to_s
|
|
53
|
-
raise ArgumentError, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
|
|
54
|
-
end
|
|
43
|
+
plan = backup_plan(fleet, settings, options)
|
|
55
44
|
if options[:plan]
|
|
56
45
|
@output.puts JSON.pretty_generate(plan_summary(plan))
|
|
57
46
|
return 0
|
|
58
47
|
end
|
|
59
48
|
|
|
60
|
-
batch =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
end
|
|
69
|
-
@output.puts JSON.pretty_generate(batch.summary.merge(report_location: batch.report_location,
|
|
70
|
-
report_error: batch.report_error))
|
|
71
|
-
batch.success? ? 0 : 1
|
|
49
|
+
batch = backup(fleet, plan, settings, options)
|
|
50
|
+
summary = batch.summary
|
|
51
|
+
@output.puts JSON.pretty_generate(summary)
|
|
52
|
+
batch.policy_success? ? 0 : 1
|
|
53
|
+
rescue Storage::PrivateFile::WriteError => exception
|
|
54
|
+
message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
|
|
55
|
+
@error.puts "net-connector-backup: #{message}"
|
|
56
|
+
2
|
|
72
57
|
rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
|
|
73
58
|
Psych::Exception, JSON::ParserError, Client::Error => exception
|
|
74
59
|
@error.puts "net-connector-backup: #{exception.message}"
|
|
@@ -80,6 +65,55 @@ module Net
|
|
|
80
65
|
|
|
81
66
|
private
|
|
82
67
|
|
|
68
|
+
# 文件、环境变量、命令行按优先级覆盖,凭据仍只来自环境变量。
|
|
69
|
+
def settings_for(options)
|
|
70
|
+
defaults = options[:config] ? ConfigFile.load(options[:config]) : {}
|
|
71
|
+
values = {}
|
|
72
|
+
values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
|
|
73
|
+
values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
|
|
74
|
+
values["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES"] = options[:max_script_output_bytes].to_s if options[:max_script_output_bytes]
|
|
75
|
+
values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
|
|
76
|
+
values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = options[:limit].to_s if options[:limit]
|
|
77
|
+
values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = nil if options[:all]
|
|
78
|
+
Settings.new(env: @env, defaults: defaults, overrides: values)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 导出已有配置不创建清单或设备连接。
|
|
82
|
+
def export_config(settings, options)
|
|
83
|
+
path = Storage::SavedConfig.new(directory: settings.backup_directory)
|
|
84
|
+
.export(host: options[:export], output: options[:output], io: @output)
|
|
85
|
+
@output.puts JSON.generate(host: options[:export], output: path) if path
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 预览与执行共用计划和主机存在性检查。
|
|
89
|
+
def backup_plan(fleet, settings, options)
|
|
90
|
+
limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
|
|
91
|
+
plan = if options[:tftp]
|
|
92
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit)
|
|
93
|
+
else
|
|
94
|
+
fleet.plan_backup(limit_per_vendor: limit)
|
|
95
|
+
end
|
|
96
|
+
if options[:host]
|
|
97
|
+
host = IPAddr.new(options[:host]).to_s
|
|
98
|
+
raise ArgumentError, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
|
|
99
|
+
end
|
|
100
|
+
plan
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
|
|
104
|
+
def backup(fleet, plan, settings, options)
|
|
105
|
+
reporting = options.slice(:success_policy)
|
|
106
|
+
if options[:tftp]
|
|
107
|
+
fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
|
|
108
|
+
concurrency: settings.concurrency,
|
|
109
|
+
vrfs: settings.tftp_vrfs,
|
|
110
|
+
source_files: settings.tftp_source_files, **reporting)
|
|
111
|
+
else
|
|
112
|
+
fleet.backup_all(plan: plan, directory: settings.backup_directory,
|
|
113
|
+
concurrency: settings.concurrency, **reporting)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
83
117
|
# 解析命令行参数并处理帮助及版本信息。
|
|
84
118
|
def parse_options
|
|
85
119
|
options = { config: @env["NET_CONNECTOR_CONFIG"] }
|
|
@@ -94,10 +128,12 @@ module Net
|
|
|
94
128
|
args.on("--directory PATH", "备份和报告目录") { |value| options[:directory] = value }
|
|
95
129
|
args.on("--host IP", "按管理地址选择一台设备") { |value| options[:host] = value }
|
|
96
130
|
args.on("--concurrency N", Integer, "最大并发设备数") { |value| options[:concurrency] = value }
|
|
131
|
+
args.on("--max-script-output-bytes N", Integer, "每个脚本的累计响应字节上限") { |value| options[:max_script_output_bytes] = value }
|
|
97
132
|
args.on("--limit-per-vendor N", Integer, "每厂商选择 N 台设备(1 至 5)") do |value|
|
|
98
133
|
options[:limit] = value
|
|
99
134
|
end
|
|
100
135
|
args.on("--all", "选择全部就绪设备") { options[:all] = true }
|
|
136
|
+
args.on("--success-policy POLICY", %w[strict selected], "成功判定:strict(默认)或 selected") { |value| options[:success_policy] = value.to_sym }
|
|
101
137
|
args.on("-v", "--version", "显示版本") do
|
|
102
138
|
@output.puts Net::Connector::VERSION
|
|
103
139
|
options[:done] = true
|
|
@@ -115,11 +151,15 @@ module Net
|
|
|
115
151
|
|
|
116
152
|
# 拒绝互斥或缺少依赖的命令行选项。
|
|
117
153
|
def validate_options!(options)
|
|
154
|
+
Report.validate_policy!(options.fetch(:success_policy, :strict))
|
|
155
|
+
Planner.validate_limit!(options[:limit])
|
|
156
|
+
Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
|
|
118
157
|
modes = [:show_config, :plan, :export].count { |key| options[key] }
|
|
119
158
|
raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
|
|
120
159
|
raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
|
|
121
160
|
raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
|
|
122
|
-
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host]
|
|
161
|
+
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host] ||
|
|
162
|
+
options[:success_policy] || options[:max_script_output_bytes])
|
|
123
163
|
raise ArgumentError, "--export 不能与备份选项同时使用"
|
|
124
164
|
end
|
|
125
165
|
end
|
|
@@ -3,144 +3,277 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "openssl"
|
|
6
|
+
require "timeout"
|
|
6
7
|
require "uri"
|
|
8
|
+
require_relative "inventory_budget"
|
|
7
9
|
|
|
8
10
|
module Net
|
|
9
11
|
module Connector
|
|
10
12
|
module Netdisco
|
|
11
|
-
# 独立于 Rails 的 Netdisco
|
|
13
|
+
# 独立于 Rails 的 Netdisco 设备清单客户端;失败时不交付部分清单。
|
|
12
14
|
class Client
|
|
13
|
-
class Error < StandardError
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
attr_reader :code
|
|
17
|
+
|
|
18
|
+
def initialize(message = nil, code: :client_error)
|
|
19
|
+
@code = code
|
|
20
|
+
super(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
14
23
|
|
|
15
24
|
class QueryRequired < Error; end
|
|
16
25
|
|
|
26
|
+
class InventoryTimeout < Error
|
|
27
|
+
def initialize(_message = nil)
|
|
28
|
+
super("Netdisco inventory exceeded inventory_timeout", code: :inventory_timeout)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
17
32
|
FIELDS = %w[ip name dns vendor os model os_ver serial].freeze
|
|
18
33
|
DEFAULT_MAX_PAGES = 10_000
|
|
34
|
+
DEFAULTS = { page_size: 500, max_pages: DEFAULT_MAX_PAGES, max_response_bytes: 16 * 1024 * 1024,
|
|
35
|
+
max_inventory_bytes: 128 * 1024 * 1024, max_devices: 100_000,
|
|
36
|
+
inventory_timeout: 300, allow_insecure_http: true }.freeze
|
|
19
37
|
|
|
20
|
-
#
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(present?(api_key) && username.nil? && password.nil?)
|
|
29
|
-
raise ArgumentError, "provide either username and password or api_key"
|
|
38
|
+
# 此纯校验入口也供 Settings 使用,避免配置预览与实际执行使用两套范围规则。
|
|
39
|
+
def self.options(**values)
|
|
40
|
+
raise ArgumentError, "unknown Netdisco client options" unless (values.keys - DEFAULTS.keys).empty?
|
|
41
|
+
|
|
42
|
+
options = DEFAULTS.merge(values)
|
|
43
|
+
%i[page_size max_pages max_response_bytes max_inventory_bytes max_devices].each do |name|
|
|
44
|
+
value = options.fetch(name)
|
|
45
|
+
raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
30
46
|
end
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
duration = options.fetch(:inventory_timeout)
|
|
48
|
+
unless duration.is_a?(Numeric) && duration.real? && duration.finite? && duration.positive? && duration.to_f.finite?
|
|
49
|
+
raise ArgumentError, "inventory_timeout must be a positive finite number"
|
|
33
50
|
end
|
|
34
|
-
unless
|
|
35
|
-
raise ArgumentError, "
|
|
51
|
+
unless [true, false].include?(options.fetch(:allow_insecure_http))
|
|
52
|
+
raise ArgumentError, "allow_insecure_http must be true or false"
|
|
36
53
|
end
|
|
54
|
+
options.freeze
|
|
55
|
+
end
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
def self.validate_url!(url, allow_insecure_http: true)
|
|
58
|
+
uri = URI.parse(url.to_s)
|
|
59
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
60
|
+
raise ArgumentError, "url must be an HTTP(S) base URL without credentials, query or fragment"
|
|
61
|
+
end
|
|
62
|
+
if uri.scheme == "http" && !allow_insecure_http
|
|
63
|
+
raise ArgumentError, "HTTP requires allow_insecure_http: true; use HTTPS"
|
|
64
|
+
end
|
|
65
|
+
uri
|
|
41
66
|
rescue URI::InvalidURIError
|
|
42
67
|
raise ArgumentError, "url is invalid", cause: nil
|
|
43
68
|
end
|
|
44
69
|
|
|
45
|
-
#
|
|
70
|
+
# requester 仍接收 (uri, request),其自行阻塞的时间由注入方负责。
|
|
71
|
+
def initialize(url:, username: nil, password: nil, api_key: nil, requester: nil, **options)
|
|
72
|
+
@options = self.class.options(**options)
|
|
73
|
+
@url = self.class.validate_url!(url, allow_insecure_http: @options.fetch(:allow_insecure_http)).freeze
|
|
74
|
+
validate_credentials!(username, password, api_key)
|
|
75
|
+
raise ArgumentError, "requester must respond to call" if requester && !requester.respond_to?(:call)
|
|
76
|
+
|
|
77
|
+
@username, @password, @api_key = [username, password, api_key].map { |value| value&.dup&.freeze }
|
|
78
|
+
@requester = requester
|
|
79
|
+
end
|
|
80
|
+
|
|
46
81
|
def devices
|
|
47
|
-
|
|
82
|
+
budget = InventoryBudget.new(@options, clock: method(:monotonic))
|
|
83
|
+
token = @api_key || authenticate(budget)
|
|
84
|
+
rows = inventory(token, budget)
|
|
85
|
+
budget.remaining
|
|
86
|
+
rows
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def inventory(token, budget)
|
|
48
94
|
rows = []
|
|
49
95
|
seen_ips = {}
|
|
50
|
-
|
|
51
|
-
@max_pages.times do
|
|
96
|
+
@options.fetch(:max_pages).times do |index|
|
|
52
97
|
uri = endpoint("api/v1/search/device")
|
|
53
|
-
uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @page_size,
|
|
54
|
-
|
|
55
|
-
|
|
98
|
+
uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @options.fetch(:page_size),
|
|
99
|
+
offset: index * @options.fetch(:page_size))
|
|
100
|
+
page = request_json(uri, authorized_get(uri, token), budget, allow_empty: true, query_fallback: true)
|
|
101
|
+
validate_rows!(page, budget)
|
|
56
102
|
return rows.uniq if page.empty?
|
|
57
103
|
|
|
58
|
-
|
|
59
|
-
|
|
104
|
+
if page.all? { |row| seen_ips[row.fetch("ip")] }
|
|
105
|
+
raise Error.new("Netdisco pagination did not advance", code: :pagination_stalled), cause: nil
|
|
106
|
+
end
|
|
60
107
|
page.each { |row| seen_ips[row.fetch("ip")] = true }
|
|
61
108
|
rows.concat(page)
|
|
62
|
-
offset += @page_size
|
|
63
109
|
end
|
|
64
|
-
raise Error
|
|
110
|
+
raise Error.new("Netdisco inventory exceeded max_pages", code: :max_pages), cause: nil
|
|
65
111
|
rescue QueryRequired
|
|
66
|
-
|
|
112
|
+
query_required_devices(token, budget)
|
|
67
113
|
end
|
|
68
114
|
|
|
69
|
-
|
|
70
|
-
def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
# 在服务要求查询参数时使用兼容查询获取清单。
|
|
75
|
-
def legacy_devices(token)
|
|
115
|
+
def query_required_devices(token, budget)
|
|
76
116
|
uri = endpoint("api/v1/search/device")
|
|
77
117
|
uri.query = URI.encode_www_form(q: "%", seeallcolumns: true)
|
|
78
|
-
rows = request_json(uri, authorized_get(uri, token), allow_empty: true)
|
|
79
|
-
validate_rows!(rows)
|
|
118
|
+
rows = request_json(uri, authorized_get(uri, token), budget, allow_empty: true)
|
|
119
|
+
validate_rows!(rows, budget)
|
|
80
120
|
rows.map { |row| row.slice(*FIELDS) }.uniq
|
|
81
121
|
end
|
|
82
122
|
|
|
83
|
-
|
|
84
|
-
def authenticate
|
|
123
|
+
def authenticate(budget)
|
|
85
124
|
uri = endpoint("login")
|
|
86
125
|
request = ::Net::HTTP::Post.new(uri)
|
|
87
126
|
request.basic_auth(@username, @password)
|
|
88
|
-
data = request_json(uri, request)
|
|
127
|
+
data = request_json(uri, request, budget)
|
|
89
128
|
token = data["api_key"] if data.is_a?(Hash)
|
|
90
|
-
raise Error, "Netdisco login did not return an API key" unless present?(token) && !token.match?(/[\r\n]/)
|
|
129
|
+
raise Error, "Netdisco login did not return an API key" unless present?(token) && !token.match?(/[\r\n\x00]/)
|
|
91
130
|
|
|
92
131
|
token
|
|
93
132
|
end
|
|
94
133
|
|
|
95
|
-
# 为清单查询建立带 API 密钥的请求。
|
|
96
134
|
def authorized_get(uri, token)
|
|
97
|
-
|
|
98
|
-
request["Authorization"] = "Apikey #{token}"
|
|
99
|
-
request
|
|
135
|
+
::Net::HTTP::Get.new(uri).tap { |request| request["Authorization"] = "Apikey #{token}" }
|
|
100
136
|
end
|
|
101
137
|
|
|
102
|
-
# 以服务根地址构建 API 地址。
|
|
103
138
|
def endpoint(path)
|
|
104
|
-
uri =
|
|
105
|
-
uri.path = "#{uri.path.chomp("/")}/#{path}"
|
|
106
|
-
uri
|
|
139
|
+
@url.dup.tap { |uri| uri.path = "#{uri.path.chomp("/")}/#{path}" }
|
|
107
140
|
end
|
|
108
141
|
|
|
109
|
-
#
|
|
110
|
-
def request_json(uri, request, allow_empty: false)
|
|
142
|
+
# 只保留稳定错误码及类型,不将服务正文、URI 或底层异常 cause 带进报告。
|
|
143
|
+
def request_json(uri, request, budget, allow_empty: false, query_fallback: false)
|
|
111
144
|
request["Accept"] = "application/json"
|
|
112
|
-
|
|
113
|
-
body =
|
|
114
|
-
if response.code == "400"
|
|
145
|
+
budget.remaining
|
|
146
|
+
response, body = request_body(uri, request, budget)
|
|
147
|
+
if query_fallback && response.code == "400"
|
|
115
148
|
parsed = JSON.parse(body)
|
|
116
149
|
raise QueryRequired, "Netdisco requires a search query" if parsed.is_a?(Hash) && parsed["error"] == "Missing query"
|
|
117
150
|
end
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
unless response.is_a?(::Net::HTTPSuccess)
|
|
152
|
+
status = response.code.to_s.match?(/\A\d{3}\z/) ? response.code : "invalid"
|
|
153
|
+
raise Error.new("Netdisco request failed (HTTP #{status})", code: :request_failed), cause: nil
|
|
154
|
+
end
|
|
155
|
+
data = allow_empty && body.strip.empty? ? [] : JSON.parse(body)
|
|
156
|
+
budget.remaining
|
|
157
|
+
data
|
|
158
|
+
rescue Error => error
|
|
159
|
+
raise error, cause: nil
|
|
122
160
|
rescue JSON::ParserError
|
|
123
|
-
raise Error
|
|
124
|
-
rescue
|
|
125
|
-
|
|
161
|
+
raise Error.new("Netdisco returned invalid JSON", code: :invalid_json), cause: nil
|
|
162
|
+
rescue StandardError => error
|
|
163
|
+
budget.remaining
|
|
164
|
+
raise Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def request_body(uri, request, budget)
|
|
168
|
+
return default_request(uri, request, budget) unless @requester
|
|
169
|
+
|
|
170
|
+
response = injected_request(uri, request, budget)
|
|
171
|
+
budget.remaining
|
|
172
|
+
body = response.body.to_s
|
|
173
|
+
budget.consume_bytes(body.bytesize, response_bytes: 0)
|
|
174
|
+
[response, body]
|
|
126
175
|
end
|
|
127
176
|
|
|
128
|
-
#
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
177
|
+
# 回调自己抛出的 Client::Error 也可能带正文,不能当成内部安全错误直接透传。
|
|
178
|
+
def injected_request(uri, request, budget)
|
|
179
|
+
@requester.call(uri, request)
|
|
180
|
+
rescue StandardError => error
|
|
181
|
+
budget.remaining
|
|
182
|
+
raise Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
|
|
132
183
|
end
|
|
133
184
|
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
185
|
+
# 仅对自有 HTTP 传输施加总期限,覆盖响应头的持续慢速输入;不异步中断用户回调。
|
|
186
|
+
# start 的块负责关闭连接;禁用隐式 GET 重试,所有阶段同时使用剩余的原生超时。
|
|
187
|
+
def default_request(uri, request, budget)
|
|
188
|
+
Timeout.timeout(budget.remaining, InventoryTimeout) do
|
|
189
|
+
::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", max_retries: 0,
|
|
190
|
+
**http_timeouts(budget)) do |http|
|
|
191
|
+
apply_timeouts(http, budget)
|
|
192
|
+
with_http_deadline(http, budget) { read_response(http, request, budget) }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# 异步超时展开栈时,Net::HTTP 的 chunked ensure 可能再次等待读取。
|
|
198
|
+
# 只关闭本次自有连接来解除这类阻塞;结束时唤醒并 join 观察线程,不遗留后台任务。
|
|
199
|
+
def with_http_deadline(http, budget)
|
|
200
|
+
mutex = Mutex.new
|
|
201
|
+
changed = ConditionVariable.new
|
|
202
|
+
finished = false
|
|
203
|
+
timer = Thread.new do
|
|
204
|
+
begin
|
|
205
|
+
mutex.synchronize { changed.wait(mutex, budget.remaining) until finished }
|
|
206
|
+
rescue InventoryTimeout
|
|
207
|
+
begin
|
|
208
|
+
http.finish
|
|
209
|
+
rescue IOError, SystemCallError
|
|
210
|
+
# 主请求可能已先关闭;外层 start 仍负责最终清理,保持原始预算错误。
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
yield
|
|
216
|
+
ensure
|
|
217
|
+
mutex&.synchronize { finished = true; changed.broadcast }
|
|
218
|
+
timer&.join
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def read_response(http, request, budget)
|
|
222
|
+
body = "".b
|
|
223
|
+
failure = nil
|
|
224
|
+
response = http.request(request) do |reply|
|
|
225
|
+
apply_timeouts(http, budget)
|
|
226
|
+
reply.read_body do |chunk|
|
|
227
|
+
budget.consume_bytes(chunk.bytesize, response_bytes: body.bytesize)
|
|
228
|
+
body << chunk
|
|
229
|
+
apply_timeouts(http, budget)
|
|
230
|
+
rescue Error => error
|
|
231
|
+
failure = error
|
|
232
|
+
# Net::HTTP 的 chunked 收尾仍会读取分隔符;先关闭连接才能立即停止超额下载。
|
|
233
|
+
http.finish
|
|
234
|
+
raise
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
budget.remaining
|
|
238
|
+
[response, body]
|
|
239
|
+
rescue StandardError => error
|
|
240
|
+
raise failure || error, cause: nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def http_timeouts(budget)
|
|
244
|
+
remaining = budget.remaining
|
|
245
|
+
{ open_timeout: [10, remaining].min, read_timeout: [60, remaining].min, write_timeout: [10, remaining].min }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def apply_timeouts(http, budget)
|
|
249
|
+
http_timeouts(budget).each { |name, value| http.public_send(:"#{name}=", value) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_rows!(rows, budget)
|
|
253
|
+
unless rows.is_a?(Array)
|
|
254
|
+
raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
|
|
255
|
+
end
|
|
256
|
+
budget.consume_devices(rows.size)
|
|
257
|
+
valid = rows.all? do |row|
|
|
137
258
|
row.is_a?(Hash) && present?(row["ip"]) &&
|
|
138
259
|
FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
139
260
|
end
|
|
140
|
-
|
|
261
|
+
unless valid
|
|
262
|
+
raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def validate_credentials!(username, password, api_key)
|
|
267
|
+
unless (api_key.nil? && present?(username) && present?(password)) ||
|
|
268
|
+
(present?(api_key) && username.nil? && password.nil?)
|
|
269
|
+
raise ArgumentError, "provide either username and password or api_key"
|
|
270
|
+
end
|
|
271
|
+
if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n\x00]/) }
|
|
272
|
+
raise ArgumentError, "credentials must be single-line strings"
|
|
273
|
+
end
|
|
141
274
|
end
|
|
142
275
|
|
|
143
|
-
|
|
276
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
277
|
def present?(value) = value.is_a?(String) && !value.strip.empty?
|
|
145
278
|
end
|
|
146
279
|
end
|