net-connector 0.6.0 → 0.7.1

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +112 -50
  5. data/docs/RELEASING.md +11 -8
  6. data/docs/VERIFICATION.md +42 -1
  7. data/docs/architecture.md +15 -2
  8. data/examples/backup.yml +40 -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/command.rb +2 -2
  17. data/lib/net/connector/engine/configuration.rb +5 -2
  18. data/lib/net/connector/engine/execution.rb +25 -14
  19. data/lib/net/connector/engine/known_hosts.rb +112 -0
  20. data/lib/net/connector/engine/log.rb +10 -7
  21. data/lib/net/connector/engine/session.rb +117 -128
  22. data/lib/net/connector/engine/transport.rb +52 -11
  23. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  24. data/lib/net/connector/netdisco/batch.rb +1 -1
  25. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  26. data/lib/net/connector/netdisco/cli.rb +13 -26
  27. data/lib/net/connector/netdisco/client.rb +6 -6
  28. data/lib/net/connector/netdisco/config_file.rb +20 -19
  29. data/lib/net/connector/netdisco/connection.rb +38 -0
  30. data/lib/net/connector/netdisco/database_client.rb +2 -2
  31. data/lib/net/connector/netdisco/device.rb +11 -1
  32. data/lib/net/connector/netdisco/fleet.rb +68 -47
  33. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  34. data/lib/net/connector/netdisco/plan.rb +14 -9
  35. data/lib/net/connector/netdisco/planner.rb +18 -14
  36. data/lib/net/connector/netdisco/progress.rb +253 -0
  37. data/lib/net/connector/netdisco/report/files.rb +41 -0
  38. data/lib/net/connector/netdisco/report/text.rb +45 -0
  39. data/lib/net/connector/netdisco/report.rb +53 -31
  40. data/lib/net/connector/netdisco/result_store.rb +3 -1
  41. data/lib/net/connector/netdisco/rules.rb +10 -2
  42. data/lib/net/connector/netdisco/settings.rb +118 -68
  43. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  44. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  45. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  46. data/lib/net/connector/netdisco/worker.rb +8 -3
  47. data/lib/net/connector/netdisco.rb +10 -0
  48. data/lib/net/connector/storage/backup_lock.rb +1 -1
  49. data/lib/net/connector/storage/batch_directory.rb +29 -0
  50. data/lib/net/connector/storage.rb +1 -0
  51. data/lib/net/connector/vendor/h3c.rb +2 -1
  52. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  53. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  55. data/lib/net/connector/vendor/radware.rb +4 -3
  56. data/lib/net/connector/version.rb +1 -1
  57. data/lib/net/connector.rb +2 -2
  58. metadata +42 -111
  59. /data/examples/{netdisco_database.yml → inventory_sql.yml} +0 -0
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Net
6
+ module Connector
7
+ module Netdisco
8
+ class CLI
9
+ # 可注入参数与输入输出的备份选项,不退出宿主进程。
10
+ class Options
11
+ def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
12
+ values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
13
+ parser = parser_for(values, output: output, program: program)
14
+ remaining = parser.parse!(argv.dup)
15
+ return values if values[:help]
16
+ raise OptionParser::InvalidArgument unless remaining.empty?
17
+ credentials = values[:environment].keys.any? { |key| key.match?(/USERNAME|PASSWORD/) }
18
+ if values[:stdin_credentials] && (credentials || values[:ask_password] || values[:ask_netdisco_password])
19
+ raise ArgumentError, "--stdin-credentials 不能与命令行凭据或密码提示同时使用"
20
+ end
21
+ read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "设备密码", input: input, output: error)
22
+ read_password(values, :ask_netdisco_password, "NETDISCO_PASSWORD", "Netdisco 密码", input: input, output: error)
23
+
24
+ values
25
+ rescue OptionParser::ParseError
26
+ raise ArgumentError, "参数无效:并发数须为 1 至 50,抽样须为 1 至 5;使用 --help 查看参数", cause: nil
27
+ end
28
+
29
+ def self.parser_for(values, output:, program:)
30
+ OptionParser.new do |opts|
31
+ opts.banner = "用法:ruby #{File.basename(program)} [选项](默认全量)"
32
+ add_settings(opts, values)
33
+ add_execution(opts, values)
34
+ opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
35
+ opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
36
+ opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
37
+ opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
38
+ opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
39
+ opts.on("-h", "--help", "显示帮助") { output.puts opts; values[:help] = true }
40
+ end
41
+ end
42
+
43
+ def self.add_execution(opts, values)
44
+ opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
45
+ raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
46
+
47
+ values[:environment]["NC_CONCURRENCY"] = count.to_s
48
+ end
49
+ opts.on("--sample N", "--limit-per-vendor N", Integer, "每厂商抽样 1 至 5 台") do |count|
50
+ Planner.validate_limit!(count)
51
+ values[:limit] = count
52
+ end
53
+ opts.on("--tftp-root DIR", "本机 TFTP 服务器文件目录") { |path| values[:tftp_root] = path }
54
+ opts.on("--all", "选择全部就绪设备") { values[:all] = true }
55
+ opts.on("--success-policy POLICY", %w[strict selected verified], "成功判定:strict / selected / verified(TFTP 文件核验)") do |policy|
56
+ values[:success_policy] = policy.to_sym
57
+ end
58
+ end
59
+
60
+ def self.settings(options, env: ENV)
61
+ raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
62
+
63
+ environment = environment(options, env: env)
64
+ path = environment["NC_CONFIG"]
65
+ defaults = path && !path.empty? ? ConfigFile.load(path) : {}
66
+ overrides = {}
67
+ { source: "NETDISCO_SOURCE", query: "NETDISCO_QUERY", query_params: "NETDISCO_QUERY_PARAMS",
68
+ max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
69
+ limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
70
+ overrides[key] = options[option].to_s if options.key?(option)
71
+ end
72
+ overrides["NC_SAMPLE_PER_VENDOR"] = nil if options[:all]
73
+ Settings.new(env: environment, defaults: defaults, overrides: overrides)
74
+ end
75
+
76
+ def self.add_settings(opts, values)
77
+ {
78
+ "--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
79
+ "--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
80
+ "--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "设备提权密码"],
81
+ "--netdisco-url URL" => ["NETDISCO_URL", "Netdisco 地址"],
82
+ "--netdisco-username USER" => ["NETDISCO_USERNAME", "Netdisco 用户名"],
83
+ "--netdisco-password PASSWORD" => ["NETDISCO_PASSWORD", "Netdisco 密码"],
84
+ "--directory DIR" => ["NC_BACKUP_DIRECTORY", "备份目录,相对项目根目录"],
85
+ "--config FILE" => ["NC_CONFIG", "YAML 配置,相对项目根目录"]
86
+ }.each do |flag, (key, description)|
87
+ opts.on(flag, description) do |value|
88
+ raise OptionParser::InvalidArgument if value.empty?
89
+
90
+ values[:environment][key] = value
91
+ end
92
+ end
93
+ opts.on("--host-key-policy POLICY", %w[strict accept_new replace], "主机密钥策略:strict / accept_new / replace(变化时自动替换)") do |policy|
94
+ values[:environment]["NC_HOST_KEY_POLICY"] = policy
95
+ end
96
+ opts.on("--known-hosts FILE", "持久保存主机密钥的文件") do |path|
97
+ values[:environment]["NC_KNOWN_HOSTS"] = path
98
+ end
99
+ end
100
+
101
+ def self.environment(options, env: ENV)
102
+ result = env.to_h.dup
103
+ overrides = options.fetch(:environment)
104
+ # 显式设备参数覆盖对应厂商默认值;未指定的字段继续按原凭据规则解析。
105
+ { "NC_DEVICE_USERNAME" => "USERNAME", "NC_DEVICE_PASSWORD" => "PASSWORD",
106
+ "NC_ENABLE_PASSWORD" => "ENABLE_PASSWORD" }.each do |key, suffix|
107
+ next unless overrides.key?(key)
108
+
109
+ result.delete_if do |name, _|
110
+ name.start_with?("NC_") && name.end_with?("_#{suffix}") &&
111
+ (suffix != "PASSWORD" || !name.end_with?("_ENABLE_PASSWORD"))
112
+ end
113
+ end
114
+ if overrides.key?("NETDISCO_USERNAME") || overrides.key?("NETDISCO_PASSWORD")
115
+ result.delete("NETDISCO_API_KEY")
116
+ end
117
+ result.merge(overrides)
118
+ end
119
+
120
+ def self.read_password(values, flag, key, label, input:, output:)
121
+ return unless values[flag]
122
+
123
+ raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
124
+ raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless input.tty?
125
+ require "io/console"
126
+ output.print "#{label}:"
127
+ output.flush
128
+ password = input.noecho(&:gets)&.chomp
129
+ output.puts
130
+ raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
131
+
132
+ values[:environment][key] = password
133
+ end
134
+ private_class_method :read_password, :parser_for
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -54,7 +54,10 @@ module Net
54
54
  message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
55
55
  @error.puts "net-connector-backup: #{message}"
56
56
  2
57
- rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
57
+ rescue OptionParser::ParseError
58
+ @error.puts "net-connector-backup: 参数无效;使用 --help 查看参数"
59
+ 2
60
+ rescue ArgumentError, KeyError, Errno::ENOENT,
58
61
  Psych::Exception, JSON::ParserError, Client::Error => exception
59
62
  @error.puts "net-connector-backup: #{exception.message}"
60
63
  2
@@ -67,18 +70,7 @@ module Net
67
70
 
68
71
  # 文件、环境变量、命令行按优先级覆盖,凭据仍只来自环境变量。
69
72
  def settings_for(options)
70
- defaults = options[:config] ? ConfigFile.load(options[:config]) : {}
71
- values = {}
72
- values["NETDISCO_SOURCE"] = options[:source] if options[:source]
73
- values["NETDISCO_QUERY"] = options[:query] if options[:query]
74
- values["NETDISCO_QUERY_PARAMS"] = options[:query_params] if options[:query_params]
75
- values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
76
- values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
77
- values["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES"] = options[:max_script_output_bytes].to_s if options[:max_script_output_bytes]
78
- values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
79
- values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = options[:limit].to_s if options[:limit]
80
- values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = nil if options[:all]
81
- Settings.new(env: @env, defaults: defaults, overrides: values)
73
+ Options.settings(options, env: @env)
82
74
  end
83
75
 
84
76
  # 导出已有配置不创建清单或设备连接。
@@ -110,7 +102,7 @@ module Net
110
102
  fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
111
103
  concurrency: settings.concurrency,
112
104
  vrfs: settings.tftp_vrfs,
113
- source_files: settings.tftp_source_files, **reporting)
105
+ source_files: settings.tftp_source_files, verification_root: options[:tftp_root] || @env["TFTP_ROOT"], **reporting)
114
106
  else
115
107
  fleet.backup_all(plan: plan, directory: settings.backup_directory,
116
108
  concurrency: settings.concurrency, **reporting)
@@ -119,10 +111,11 @@ module Net
119
111
 
120
112
  # 解析命令行参数并处理帮助及版本信息。
121
113
  def parse_options
122
- options = { config: @env["NET_CONNECTOR_CONFIG"] }
114
+ options = { environment: {} }
123
115
  parser = OptionParser.new do |args|
124
116
  args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
125
- args.on("--config FILE", "读取不含凭据的 YAML 设置") { |value| options[:config] = value }
117
+ Options.add_settings(args, options)
118
+ Options.add_execution(args, options)
126
119
  args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
127
120
  args.on("--query SQL", "PostgreSQL 清单查询,使用 $1、$2 绑定参数") { |value| options[:query] = value }
128
121
  args.on("--query-params JSON", "查询参数的 JSON 数组") { |value| options[:query_params] = value }
@@ -131,15 +124,8 @@ module Net
131
124
  args.on("--tftp", "由设备发起 TFTP 备份") { options[:tftp] = true }
132
125
  args.on("--export IP", "导出已保存的本地配置") { |value| options[:export] = value }
133
126
  args.on("--output FILE", "将导出配置写入私有文件") { |value| options[:output] = value }
134
- args.on("--directory PATH", "备份和报告目录") { |value| options[:directory] = value }
135
127
  args.on("--host IP", "按管理地址选择一台设备") { |value| options[:host] = value }
136
- args.on("--concurrency N", Integer, "最大并发设备数") { |value| options[:concurrency] = value }
137
128
  args.on("--max-script-output-bytes N", Integer, "每个脚本的累计响应字节上限") { |value| options[:max_script_output_bytes] = value }
138
- args.on("--limit-per-vendor N", Integer, "每厂商选择 N 台设备(1 至 5)") do |value|
139
- options[:limit] = value
140
- end
141
- args.on("--all", "选择全部就绪设备") { options[:all] = true }
142
- args.on("--success-policy POLICY", %w[strict selected], "成功判定:strict(默认)或 selected") { |value| options[:success_policy] = value.to_sym }
143
129
  args.on("-v", "--version", "显示版本") do
144
130
  @output.puts Net::Connector::VERSION
145
131
  options[:done] = true
@@ -157,9 +143,10 @@ module Net
157
143
 
158
144
  # 拒绝互斥或缺少依赖的命令行选项。
159
145
  def validate_options!(options)
146
+ raise ArgumentError, "verified policy requires --tftp" if options[:success_policy] == :verified && !options[:tftp]
160
147
  Report.validate_policy!(options.fetch(:success_policy, :strict))
161
148
  Planner.validate_limit!(options[:limit])
162
- Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
149
+ raise ArgumentError, "--tftp-root requires --tftp" if options[:tftp_root] && !options[:tftp]
163
150
  modes = [:show_config, :plan, :export].count { |key| options[key] }
164
151
  raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
165
152
  raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
@@ -167,8 +154,8 @@ module Net
167
154
  if options[:export] && (options[:source] || options[:query] || options[:query_params])
168
155
  raise ArgumentError, "--export 不能与清单查询选项同时使用"
169
156
  end
170
- if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host] ||
171
- options[:success_policy] || options[:max_script_output_bytes])
157
+ if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:environment].key?("NC_CONCURRENCY") || options[:host] ||
158
+ options[:success_policy] || options[:tftp_root] || options[:max_script_output_bytes])
172
159
  raise ArgumentError, "--export 不能与备份选项同时使用"
173
160
  end
174
161
  end
@@ -258,9 +258,9 @@ module Net
258
258
  row.is_a?(Hash) && present?(row["ip"]) &&
259
259
  FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
260
260
  end
261
- unless valid
262
- raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
263
- end
261
+ return if valid
262
+
263
+ raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
264
264
  end
265
265
 
266
266
  def validate_credentials!(username, password, api_key)
@@ -268,9 +268,9 @@ module Net
268
268
  (present?(api_key) && username.nil? && password.nil?)
269
269
  raise ArgumentError, "provide either username and password or api_key"
270
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
271
+ return unless [username, password, api_key].compact.any? { |value| value.match?(/[\r\n\x00]/) }
272
+
273
+ raise ArgumentError, "credentials must be single-line strings"
274
274
  end
275
275
 
276
276
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -20,26 +20,27 @@ module Net
20
20
  "max_devices" => ["NETDISCO_MAX_DEVICES", :integer],
21
21
  "inventory_timeout" => ["NETDISCO_INVENTORY_TIMEOUT", :number],
22
22
  "allow_insecure_http" => ["NETDISCO_ALLOW_INSECURE_HTTP", :boolean] },
23
- "backup" => { "directory" => ["NET_CONNECTOR_BACKUP_DIRECTORY", :string],
24
- "concurrency" => ["NET_CONNECTOR_CONCURRENCY", :integer],
25
- "limit_per_vendor" => ["NET_CONNECTOR_SAMPLE_PER_VENDOR", :integer] },
26
- "inventory" => { "include_hosts" => ["NET_CONNECTOR_INCLUDE_HOSTS", :list],
27
- "exclude_hosts" => ["NET_CONNECTOR_EXCLUDE_HOSTS", :list],
28
- "include_vendors" => ["NET_CONNECTOR_INCLUDE_VENDORS", :list],
29
- "vendor_overrides" => ["NET_CONNECTOR_VENDOR_OVERRIDES", :json],
30
- "host_overrides" => ["NET_CONNECTOR_HOST_OVERRIDES", :json],
31
- "device_rules" => ["NET_CONNECTOR_DEVICE_RULES", :json] },
32
- "ssh" => { "protocol" => ["NET_CONNECTOR_PROTOCOL", :string],
33
- "max_script_output_bytes" => ["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES", :optional_integer],
34
- "known_hosts" => ["NET_CONNECTOR_KNOWN_HOSTS", :string],
35
- "host_key_policy" => ["NET_CONNECTOR_HOST_KEY_POLICY", :string],
36
- "log_directory" => ["NET_CONNECTOR_LOG_DIRECTORY", :string],
37
- "log_level" => ["NET_CONNECTOR_LOG_LEVEL", :string] },
23
+ "backup" => { "directory" => ["NC_BACKUP_DIRECTORY", :string],
24
+ "concurrency" => ["NC_CONCURRENCY", :integer],
25
+ "limit_per_vendor" => ["NC_SAMPLE_PER_VENDOR", :integer] },
26
+ "inventory" => { "include_hosts" => ["NC_INCLUDE_HOSTS", :list],
27
+ "exclude_hosts" => ["NC_EXCLUDE_HOSTS", :list],
28
+ "include_vendors" => ["NC_INCLUDE_VENDORS", :list],
29
+ "vendor_overrides" => ["NC_VENDOR_OVERRIDES", :json],
30
+ "host_overrides" => ["NC_HOST_OVERRIDES", :json],
31
+ "device_rules" => ["NC_DEVICE_RULES", :json] },
32
+ "ssh" => { "protocol" => ["NC_PROTOCOL", :string],
33
+ "vendor_protocols" => ["NC_VENDOR_PROTOCOLS", :json],
34
+ "max_script_output_bytes" => ["NC_MAX_SCRIPT_OUTPUT_BYTES", :optional_integer],
35
+ "known_hosts" => ["NC_KNOWN_HOSTS", :string],
36
+ "host_key_policy" => ["NC_HOST_KEY_POLICY", :string],
37
+ "log_directory" => ["NC_LOG_DIRECTORY", :string],
38
+ "log_level" => ["NC_LOG_LEVEL", :string] },
38
39
  "tftp" => { "server" => ["TFTP_HOST", :string],
39
- "vrfs" => ["NET_CONNECTOR_TFTP_VRFS", :json],
40
- "h3c_source_file" => ["NET_CONNECTOR_H3C_TFTP_SOURCE_FILE", :string],
41
- "h3c_wireless_source_file" => ["NET_CONNECTOR_H3C_WIRELESS_TFTP_SOURCE_FILE", :string],
42
- "huawei_source_file" => ["NET_CONNECTOR_HUAWEI_TFTP_SOURCE_FILE", :string] }
40
+ "vrfs" => ["NC_TFTP_VRFS", :json],
41
+ "h3c_source_file" => ["NC_H3C_TFTP_SOURCE_FILE", :string],
42
+ "h3c_wireless_source_file" => ["NC_H3C_WIRELESS_TFTP_SOURCE_FILE", :string],
43
+ "huawei_source_file" => ["NC_HUAWEI_TFTP_SOURCE_FILE", :string] }
43
44
  }.freeze
44
45
 
45
46
  # 安全读取 YAML 配置并映射到环境变量格式。
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "client"
5
+
6
+ # 清单连接与设备凭据来源;业务入口负责规划和执行备份。
7
+ module Net
8
+ module Connector
9
+ module Netdisco
10
+ # 将调用方提供的输入解析为清单客户端和设备凭据 resolver。
11
+ class Connection
12
+ def self.build(settings, stdin_credentials: false, input: $stdin)
13
+ unless stdin_credentials
14
+ return [settings.client, settings.method(:device_credentials_for)]
15
+ end
16
+
17
+ begin
18
+ values = JSON.parse(input.gets || "")
19
+ rescue JSON::ParserError
20
+ raise ArgumentError, "凭据 JSON 无效", cause: nil
21
+ end
22
+ keys = %w[netdisco_username netdisco_password device_username device_password]
23
+ raise ArgumentError, "需要四个非空凭据字符串" unless values.is_a?(Hash) && keys.all? { |key| values[key].is_a?(String) && !values[key].strip.empty? }
24
+
25
+ client = settings.client(credentials: {
26
+ username: values.fetch("netdisco_username"), password: values.fetch("netdisco_password")
27
+ })
28
+ credentials = lambda do |device|
29
+ inherited = device ? settings.device_credentials_for(device) : nil
30
+ (inherited || {}).merge(username: values.fetch("device_username"),
31
+ password: values.fetch("device_password"))
32
+ end
33
+ [client, credentials]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -37,7 +37,7 @@ module Net
37
37
 
38
38
  def initialize(connection_options:, **options)
39
39
  @options = self.class.options(**options)
40
- @connection_options = validate_connection_options(connection_options)
40
+ @connection_options = validate_connection_options!(connection_options)
41
41
  end
42
42
 
43
43
  # 每次查询独占连接和游标;只有全部结果验证成功后才把清单交给 Fleet。
@@ -103,7 +103,7 @@ module Net
103
103
  end
104
104
  end
105
105
 
106
- def validate_connection_options(options)
106
+ def validate_connection_options!(options)
107
107
  unless options.is_a?(Hash) && (options.keys - CONNECTION_ENV.keys).empty?
108
108
  raise ArgumentError, "connection_options must contain supported PostgreSQL connection keys"
109
109
  end
@@ -44,7 +44,17 @@ module Net
44
44
  def ready? = issue.nil?
45
45
 
46
46
  # 生成不随名称或厂商信息改变的本地文本文件名。
47
- def backup_filename = Storage::SavedConfig.filename(host)
47
+ def backup_filename(style: :ip)
48
+ address = Storage::SavedConfig.filename(host)
49
+ return address if style == :ip
50
+ raise ArgumentError, "filename style must be ip or hostname_ip" unless style == :hostname_ip
51
+
52
+ label = name.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "-")
53
+ .gsub(/[^\p{L}\p{N}._-]+/, "-").byteslice(0, 160).scrub("")
54
+ .gsub(/\A[._-]+|[._-]+\z/, "")
55
+ label = "unnamed" if label.empty?
56
+ "#{label}-#{address}"
57
+ end
48
58
 
49
59
  # 生成设备 CLI 可用的 ASCII 远端文件名。
50
60
  def tftp_filename
@@ -13,7 +13,7 @@ module Net
13
13
 
14
14
  # 组装清单客户端、规则、凭据与结果存储。
15
15
  def initialize(client: nil, settings: Settings.new, rules: nil, credentials: nil, connector_factory: nil,
16
- result_store: ResultStore::Text.new)
16
+ result_store: ResultStore::Json.new)
17
17
  @settings = settings
18
18
  @client = client
19
19
  @rules = rules
@@ -35,46 +35,53 @@ module Net
35
35
  end
36
36
 
37
37
  # 规划 TFTP 配置备份的设备集合。
38
- def plan_tftp_backup(limit_per_vendor: 5)
39
- build_plan(:tftp, limit_per_vendor, @settings.snapshot(mode: :tftp))
38
+ def plan_tftp_backup(limit_per_vendor: 5, allow_fixed_name_reuse: false)
39
+ build_plan(:tftp, limit_per_vendor, @settings.snapshot(mode: :tftp),
40
+ allow_fixed_name_reuse: allow_fixed_name_reuse)
40
41
  end
41
42
 
42
43
  # 先拉取完整清单,再并发采集并保存设备配置。
43
44
  def backup_all(directory: DEFAULT_SETTING, concurrency: DEFAULT_SETTING,
44
45
  limit_per_vendor: nil, plan: nil, on_start: nil, on_result: nil, on_change: nil,
45
- success_policy: :strict)
46
- reporting = Report.validate_policy!(success_policy)
47
- policy = @settings.snapshot(mode: :backup)
48
- directory = policy.backup_directory if directory.equal?(DEFAULT_SETTING)
49
- concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
46
+ success_policy: :strict, filename_style: :ip)
47
+ raise ArgumentError, "filename style must be ip or hostname_ip" unless %i[ip hostname_ip].include?(filename_style)
48
+
49
+ Report.validate_policy!(success_policy)
50
+ raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified
51
+ settings_snapshot = @settings.snapshot(mode: :backup)
52
+ directory = settings_snapshot.backup_directory if directory.equal?(DEFAULT_SETTING)
53
+ concurrency = settings_snapshot.concurrency if concurrency.equal?(DEFAULT_SETTING)
50
54
  raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
51
- Worker.new(concurrency: concurrency)
55
+ Worker.validate_concurrency!(concurrency)
52
56
  Planner.validate_limit!(limit_per_vendor)
53
57
  validate_callback!(on_result, :on_result)
54
58
  validate_callback!(on_start, :on_start)
55
59
  validate_callback!(on_change, :on_change)
56
60
 
57
- plan ||= build_plan(:backup, limit_per_vendor, policy)
61
+ plan ||= build_plan(:backup, limit_per_vendor, settings_snapshot)
58
62
  validate_plan!(plan, :backup)
59
63
  callbacks = [on_result]
60
- callbacks << ->(item) { on_change.call(item) if item.backup.is_a?(Backup) && item.backup.changed? } if on_change
64
+ callbacks << ->(outcome) { on_change.call(outcome) if outcome.backup.is_a?(Backup) && outcome.backup.changed? } if on_change
61
65
  target_directory = File.expand_path(directory)
62
66
  run_batch(:backup, plan, concurrency: concurrency, report_directory: directory,
63
- output_directory: target_directory, policy: policy, reporting: reporting,
67
+ output_directory: target_directory, settings_snapshot: settings_snapshot, success_policy: success_policy,
64
68
  on_start: on_start, on_result: callbacks.compact) do |device, log_directory|
65
- backup_one(device, target_directory, log_directory, policy)
69
+ backup_device(device, target_directory, log_directory, settings_snapshot, filename_style: filename_style)
66
70
  end
67
71
  end
68
72
 
69
73
  # 让设备主动导出配置,在执行前检查同批目标文件名冲突。
70
74
  def tftp_backup_all(server:, source_files: {}, concurrency: DEFAULT_SETTING,
71
75
  limit_per_vendor: 5, vrfs: {}, on_start: nil, on_result: nil, plan: nil,
72
- report_directory: DEFAULT_SETTING, success_policy: :strict)
73
- reporting = Report.validate_policy!(success_policy)
74
- policy = @settings.snapshot(mode: :tftp)
75
- concurrency = policy.concurrency if concurrency.equal?(DEFAULT_SETTING)
76
- report_directory = policy.backup_directory if report_directory.equal?(DEFAULT_SETTING)
77
- Worker.new(concurrency: concurrency)
76
+ report_directory: DEFAULT_SETTING, success_policy: :strict, verification_root: nil,
77
+ preserve_history: false)
78
+ Report.validate_policy!(success_policy)
79
+ raise ArgumentError, "verified policy requires a local TFTP directory" if success_policy == :verified && !verification_root
80
+ verification = TftpVerification.new(root: verification_root)
81
+ settings_snapshot = @settings.snapshot(mode: :tftp)
82
+ concurrency = settings_snapshot.concurrency if concurrency.equal?(DEFAULT_SETTING)
83
+ report_directory = settings_snapshot.backup_directory if report_directory.equal?(DEFAULT_SETTING)
84
+ Worker.validate_concurrency!(concurrency)
78
85
  server = TftpTarget.new(host: server, path: "preflight.cfg").host
79
86
  raise ArgumentError, "source_files must be a Hash" unless source_files.is_a?(Hash)
80
87
  source_files.each_value { |source_file| TftpTarget.validate_source_file!(source_file) }
@@ -84,40 +91,53 @@ module Net
84
91
  validate_callback!(on_result, :on_result)
85
92
  validate_callback!(on_start, :on_start)
86
93
 
87
- plan ||= build_plan(:tftp, limit_per_vendor, policy)
94
+ plan ||= build_plan(:tftp, limit_per_vendor, settings_snapshot,
95
+ allow_fixed_name_reuse: preserve_history && !verification_root.nil?)
88
96
  validate_plan!(plan, :tftp)
97
+ if plan.allow_fixed_name_reuse && (!preserve_history || !verification_root)
98
+ raise ArgumentError, "fixed-name TFTP uploads require local history archival"
99
+ end
100
+ archive = TftpArchive.new(directory: report_directory, root: verification_root) if preserve_history
89
101
  run_batch(:tftp, plan, concurrency: concurrency, report_directory: report_directory,
90
- policy: policy, reporting: reporting, on_start: on_start, on_result: on_result) do |device, log_directory|
91
- tftp_backup_one(device, server, source_files, log_directory, vrfs, policy)
102
+ settings_snapshot: settings_snapshot, success_policy: success_policy, on_start: on_start, on_result: on_result,
103
+ verification: preserve_history ? nil : verification) do |device, log_directory|
104
+ if archive
105
+ archive.upload_and_archive(device, started_at: Time.now.utc) do |remote_filename|
106
+ tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot, path: remote_filename)
107
+ end
108
+ else
109
+ tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot)
110
+ end
92
111
  end
93
112
  end
94
113
 
95
114
  private
96
115
 
97
116
  # 策略在清单请求之前固定;注入的清单客户端仍由调用方负责其自身生命周期。
98
- def inventory(policy)
99
- rows = (@client || @settings.client(policy: policy)).devices
117
+ def inventory(settings_snapshot)
118
+ rows = (@client || @settings.client(policy: settings_snapshot)).devices
100
119
  raise Client::Error, "Netdisco returned an invalid device inventory" unless rows.is_a?(Array)
101
120
 
102
- rules = @rules || policy.rules
121
+ rules = @rules || settings_snapshot.rules
103
122
  devices = rows.map { |row| Device.from_row(row, rules: rules) }
104
123
  duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
105
124
  devices.map { |device| duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device }.freeze
106
125
  end
107
126
 
108
- def build_plan(mode, limit, policy)
127
+ def build_plan(mode, limit, settings_snapshot, allow_fixed_name_reuse: false)
109
128
  Planner.validate_limit!(limit)
110
- Planner.new(inventory(policy)).call(mode: mode, limit_per_vendor: limit)
129
+ Planner.new(inventory(settings_snapshot)).call(mode: mode, limit_per_vendor: limit,
130
+ allow_fixed_name_reuse: allow_fixed_name_reuse)
111
131
  end
112
132
 
113
133
  # 统一组织设备任务、日志目录与报告;Worker 保证单台设备失败不影响其他设备。
114
- def run_batch(mode, plan, concurrency:, report_directory:, policy:, reporting:, on_start:, on_result:, output_directory: nil)
134
+ def run_batch(mode, plan, concurrency:, report_directory:, settings_snapshot:, success_policy:, on_start:, on_result:, output_directory: nil, verification: nil)
115
135
  worker = Worker.new(concurrency: concurrency)
116
136
  started_at = Time.now.utc
117
137
  started = monotonic
118
138
  outcomes = plan.outcomes.dup
119
139
  FileUtils.mkdir_p(output_directory, mode: 0o700) if output_directory && !plan.ready.empty?
120
- log_directory = policy.log_directory
140
+ log_directory = settings_snapshot.log_directory
121
141
  FileUtils.mkdir_p(log_directory, mode: 0o700) if log_directory && !plan.ready.empty?
122
142
  callback_errors = worker.run(plan.ready, outcomes: outcomes, on_start: on_start, on_result: on_result,
123
143
  on_error: ->(device, error) { build_outcome(device, :failed, error: error) }) do |device|
@@ -126,8 +146,9 @@ module Net
126
146
  batch = Batch.new(mode: mode, outcomes: outcomes.freeze, started_at: started_at,
127
147
  finished_at: Time.now.utc, callback_errors: callback_errors,
128
148
  report_location: nil, report_error: nil)
129
- batch = Report.new(batch, policy: reporting, duration_ms: ((monotonic - started) * 1000).round)
130
- save_report(batch, directory: report_directory)
149
+ report = Report.new(batch, policy: success_policy, duration_ms: ((monotonic - started) * 1000).round, log_directory: log_directory)
150
+ report = verification.call(report) if verification
151
+ save_report(report, directory: report_directory)
131
152
  end
132
153
 
133
154
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -138,16 +159,16 @@ module Net
138
159
  end
139
160
 
140
161
  # 保存批量报告,记录写入失败而不覆盖设备结果。
141
- def save_report(batch, directory:)
142
- return batch unless @result_store
162
+ def save_report(report, directory:)
163
+ return report unless @result_store
143
164
 
144
- batch.with(report_location: @result_store.write(batch, directory: directory))
165
+ report.with(report_location: @result_store.write(report, directory: directory))
145
166
  rescue Storage::PrivateFile::WriteError => error
146
167
  # 报告也可能已经原子替换;保留已知位置,不将持久性失败误写成完全没有产物。
147
168
  location = error.receipt.path if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed?
148
- batch.with_report_error(error, location: location)
169
+ report.with_report_error(error, location: location)
149
170
  rescue StandardError => error
150
- batch.with_report_error(error)
171
+ report.with_report_error(error)
151
172
  end
152
173
 
153
174
  # 确认传入计划属于当前备份模式。
@@ -165,30 +186,30 @@ module Net
165
186
  end
166
187
 
167
188
  # 执行单台设备的 TFTP 导出。
168
- def tftp_backup_one(device, server, source_files, log_directory, vrfs, policy)
169
- run_one(device, log_directory, policy, success_status: :reported_uploaded,
170
- close_error_status: :reported_with_error, backup_class: TftpReceipt) do |connector|
189
+ def tftp_backup_device(device, server, source_files, log_directory, vrfs, settings_snapshot, path: device.tftp_filename)
190
+ run_device(device, log_directory, settings_snapshot, success_status: :reported_uploaded,
191
+ partial_status: :reported_with_error, backup_class: TftpReceipt) do |connector|
171
192
  source_file = source_files.fetch(device.vendor, nil)
172
- transfer_settings = { host: server, path: device.tftp_filename, source_file: source_file }
193
+ transfer_settings = { host: server, path: path, source_file: source_file }
173
194
  transfer_settings[:vrf] = vrfs.fetch(device.vendor) if vrfs.key?(device.vendor)
174
195
  connector.tftp_backup(**transfer_settings)
175
196
  end
176
197
  end
177
198
 
178
199
  # 执行单台设备的本地配置采集。
179
- def backup_one(device, directory, log_directory, policy)
180
- destination = File.join(directory, device.backup_filename)
200
+ def backup_device(device, directory, log_directory, settings_snapshot, filename_style: :ip)
201
+ destination = File.join(directory, device.backup_filename(style: filename_style))
181
202
  Storage::BackupLock.synchronize(destination, host: device.host) do |path_lock|
182
203
  Storage::SafeFile.open(destination, missing: true) { |_file, _stat| nil }
183
- run_one(device, log_directory, policy, success_status: :backed_up,
184
- close_error_status: :saved_with_error, backup_class: Backup) do |connector|
204
+ run_device(device, log_directory, settings_snapshot, success_status: :backed_up,
205
+ partial_status: :saved_with_error, backup_class: Backup) do |connector|
185
206
  path_lock.with_delegated_lock { connector.backup(path: destination) }
186
207
  end
187
208
  end
188
209
  end
189
210
 
190
211
  # 隔离单台设备的凭据、连接、操作和关闭异常。
191
- def run_one(device, log_directory, policy, success_status:, close_error_status:, backup_class:)
212
+ def run_device(device, log_directory, settings_snapshot, success_status:, partial_status:, backup_class:)
192
213
  connection_settings = @credentials ? @credentials.call(device) : @settings.device_credentials_for(device)
193
214
  return build_outcome(device, :missing_credentials) if connection_settings.nil?
194
215
  unless connection_settings.is_a?(Hash) && connection_settings.keys.all?(Symbol) &&
@@ -197,7 +218,7 @@ module Net
197
218
  raise ArgumentError, "credential resolver must return settings with username and without host"
198
219
  end
199
220
 
200
- connection_settings = policy.connection_options(device.vendor).merge(connection_settings)
221
+ connection_settings = settings_snapshot.connection_options(device.vendor).merge(connection_settings)
201
222
  log_name = device.host.tr(":", "_")
202
223
  if log_directory
203
224
  connection_settings = connection_settings.merge(log_file: File.join(log_directory, "#{log_name}.log"))
@@ -206,7 +227,7 @@ module Net
206
227
  backup, failure = perform_one(connector, backup_class) { yield connector }
207
228
  return build_outcome(device, success_status, backup: backup) unless failure
208
229
 
209
- build_outcome(device, backup ? close_error_status : :failed, backup: backup, error: failure)
230
+ build_outcome(device, backup ? partial_status : :failed, backup: backup, error: failure)
210
231
  rescue StandardError => error
211
232
  build_outcome(device, :failed, error: error)
212
233
  end