net-connector 0.4.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. metadata +517 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Net
6
+ module Connector
7
+ module Netdisco
8
+ # 单台设备的最终结果,保留状态、配置产物和错误类型。
9
+ Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
10
+ :started_at, :finished_at) do
11
+ # 创建结果,允许计划阶段尚无执行时间。
12
+ def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil)
13
+ super
14
+ end
15
+
16
+ # 判断设备是否完成配置保存或上报上传成功。
17
+ def success? = [:backed_up, :reported_uploaded].include?(status)
18
+
19
+ # 判断配置已产生但收尾过程是否出错。
20
+ def partial? = [:saved_with_error, :reported_with_error].include?(status)
21
+
22
+ # 判断设备是否因为清单或采样规则未执行。
23
+ 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
+ end
30
+
31
+ # 汇总一次批量任务的设备结果、回调故障和报告写入状态。
32
+ Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
33
+ :report_location, :report_error) do
34
+ # 按设备最终状态统计数量。
35
+ def counts = outcomes.group_by(&:status).transform_values(&:size)
36
+
37
+ # 仅在所有设备成功且回调、报告均正常时判定整批成功。
38
+ def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
39
+
40
+ # 空清单与已尝试但未全部成功的批次分别标记。
41
+ def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
42
+
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
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+ require "optparse"
6
+
7
+ module Net
8
+ module Connector
9
+ module Netdisco
10
+ class CLI
11
+ # 保存命令行输入输出和清单任务工厂。
12
+ def initialize(argv: ARGV, env: ENV, output: $stdout, error: $stderr,
13
+ fleet_factory: ->(settings) { Fleet.new(settings: settings) })
14
+ @argv = argv.dup
15
+ @env = env
16
+ @output = output
17
+ @error = error
18
+ @fleet_factory = fleet_factory
19
+ end
20
+
21
+ # 解析参数并执行预览、导出或批量备份。
22
+ def run
23
+ options = parse_options
24
+ return 0 if options[:done]
25
+
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)
32
+
33
+ if options[:show_config]
34
+ @output.puts JSON.pretty_generate(settings.public_config)
35
+ return 0
36
+ end
37
+ 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
41
+ return 0
42
+ end
43
+
44
+ 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, "host #{options[:host]} was not found in inventory" unless plan.inventory.any? { |device| device.host == host }
54
+ end
55
+ if options[:plan]
56
+ @output.puts JSON.pretty_generate(plan_summary(plan))
57
+ return 0
58
+ end
59
+
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
72
+ rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
73
+ Psych::Exception, JSON::ParserError, Client::Error => exception
74
+ @error.puts "net-connector-backup: #{exception.message}"
75
+ 2
76
+ rescue StandardError => exception
77
+ @error.puts "net-connector-backup failed (#{exception.class})"
78
+ 2
79
+ end
80
+
81
+ private
82
+
83
+ # 解析命令行参数并处理帮助及版本信息。
84
+ def parse_options
85
+ options = { config: @env["NET_CONNECTOR_CONFIG"] }
86
+ parser = OptionParser.new do |args|
87
+ args.banner = "Usage: net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
88
+ args.on("--config FILE", "Read non-secret YAML settings") { |value| options[:config] = value }
89
+ args.on("--show-config", "Print effective non-secret settings") { options[:show_config] = true }
90
+ args.on("--plan", "Preview selected inventory without device connections") { options[:plan] = true }
91
+ args.on("--tftp", "Use device-initiated TFTP backup") { options[:tftp] = true }
92
+ args.on("--export IP", "Export a saved local configuration") { |value| options[:export] = value }
93
+ args.on("--output FILE", "Write exported configuration to a private file") { |value| options[:output] = value }
94
+ args.on("--directory PATH", "Backup and report directory") { |value| options[:directory] = value }
95
+ args.on("--host IP", "Select one inventory device by management IP") { |value| options[:host] = value }
96
+ args.on("--concurrency N", Integer, "Maximum simultaneous devices") { |value| options[:concurrency] = value }
97
+ args.on("--limit-per-vendor N", Integer, "Select N devices per vendor (1..5)") do |value|
98
+ options[:limit] = value
99
+ end
100
+ args.on("--all", "Select all ready devices") { options[:all] = true }
101
+ args.on("-v", "--version", "Print version") do
102
+ @output.puts Net::Connector::VERSION
103
+ options[:done] = true
104
+ end
105
+ args.on("-h", "--help", "Show help") do
106
+ @output.puts args
107
+ options[:done] = true
108
+ end
109
+ end
110
+ parser.parse!(@argv)
111
+ raise OptionParser::InvalidOption, @argv.join(" ") unless @argv.empty?
112
+
113
+ options
114
+ end
115
+
116
+ # 拒绝互斥或缺少依赖的命令行选项。
117
+ def validate_options!(options)
118
+ modes = [:show_config, :plan, :export].count { |key| options[key] }
119
+ raise ArgumentError, "choose one of --show-config, --plan, or --export" if modes > 1
120
+ raise ArgumentError, "--output requires --export" if options[:output] && !options[:export]
121
+ raise ArgumentError, "--all and --limit-per-vendor cannot be combined" if options[:all] && options[:limit]
122
+ if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host])
123
+ raise ArgumentError, "--export cannot be combined with backup options"
124
+ end
125
+ end
126
+
127
+ # 整理备份计划供命令行预览。
128
+ def plan_summary(plan)
129
+ {
130
+ total: plan.inventory.size,
131
+ ready: plan.inventory.count(&:ready?),
132
+ selected: plan.selected.map do |device|
133
+ { host: device.host, name: device.name, connector: device.vendor,
134
+ filename: plan.mode == :backup ? device.backup_filename : device.tftp_filename }
135
+ end,
136
+ skipped: plan.outcomes.compact.group_by(&:status).transform_values(&:size)
137
+ }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Net
9
+ module Connector
10
+ module Netdisco
11
+ # 独立于 Rails 的 Netdisco 设备清单客户端。
12
+ class Client
13
+ class Error < StandardError; end
14
+
15
+ class QueryRequired < Error; end
16
+
17
+ FIELDS = %w[ip name dns vendor os model os_ver serial].freeze
18
+ DEFAULT_MAX_PAGES = 10_000
19
+
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"
30
+ end
31
+ if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n]/) }
32
+ raise ArgumentError, "credentials must be single-line strings"
33
+ 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"
36
+ end
37
+
38
+ @username, @password, @api_key = username, password, api_key
39
+ @page_size, @max_pages = page_size, max_pages
40
+ @requester = requester || method(:default_request)
41
+ rescue URI::InvalidURIError
42
+ raise ArgumentError, "url is invalid", cause: nil
43
+ end
44
+
45
+ # 逐页获取并校验完整设备清单。
46
+ def devices
47
+ token = @api_key || authenticate
48
+ rows = []
49
+ seen_ips = {}
50
+ offset = 0
51
+ @max_pages.times do
52
+ 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)
56
+ return rows.uniq if page.empty?
57
+
58
+ raise Error, "Netdisco pagination did not advance" if page.all? { |row| seen_ips[row.fetch("ip")] }
59
+
60
+ page.each { |row| seen_ips[row.fetch("ip")] = true }
61
+ rows.concat(page)
62
+ offset += @page_size
63
+ end
64
+ raise Error, "Netdisco inventory exceeded max_pages"
65
+ rescue QueryRequired
66
+ legacy_devices(token)
67
+ end
68
+
69
+ # 仅显示服务地址,避免凭据进入调试输出。
70
+ def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
71
+
72
+ private
73
+
74
+ # 在服务要求查询参数时使用兼容查询获取清单。
75
+ def legacy_devices(token)
76
+ uri = endpoint("api/v1/search/device")
77
+ 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)
80
+ rows.map { |row| row.slice(*FIELDS) }.uniq
81
+ end
82
+
83
+ # 使用账号密码登录并取得 API 密钥。
84
+ def authenticate
85
+ uri = endpoint("login")
86
+ request = ::Net::HTTP::Post.new(uri)
87
+ request.basic_auth(@username, @password)
88
+ data = request_json(uri, request)
89
+ 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]/)
91
+
92
+ token
93
+ end
94
+
95
+ # 为清单查询建立带 API 密钥的请求。
96
+ def authorized_get(uri, token)
97
+ request = ::Net::HTTP::Get.new(uri)
98
+ request["Authorization"] = "Apikey #{token}"
99
+ request
100
+ end
101
+
102
+ # 以服务根地址构建 API 地址。
103
+ def endpoint(path)
104
+ uri = @url.dup
105
+ uri.path = "#{uri.path.chomp("/")}/#{path}"
106
+ uri
107
+ end
108
+
109
+ # 发起请求并将 HTTP 和 JSON 故障转换为客户端错误。
110
+ def request_json(uri, request, allow_empty: false)
111
+ request["Accept"] = "application/json"
112
+ response = @requester.call(uri, request)
113
+ body = response.body.to_s
114
+ if response.code == "400"
115
+ parsed = JSON.parse(body)
116
+ raise QueryRequired, "Netdisco requires a search query" if parsed.is_a?(Hash) && parsed["error"] == "Missing query"
117
+ 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)
122
+ 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
126
+ end
127
+
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) }
132
+ end
133
+
134
+ # 校验清单的字段类型和设备地址。
135
+ def validate_rows!(rows)
136
+ valid = rows.is_a?(Array) && rows.all? do |row|
137
+ row.is_a?(Hash) && present?(row["ip"]) &&
138
+ FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
139
+ end
140
+ raise Error, "Netdisco returned an invalid device inventory" unless valid
141
+ end
142
+
143
+ # 判断凭据字段是否为非空字符串。
144
+ def present?(value) = value.is_a?(String) && !value.strip.empty?
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ # 命令行使用的无凭据 YAML 配置,环境变量优先。
10
+ class ConfigFile
11
+ FIELDS = {
12
+ "netdisco" => { "url" => ["NETDISCO_URL", :string],
13
+ "page_size" => ["NETDISCO_PAGE_SIZE", :integer] },
14
+ "backup" => { "directory" => ["NET_CONNECTOR_BACKUP_DIRECTORY", :string],
15
+ "concurrency" => ["NET_CONNECTOR_CONCURRENCY", :integer],
16
+ "limit_per_vendor" => ["NET_CONNECTOR_SAMPLE_PER_VENDOR", :integer] },
17
+ "inventory" => { "include_hosts" => ["NET_CONNECTOR_INCLUDE_HOSTS", :list],
18
+ "exclude_hosts" => ["NET_CONNECTOR_EXCLUDE_HOSTS", :list],
19
+ "include_vendors" => ["NET_CONNECTOR_INCLUDE_VENDORS", :list],
20
+ "vendor_overrides" => ["NET_CONNECTOR_VENDOR_OVERRIDES", :json],
21
+ "host_overrides" => ["NET_CONNECTOR_HOST_OVERRIDES", :json],
22
+ "device_rules" => ["NET_CONNECTOR_DEVICE_RULES", :json] },
23
+ "ssh" => { "protocol" => ["NET_CONNECTOR_PROTOCOL", :string],
24
+ "known_hosts" => ["NET_CONNECTOR_KNOWN_HOSTS", :string],
25
+ "host_key_policy" => ["NET_CONNECTOR_HOST_KEY_POLICY", :string],
26
+ "log_directory" => ["NET_CONNECTOR_LOG_DIRECTORY", :string],
27
+ "log_level" => ["NET_CONNECTOR_LOG_LEVEL", :string] },
28
+ "tftp" => { "server" => ["TFTP_HOST", :string],
29
+ "vrfs" => ["NET_CONNECTOR_TFTP_VRFS", :json] }
30
+ }.freeze
31
+
32
+ # 安全读取 YAML 配置并映射到环境变量格式。
33
+ def self.load(path)
34
+ raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
35
+
36
+ document = YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: false)
37
+ raise ArgumentError, "config must be a YAML mapping" unless document.is_a?(Hash)
38
+
39
+ unknown_sections = document.keys - FIELDS.keys
40
+ raise ArgumentError, "unknown config sections: #{unknown_sections.join(", ")}" unless unknown_sections.empty?
41
+
42
+ document.each_with_object({}) do |(section, values), result|
43
+ fields = FIELDS.fetch(section)
44
+ raise ArgumentError, "#{section} must be a mapping" unless values.is_a?(Hash)
45
+
46
+ unknown = values.keys - fields.keys
47
+ raise ArgumentError, "unknown #{section} settings: #{unknown.join(", ")}" unless unknown.empty?
48
+
49
+ values.each do |name, value|
50
+ key, type = fields.fetch(name)
51
+ result[key] = encode(section, name, type, value)
52
+ end
53
+ end
54
+ rescue Psych::Exception => error
55
+ raise ArgumentError, "invalid YAML config (#{error.class})"
56
+ end
57
+
58
+ # 按字段类型校验并编码单项配置。
59
+ def self.encode(section, name, type, value)
60
+ case type
61
+ when :string
62
+ raise ArgumentError, "#{section}.#{name} must be a nonempty String" unless value.is_a?(String) && !value.strip.empty?
63
+
64
+ value
65
+ when :integer
66
+ raise ArgumentError, "#{section}.#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
67
+
68
+ value.to_s
69
+ when :list
70
+ unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? && !item.include?(",") }
71
+ raise ArgumentError, "#{section}.#{name} must be a list of strings without commas"
72
+ end
73
+
74
+ value.join(",")
75
+ when :json
76
+ expected = name == "device_rules" ? Array : Hash
77
+ raise ArgumentError, "#{section}.#{name} must be a #{expected}" unless value.is_a?(expected)
78
+
79
+ JSON.generate(value)
80
+ end
81
+ end
82
+
83
+ private_class_method :encode
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "../operations/saved_config"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ # 仅保存清单元数据,设备凭据由调用方管理。
10
+ Device = Data.define(:host, :source_ip, :name, :vendor, :source_vendor, :model, :os, :os_version, :serial, :issue) do
11
+ # 清单行来自可变的外部数据;设备快照不共享其中的字符串。
12
+ def initialize(*values, **attributes)
13
+ frozen_values = values.map { |value| value.is_a?(String) ? value.dup.freeze : value }
14
+ frozen_attributes = attributes.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }
15
+ super(*frozen_values, **frozen_attributes)
16
+ end
17
+
18
+ # 将 Netdisco 清单记录转换为可规划的设备对象。
19
+ def self.from_row(row, rules:)
20
+ valid = row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].empty? &&
21
+ Client::FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
22
+ raise Client::Error, "Netdisco returned an invalid device inventory" unless valid
23
+
24
+ source_ip = row.fetch("ip")
25
+ host = nil
26
+ issue = nil
27
+ begin
28
+ raise IPAddr::InvalidAddressError, "address includes a prefix" if source_ip.include?("/")
29
+
30
+ host = IPAddr.new(source_ip).to_s
31
+ rescue IPAddr::Error
32
+ issue = :invalid_address
33
+ end
34
+ vendor = rules.resolve(row)
35
+ issue ||= :unsupported_vendor unless vendor
36
+ issue ||= :filtered unless rules.selected?(host, vendor)
37
+ name = [row["name"], row["dns"]].find { |value| value && !value.strip.empty? }
38
+ new(host: host, source_ip: source_ip, name: name&.strip, vendor: vendor,
39
+ source_vendor: row["vendor"], model: row["model"], os: row["os"],
40
+ os_version: row["os_ver"], serial: row["serial"], issue: issue)
41
+ end
42
+
43
+ # 判断设备是否满足备份选择条件。
44
+ def ready? = issue.nil?
45
+
46
+ # 生成不随名称或厂商信息改变的本地文本文件名。
47
+ def backup_filename = Operations::SavedConfig.filename(host)
48
+
49
+ # 生成设备 CLI 可用的 ASCII 远端文件名。
50
+ def tftp_filename
51
+ return "running-config.xml" if vendor == :palo_alto
52
+
53
+ label = name.to_s.encode(Encoding::US_ASCII, invalid: :replace, undef: :replace, replace: "-")
54
+ .gsub(/[^A-Za-z0-9._-]+/, "-").byteslice(0, 180)
55
+ .gsub(/\A[._-]+|[._-]+\z/, "")
56
+ label = vendor&.to_s || "device" if label.empty?
57
+ extension = case vendor
58
+ when :radware then "tgz"
59
+ when :hillstone then "dat"
60
+ else "cfg"
61
+ end
62
+ TftpTarget.filename(host, extension: extension, label: label)
63
+ end
64
+
65
+ # 按厂商及设备地址实例化连接器。
66
+ def connector(**settings)
67
+ raise ArgumentError, "device is not ready: #{issue}" unless ready?
68
+ raise ArgumentError, "host cannot be overridden" if settings.key?(:host)
69
+
70
+ Net::Connector.build(vendor, host: host, **settings)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end