net-connector 0.4.0 → 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 +110 -68
- data/CONTRIBUTING.md +40 -0
- data/README.md +140 -95
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -5
- data/docs/architecture.md +321 -248
- data/lib/net/connector/device/base.rb +12 -4
- data/lib/net/connector/device/interface_description.rb +3 -0
- data/lib/net/connector/device/profile/builder.rb +236 -0
- data/lib/net/connector/device/profile.rb +2 -227
- data/lib/net/connector/device/running_config/strategy.rb +5 -0
- data/lib/net/connector/device/running_config.rb +7 -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 +53 -28
- data/lib/net/connector/engine/errors.rb +41 -54
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +23 -17
- data/lib/net/connector/engine/session.rb +39 -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 +92 -50
- 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 -8
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +105 -108
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/plan.rb +77 -0
- data/lib/net/connector/netdisco/planner.rb +12 -23
- 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 +43 -27
- 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 +55 -0
- data/lib/net/connector/operations/tftp/strategy.rb +9 -11
- 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 +34 -0
- data/lib/net/connector/operations/topology.rb +98 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
- data/lib/net/connector/vendor/h3c/topology.rb +17 -3
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
- data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
- data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
- data/lib/net/connector/vendor/radware/topology.rb +3 -0
- data/lib/net/connector/version.rb +1 -1
- metadata +60 -28
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
@@ -8,9 +9,29 @@ module Net
|
|
|
8
9
|
# 单台设备的最终结果,保留状态、配置产物和错误类型。
|
|
9
10
|
Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
|
|
10
11
|
:started_at, :finished_at) do
|
|
12
|
+
attr_reader :diagnostic
|
|
13
|
+
|
|
11
14
|
# 创建结果,允许计划阶段尚无执行时间。
|
|
12
|
-
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil
|
|
13
|
-
|
|
15
|
+
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil,
|
|
16
|
+
duration_ms: nil, diagnostic: nil)
|
|
17
|
+
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
18
|
+
raise ArgumentError, "duration_ms must be nonnegative and finite"
|
|
19
|
+
end
|
|
20
|
+
raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
|
|
21
|
+
|
|
22
|
+
@duration_ms, @diagnostic = duration_ms, diagnostic
|
|
23
|
+
super(device: device, status: status, backup: backup, error_code: error_code, error_type: error_type,
|
|
24
|
+
started_at: started_at, finished_at: finished_at)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Data 成员保持不变;Worker 加上时间、旧文件迁移调整产物时仍保留内部元数据。
|
|
28
|
+
def with(**attributes)
|
|
29
|
+
return self if attributes.empty?
|
|
30
|
+
|
|
31
|
+
metadata = { duration_ms: @duration_ms, diagnostic: diagnostic }
|
|
32
|
+
metadata[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
|
|
33
|
+
metadata[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
|
|
34
|
+
self.class.new(**to_h, **metadata.merge(attributes))
|
|
14
35
|
end
|
|
15
36
|
|
|
16
37
|
# 判断设备是否完成配置保存或上报上传成功。
|
|
@@ -24,7 +45,9 @@ module Net
|
|
|
24
45
|
|
|
25
46
|
# 计算设备任务的耗时,计划阶段没有时间时返回空值。
|
|
26
47
|
def duration_ms
|
|
27
|
-
|
|
48
|
+
return @duration_ms unless @duration_ms.nil?
|
|
49
|
+
|
|
50
|
+
[((finished_at - started_at) * 1000).round, 0].max if started_at && finished_at
|
|
28
51
|
end
|
|
29
52
|
end
|
|
30
53
|
|
|
@@ -40,6 +63,9 @@ module Net
|
|
|
40
63
|
# 空清单与已尝试但未全部成功的批次分别标记。
|
|
41
64
|
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
42
65
|
|
|
66
|
+
# 单独请求 v2 包装,不改变旧 Data 的成员、解构或默认 summary。
|
|
67
|
+
def report(policy: :strict) = Report.new(self, policy: policy)
|
|
68
|
+
|
|
43
69
|
# 生成可写入报告及供命令行展示的结构化摘要。
|
|
44
70
|
def summary
|
|
45
71
|
{
|
|
@@ -66,6 +92,8 @@ module Net
|
|
|
66
92
|
}
|
|
67
93
|
end
|
|
68
94
|
end
|
|
95
|
+
|
|
96
|
+
autoload :Report, File.expand_path("report", __dir__)
|
|
69
97
|
end
|
|
70
98
|
end
|
|
71
99
|
end
|
|
@@ -24,85 +24,123 @@ 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, "host #{options[:host]} was not found in inventory" 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
|
-
|
|
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
|
+
summary = summary.merge(report_location: batch.report_location, report_error: batch.report_error) unless batch.instance_of?(Report)
|
|
52
|
+
@output.puts JSON.pretty_generate(summary)
|
|
53
|
+
(batch.instance_of?(Report) ? batch.policy_success? : batch.success?) ? 0 : 1
|
|
54
|
+
rescue Operations::PrivateFile::WriteError => exception
|
|
55
|
+
message = Operations::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
|
|
56
|
+
@error.puts "net-connector-backup: #{message}"
|
|
57
|
+
2
|
|
72
58
|
rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
|
|
73
59
|
Psych::Exception, JSON::ParserError, Client::Error => exception
|
|
74
60
|
@error.puts "net-connector-backup: #{exception.message}"
|
|
75
61
|
2
|
|
76
62
|
rescue StandardError => exception
|
|
77
|
-
@error.puts "net-connector-backup
|
|
63
|
+
@error.puts "net-connector-backup 失败(#{exception.class})"
|
|
78
64
|
2
|
|
79
65
|
end
|
|
80
66
|
|
|
81
67
|
private
|
|
82
68
|
|
|
69
|
+
# 文件、环境变量、命令行按优先级覆盖,凭据仍只来自环境变量。
|
|
70
|
+
def settings_for(options)
|
|
71
|
+
defaults = options[:config] ? ConfigFile.load(options[:config]) : {}
|
|
72
|
+
values = {}
|
|
73
|
+
values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
|
|
74
|
+
values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
|
|
75
|
+
values["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES"] = options[:max_script_output_bytes].to_s if options[:max_script_output_bytes]
|
|
76
|
+
values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
|
|
77
|
+
values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = options[:limit].to_s if options[:limit]
|
|
78
|
+
values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = nil if options[:all]
|
|
79
|
+
Settings.new(env: @env, defaults: defaults, overrides: values)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 导出已有配置不创建清单或设备连接。
|
|
83
|
+
def export_config(settings, options)
|
|
84
|
+
path = Operations::SavedConfig.new(directory: settings.backup_directory)
|
|
85
|
+
.export(host: options[:export], output: options[:output], io: @output)
|
|
86
|
+
@output.puts JSON.generate(host: options[:export], output: path) if path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 预览与执行共用计划和主机存在性检查。
|
|
90
|
+
def backup_plan(fleet, settings, options)
|
|
91
|
+
limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
|
|
92
|
+
plan = if options[:tftp]
|
|
93
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit)
|
|
94
|
+
else
|
|
95
|
+
fleet.plan_backup(limit_per_vendor: limit)
|
|
96
|
+
end
|
|
97
|
+
if options[:host]
|
|
98
|
+
host = IPAddr.new(options[:host]).to_s
|
|
99
|
+
raise ArgumentError, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
|
|
100
|
+
end
|
|
101
|
+
plan
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
|
|
105
|
+
def backup(fleet, plan, settings, options)
|
|
106
|
+
reporting = options.slice(:success_policy, :report_schema)
|
|
107
|
+
if options[:tftp]
|
|
108
|
+
fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
|
|
109
|
+
concurrency: settings.concurrency,
|
|
110
|
+
vrfs: settings.tftp_vrfs,
|
|
111
|
+
source_files: settings.tftp_source_files, **reporting)
|
|
112
|
+
else
|
|
113
|
+
fleet.backup_all(plan: plan, directory: settings.backup_directory,
|
|
114
|
+
concurrency: settings.concurrency, **reporting)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
83
118
|
# 解析命令行参数并处理帮助及版本信息。
|
|
84
119
|
def parse_options
|
|
85
120
|
options = { config: @env["NET_CONNECTOR_CONFIG"] }
|
|
86
121
|
parser = OptionParser.new do |args|
|
|
87
|
-
args.banner = "
|
|
88
|
-
args.on("--config FILE", "
|
|
89
|
-
args.on("--show-config", "
|
|
90
|
-
args.on("--plan", "
|
|
91
|
-
args.on("--tftp", "
|
|
92
|
-
args.on("--export IP", "
|
|
93
|
-
args.on("--output FILE", "
|
|
94
|
-
args.on("--directory PATH", "
|
|
95
|
-
args.on("--host IP", "
|
|
96
|
-
args.on("--concurrency N", Integer, "
|
|
97
|
-
args.on("--
|
|
122
|
+
args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
|
|
123
|
+
args.on("--config FILE", "读取不含凭据的 YAML 设置") { |value| options[:config] = value }
|
|
124
|
+
args.on("--show-config", "显示生效的非敏感设置") { options[:show_config] = true }
|
|
125
|
+
args.on("--plan", "预览设备清单,不连接设备") { options[:plan] = true }
|
|
126
|
+
args.on("--tftp", "由设备发起 TFTP 备份") { options[:tftp] = true }
|
|
127
|
+
args.on("--export IP", "导出已保存的本地配置") { |value| options[:export] = value }
|
|
128
|
+
args.on("--output FILE", "将导出配置写入私有文件") { |value| options[:output] = value }
|
|
129
|
+
args.on("--directory PATH", "备份和报告目录") { |value| options[:directory] = value }
|
|
130
|
+
args.on("--host IP", "按管理地址选择一台设备") { |value| options[:host] = value }
|
|
131
|
+
args.on("--concurrency N", Integer, "最大并发设备数") { |value| options[:concurrency] = value }
|
|
132
|
+
args.on("--max-script-output-bytes N", Integer, "每个脚本的累计响应字节上限") { |value| options[:max_script_output_bytes] = value }
|
|
133
|
+
args.on("--limit-per-vendor N", Integer, "每厂商选择 N 台设备(1 至 5)") do |value|
|
|
98
134
|
options[:limit] = value
|
|
99
135
|
end
|
|
100
|
-
args.on("--all", "
|
|
101
|
-
args.on("-
|
|
136
|
+
args.on("--all", "选择全部就绪设备") { options[:all] = true }
|
|
137
|
+
args.on("--success-policy POLICY", %w[strict selected], "成功判定:strict(默认)或 selected") { |value| options[:success_policy] = value.to_sym }
|
|
138
|
+
args.on("--report-schema N", Integer, "报告版本:1(默认)或 2;selected 使用 2") { |value| options[:report_schema] = value }
|
|
139
|
+
args.on("-v", "--version", "显示版本") do
|
|
102
140
|
@output.puts Net::Connector::VERSION
|
|
103
141
|
options[:done] = true
|
|
104
142
|
end
|
|
105
|
-
args.on("-h", "--help", "
|
|
143
|
+
args.on("-h", "--help", "显示帮助") do
|
|
106
144
|
@output.puts args
|
|
107
145
|
options[:done] = true
|
|
108
146
|
end
|
|
@@ -115,12 +153,16 @@ module Net
|
|
|
115
153
|
|
|
116
154
|
# 拒绝互斥或缺少依赖的命令行选项。
|
|
117
155
|
def validate_options!(options)
|
|
156
|
+
Report.options(policy: options.fetch(:success_policy, :strict), schema: options[:report_schema])
|
|
157
|
+
Planner.validate_limit!(options[:limit])
|
|
158
|
+
Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
|
|
118
159
|
modes = [:show_config, :plan, :export].count { |key| options[key] }
|
|
119
|
-
raise ArgumentError, "
|
|
120
|
-
raise ArgumentError, "--output
|
|
121
|
-
raise ArgumentError, "--all
|
|
122
|
-
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host]
|
|
123
|
-
|
|
160
|
+
raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
|
|
161
|
+
raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
|
|
162
|
+
raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
|
|
163
|
+
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host] ||
|
|
164
|
+
options[:success_policy] || options[:report_schema] || options[:max_script_output_bytes])
|
|
165
|
+
raise ArgumentError, "--export 不能与备份选项同时使用"
|
|
124
166
|
end
|
|
125
167
|
end
|
|
126
168
|
|
|
@@ -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
|
-
legacy_devices(token)
|
|
112
|
+
legacy_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 legacy_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
|