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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +93 -18
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +139 -6
  7. data/docs/architecture.md +248 -39
  8. data/lib/net/connector/device/base.rb +28 -101
  9. data/lib/net/connector/device/local_backup.rb +96 -0
  10. data/lib/net/connector/device/profile.rb +8 -3
  11. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  12. data/lib/net/connector/device/running_config.rb +46 -5
  13. data/lib/net/connector/device/save_config.rb +21 -0
  14. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  15. data/lib/net/connector/device/tftp/receipt.rb +71 -0
  16. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  17. data/lib/net/connector/device/tftp/target.rb +63 -0
  18. data/lib/net/connector/device/tftp.rb +117 -0
  19. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  20. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  21. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  22. data/lib/net/connector/device/topology/strategy.rb +75 -0
  23. data/lib/net/connector/device/topology.rb +277 -0
  24. data/lib/net/connector/engine/authentication.rb +1 -1
  25. data/lib/net/connector/engine/command.rb +14 -2
  26. data/lib/net/connector/engine/configuration.rb +11 -8
  27. data/lib/net/connector/engine/dialogue.rb +59 -35
  28. data/lib/net/connector/engine/error_metadata.rb +50 -0
  29. data/lib/net/connector/engine/errors.rb +55 -56
  30. data/lib/net/connector/engine/execution.rb +44 -10
  31. data/lib/net/connector/engine/log/event.rb +66 -0
  32. data/lib/net/connector/engine/log/formatter.rb +16 -0
  33. data/lib/net/connector/engine/log/messages.rb +52 -0
  34. data/lib/net/connector/engine/log/stream.rb +56 -0
  35. data/lib/net/connector/engine/log.rb +107 -84
  36. data/lib/net/connector/engine/session.rb +115 -43
  37. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  38. data/lib/net/connector/engine/terminal_text.rb +33 -0
  39. data/lib/net/connector/engine/transport.rb +2 -2
  40. data/lib/net/connector/netdisco/batch.rb +25 -33
  41. data/lib/net/connector/netdisco/cli.rb +71 -31
  42. data/lib/net/connector/netdisco/client.rb +206 -73
  43. data/lib/net/connector/netdisco/config_file.rb +26 -4
  44. data/lib/net/connector/netdisco/device.rb +4 -5
  45. data/lib/net/connector/netdisco/diagnostic.rb +61 -0
  46. data/lib/net/connector/netdisco/fleet.rb +96 -66
  47. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  48. data/lib/net/connector/netdisco/report.rb +115 -0
  49. data/lib/net/connector/netdisco/result_store.rb +2 -2
  50. data/lib/net/connector/netdisco/rules.rb +28 -5
  51. data/lib/net/connector/netdisco/settings.rb +188 -85
  52. data/lib/net/connector/netdisco/worker.rb +26 -17
  53. data/lib/net/connector/storage/backup_lock.rb +116 -0
  54. data/lib/net/connector/storage/private_file.rb +109 -0
  55. data/lib/net/connector/storage/safe_file.rb +62 -0
  56. data/lib/net/connector/{operations → storage}/saved_config.rb +25 -20
  57. data/lib/net/connector/storage.rb +15 -0
  58. data/lib/net/connector/textfsm.rb +72 -0
  59. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +12 -5
  60. data/lib/net/connector/vendor/cisco_ios/topology.rb +12 -6
  61. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +11 -4
  62. data/lib/net/connector/vendor/h3c/tftp_backup.rb +9 -4
  63. data/lib/net/connector/vendor/h3c/topology.rb +11 -6
  64. data/lib/net/connector/vendor/h3c.rb +3 -3
  65. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +15 -6
  66. data/lib/net/connector/vendor/hillstone/topology.rb +11 -6
  67. data/lib/net/connector/vendor/huawei/tftp_backup.rb +9 -3
  68. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  69. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +17 -8
  70. data/lib/net/connector/vendor/palo_alto/topology.rb +9 -7
  71. data/lib/net/connector/vendor/radware/tftp_backup.rb +12 -5
  72. data/lib/net/connector/vendor/radware/topology.rb +3 -2
  73. data/lib/net/connector/version.rb +1 -1
  74. data/lib/net/connector.rb +1 -6
  75. metadata +54 -38
  76. data/lib/net/connector/device/interface_description.rb +0 -44
  77. data/lib/net/connector/device/interface_name.rb +0 -56
  78. data/lib/net/connector/engine/base.rb +0 -4
  79. data/lib/net/connector/engine/log_messages.rb +0 -47
  80. data/lib/net/connector/engine/profile.rb +0 -4
  81. data/lib/net/connector/engine.rb +0 -4
  82. data/lib/net/connector/operations/local_backup.rb +0 -49
  83. data/lib/net/connector/operations/parse_output.rb +0 -60
  84. data/lib/net/connector/operations/private_file.rb +0 -25
  85. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  86. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  87. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  88. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  89. data/lib/net/connector/operations/running_config.rb +0 -16
  90. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  91. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  92. data/lib/net/connector/operations/tftp/file_upload.rb +0 -42
  93. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  94. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  96. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  98. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  99. data/lib/net/connector/operations/tftp_backup.rb +0 -120
  100. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  101. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  102. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  103. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  104. data/lib/net/connector/operations/topology/radware.rb +0 -11
  105. data/lib/net/connector/operations/topology/strategy.rb +0 -67
  106. data/lib/net/connector/operations/topology.rb +0 -193
  107. 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(:record)
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 record(bytes)
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
- super
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 summary
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
- values = options[:config] ? ConfigFile.load(options[:config]).merge(@env.to_h) : @env.to_h
28
- values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
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
- path = Operations::SavedConfig.new(directory: settings.backup_directory)
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
- limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
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 = if options[:tftp]
61
- fleet.tftp_backup_all(plan: plan, server: values.fetch("TFTP_HOST"),
62
- concurrency: settings.concurrency,
63
- vrfs: settings.tftp_vrfs,
64
- source_files: settings.tftp_source_files)
65
- else
66
- fleet.backup_all(plan: plan, directory: settings.backup_directory,
67
- concurrency: settings.concurrency)
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; end
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
- # 校验 Netdisco 地址与凭据并建立请求入口。
21
- def initialize(url:, username: nil, password: nil, api_key: nil, page_size: 500,
22
- max_pages: DEFAULT_MAX_PAGES, requester: nil)
23
- @url = URI.parse(url.to_s)
24
- unless @url.is_a?(URI::HTTP) && @url.host && !@url.userinfo && !@url.query && !@url.fragment
25
- raise ArgumentError, "url must be an HTTP(S) base URL without credentials, query or fragment"
26
- end
27
- unless (api_key.nil? && present?(username) && present?(password)) ||
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
- if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n]/) }
32
- raise ArgumentError, "credentials must be single-line strings"
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 page_size.is_a?(Integer) && page_size.positive? && max_pages.is_a?(Integer) && max_pages.positive?
35
- raise ArgumentError, "page_size and max_pages must be positive integers"
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
- @username, @password, @api_key = username, password, api_key
39
- @page_size, @max_pages = page_size, max_pages
40
- @requester = requester || method(:default_request)
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
- token = @api_key || authenticate
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
- offset = 0
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, offset: offset)
54
- page = request_json(uri, authorized_get(uri, token), allow_empty: true)
55
- validate_rows!(page)
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
- raise Error, "Netdisco pagination did not advance" if page.all? { |row| seen_ips[row.fetch("ip")] }
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, "Netdisco inventory exceeded max_pages"
110
+ raise Error.new("Netdisco inventory exceeded max_pages", code: :max_pages), cause: nil
65
111
  rescue QueryRequired
66
- legacy_devices(token)
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
- # 使用账号密码登录并取得 API 密钥。
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
- request = ::Net::HTTP::Get.new(uri)
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 = @url.dup
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
- # 发起请求并将 HTTP 和 JSON 故障转换为客户端错误。
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
- response = @requester.call(uri, request)
113
- body = response.body.to_s
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
- raise Error, "Netdisco request failed (HTTP #{response.code})" unless response.is_a?(::Net::HTTPSuccess)
119
- return [] if allow_empty && body.strip.empty?
120
-
121
- JSON.parse(body)
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, "Netdisco returned invalid JSON", cause: nil
124
- rescue IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError => error
125
- raise Error, "Netdisco connection failed (#{error.class})", cause: nil
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
- # 使用标准库 HTTP 客户端发送请求。
129
- def default_request(uri, request)
130
- ::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
131
- open_timeout: 10, read_timeout: 60, write_timeout: 10) { |http| http.request(request) }
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
- def validate_rows!(rows)
136
- valid = rows.is_a?(Array) && rows.all? do |row|
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
- raise Error, "Netdisco returned an invalid device inventory" unless valid
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