net-connector 0.5.0 → 0.7.0

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