net-connector 0.7.3 → 0.9.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/CONTRIBUTING.md +12 -0
  4. data/README.md +27 -424
  5. data/README.zh-CN.md +64 -0
  6. data/{exe/net-connector-backup → bin/net-backup} +1 -0
  7. data/docs/VERIFICATION.md +2 -2
  8. data/docs/architecture.md +27 -5
  9. data/docs/netdisco.md +176 -0
  10. data/docs/netdisco.zh-CN.md +247 -0
  11. data/docs/usage.md +124 -0
  12. data/docs/usage.zh-CN.md +190 -0
  13. data/examples/backup.yml +5 -4
  14. data/examples/inventory_sql.yml +5 -1
  15. data/lib/net/connector/device/base.rb +12 -5
  16. data/lib/net/connector/device/local_backup.rb +3 -0
  17. data/lib/net/connector/device/running_config/strategy.rb +2 -1
  18. data/lib/net/connector/device/running_config.rb +2 -0
  19. data/lib/net/connector/device/tftp/receipt.rb +1 -0
  20. data/lib/net/connector/device/tftp/strategy.rb +28 -9
  21. data/lib/net/connector/device/tftp.rb +23 -16
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
  23. data/lib/net/connector/engine/authentication.rb +1 -0
  24. data/lib/net/connector/engine/core.rb +1 -0
  25. data/lib/net/connector/engine/dialogue.rb +5 -134
  26. data/lib/net/connector/engine/errors.rb +0 -92
  27. data/lib/net/connector/engine/known_hosts.rb +20 -4
  28. data/lib/net/connector/engine/log.rb +27 -6
  29. data/lib/net/connector/engine/recovery.rb +1 -1
  30. data/lib/net/connector/engine/redactor.rb +98 -0
  31. data/lib/net/connector/engine/response_reader.rb +139 -0
  32. data/lib/net/connector/engine/session.rb +20 -7
  33. data/lib/net/connector/engine/transport.rb +21 -10
  34. data/lib/net/connector/netdisco/backup_run.rb +35 -8
  35. data/lib/net/connector/netdisco/cli/options.rb +20 -8
  36. data/lib/net/connector/netdisco/cli.rb +14 -11
  37. data/lib/net/connector/netdisco/config_file.rb +19 -14
  38. data/lib/net/connector/netdisco/connection.rb +0 -1
  39. data/lib/net/connector/netdisco/database_client.rb +44 -26
  40. data/lib/net/connector/netdisco/device.rb +5 -0
  41. data/lib/net/connector/netdisco/diagnostic.rb +8 -0
  42. data/lib/net/connector/netdisco/fleet.rb +12 -3
  43. data/lib/net/connector/netdisco/plan.rb +16 -3
  44. data/lib/net/connector/netdisco/planner.rb +2 -1
  45. data/lib/net/connector/netdisco/progress.rb +31 -7
  46. data/lib/net/connector/netdisco/report/files.rb +4 -2
  47. data/lib/net/connector/netdisco/result_store.rb +6 -7
  48. data/lib/net/connector/netdisco/rules.rb +8 -5
  49. data/lib/net/connector/netdisco/settings.rb +83 -19
  50. data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
  51. data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
  52. data/lib/net/connector/netdisco/worker.rb +3 -1
  53. data/lib/net/connector/netdisco.rb +1 -21
  54. data/lib/net/connector/storage/private_file.rb +6 -0
  55. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  56. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
  57. data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
  58. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
  59. data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
  60. data/lib/net/connector/vendor/h3c/topology.rb +2 -0
  61. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  62. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
  63. data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
  64. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
  65. data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
  66. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
  67. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
  68. data/lib/net/connector/vendor/radware/running_config.rb +1 -0
  69. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
  70. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  71. data/lib/net/connector/version.rb +2 -1
  72. data/lib/net/connector.rb +1 -0
  73. data/lib/net-connector.rb +1 -0
  74. metadata +12 -20
  75. data/lib/net/connector/netdisco/tftp_history.rb +0 -16
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../settings"
5
+ require_relative "../planner"
6
+ require_relative "../config_file"
4
7
 
5
8
  module Net
6
9
  module Connector
@@ -8,6 +11,7 @@ module Net
8
11
  class CLI
9
12
  # 可注入参数与输入输出的备份选项,不退出宿主进程。
10
13
  class Options
14
+ # 复制参数数组,返回选项或帮助标记;非法输入抛错,不退出宿主进程。
11
15
  def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
12
16
  values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
13
17
  parser = parser_for(values, output: output, program: program)
@@ -19,7 +23,7 @@ module Net
19
23
  raise ArgumentError, "--stdin-credentials 不能与命令行凭据或密码提示同时使用"
20
24
  end
21
25
  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)
26
+ read_password(values, :ask_netdisco_password, "ND_PASSWORD", "Netdisco 密码", input: input, output: error)
23
27
 
24
28
  values
25
29
  rescue OptionParser::ParseError
@@ -33,6 +37,7 @@ module Net
33
37
  add_execution(opts, values)
34
38
  opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
35
39
  opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
40
+ opts.on("--no-progress", "关闭进度显示") { values[:no_progress] = true }
36
41
  opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
37
42
  opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
38
43
  opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
@@ -40,6 +45,7 @@ module Net
40
45
  end
41
46
  end
42
47
 
48
+ # 正式 CLI 与批次示例共用并发、抽样、核验和成功策略选项。
43
49
  def self.add_execution(opts, values)
44
50
  opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
45
51
  raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
@@ -57,14 +63,17 @@ module Net
57
63
  end
58
64
  end
59
65
 
66
+ # 公开 CLI 参数进入 overrides,凭据留在 environment;内部键不会重新开放已移除环境设置。
60
67
  def self.settings(options, env: ENV)
61
68
  raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
62
69
 
63
70
  environment = environment(options, env: env)
64
71
  path = environment["NC_CONFIG"]
65
72
  defaults = path && !path.empty? ? ConfigFile.load(path) : {}
66
- overrides = {}
67
- { source: "NETDISCO_SOURCE", query: "NETDISCO_QUERY", query_params: "NETDISCO_QUERY_PARAMS",
73
+ keys = ConfigFile::FIELDS.values.flat_map { |fields| fields.values.map(&:first) }
74
+ overrides = options.fetch(:environment).slice(*keys)
75
+ environment = environment.reject { |key, _| overrides.key?(key) && !Settings::ENVIRONMENT_KEYS.include?(key) }
76
+ { source: "ND_SOURCE", query: "ND_QUERY", query_params: "ND_QUERY_PARAMS",
68
77
  max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
69
78
  limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
70
79
  overrides[key] = options[option].to_s if options.key?(option)
@@ -73,14 +82,15 @@ module Net
73
82
  Settings.new(env: environment, defaults: defaults, overrides: overrides)
74
83
  end
75
84
 
85
+ # 共用配置与凭据参数登记;具体入口决定后续预览、执行或导出。
76
86
  def self.add_settings(opts, values)
77
87
  {
78
88
  "--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
79
89
  "--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
80
90
  "--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 密码"],
91
+ "--netdisco-url URL" => ["ND_URL", "Netdisco 地址"],
92
+ "--netdisco-username USER" => ["ND_USERNAME", "Netdisco 用户名"],
93
+ "--netdisco-password PASSWORD" => ["ND_PASSWORD", "Netdisco 密码"],
84
94
  "--directory DIR" => ["NC_BACKUP_DIRECTORY", "备份目录,相对项目根目录"],
85
95
  "--config FILE" => ["NC_CONFIG", "YAML 配置,相对项目根目录"]
86
96
  }.each do |flag, (key, description)|
@@ -98,6 +108,7 @@ module Net
98
108
  end
99
109
  end
100
110
 
111
+ # 复制原环境并合并显式凭据,清除被通用凭据覆盖的厂商值及旧 API key。
101
112
  def self.environment(options, env: ENV)
102
113
  result = env.to_h.dup
103
114
  overrides = options.fetch(:environment)
@@ -111,12 +122,13 @@ module Net
111
122
  (suffix != "PASSWORD" || !name.end_with?("_ENABLE_PASSWORD"))
112
123
  end
113
124
  end
114
- if overrides.key?("NETDISCO_USERNAME") || overrides.key?("NETDISCO_PASSWORD")
115
- result.delete("NETDISCO_API_KEY")
125
+ if overrides.key?("ND_USERNAME") || overrides.key?("ND_PASSWORD")
126
+ result.delete("ND_API_KEY")
116
127
  end
117
128
  result.merge(overrides)
118
129
  end
119
130
 
131
+ # 隐藏输入只用于交互终端;与显式密码互斥,不输出或记录密码内容。
120
132
  def self.read_password(values, flag, key, label, input:, output:)
121
133
  return unless values[flag]
122
134
 
@@ -3,6 +3,9 @@
3
3
  require "json"
4
4
  require "ipaddr"
5
5
  require "optparse"
6
+ require_relative "../version"
7
+ require_relative "fleet"
8
+ require_relative "cli/options"
6
9
 
7
10
  module Net
8
11
  module Connector
@@ -36,7 +39,7 @@ module Net
36
39
  return 0
37
40
  end
38
41
  if options[:tftp] && !options[:plan] && !settings.tftp_server
39
- raise ArgumentError, "TFTP_HOST is required"
42
+ raise ArgumentError, "YAML tftp.server is required"
40
43
  end
41
44
 
42
45
  fleet = @fleet_factory.call(settings)
@@ -46,29 +49,29 @@ module Net
46
49
  return 0
47
50
  end
48
51
 
49
- batch = backup(fleet, plan, settings, options)
50
- summary = batch.summary
52
+ report = backup(fleet, plan, settings, options)
53
+ summary = report.summary
51
54
  @output.puts JSON.pretty_generate(summary)
52
- batch.policy_success? ? 0 : 1
55
+ report.policy_success? ? 0 : 1
53
56
  rescue Storage::PrivateFile::WriteError => exception
54
57
  message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
55
- @error.puts "net-connector-backup: #{message}"
58
+ @error.puts "net-backup: #{message}"
56
59
  2
57
60
  rescue OptionParser::ParseError
58
- @error.puts "net-connector-backup: 参数无效;使用 --help 查看参数"
61
+ @error.puts "net-backup: 参数无效;使用 --help 查看参数"
59
62
  2
60
63
  rescue ArgumentError, KeyError, Errno::ENOENT,
61
64
  Psych::Exception, JSON::ParserError, Client::Error => exception
62
- @error.puts "net-connector-backup: #{exception.message}"
65
+ @error.puts "net-backup: #{exception.message}"
63
66
  2
64
67
  rescue StandardError => exception
65
- @error.puts "net-connector-backup 失败(#{exception.class})"
68
+ @error.puts "net-backup 失败(#{exception.class})"
66
69
  2
67
70
  end
68
71
 
69
72
  private
70
73
 
71
- # 文件、环境变量、命令行按优先级覆盖,凭据仍只来自环境变量。
74
+ # 公开 CLI 参数覆盖 YAML 与 ND_URL;设备凭据按环境或显式 CLI 参数解析。
72
75
  def settings_for(options)
73
76
  Options.settings(options, env: @env)
74
77
  end
@@ -102,7 +105,7 @@ module Net
102
105
  fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
103
106
  concurrency: settings.concurrency,
104
107
  vrfs: settings.tftp_vrfs,
105
- source_files: settings.tftp_source_files, verification_root: options[:tftp_root] || @env["TFTP_ROOT"], **reporting)
108
+ source_files: settings.tftp_source_files, verification_root: options[:tftp_root] || settings.tftp_root, **reporting)
106
109
  else
107
110
  fleet.backup_all(plan: plan, directory: settings.backup_directory,
108
111
  concurrency: settings.concurrency, **reporting)
@@ -113,7 +116,7 @@ module Net
113
116
  def parse_options
114
117
  options = { environment: {} }
115
118
  parser = OptionParser.new do |args|
116
- args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
119
+ args.banner = "用法:net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
117
120
  Options.add_settings(args, options)
118
121
  Options.add_execution(args, options)
119
122
  args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
@@ -6,20 +6,24 @@ require "yaml"
6
6
  module Net
7
7
  module Connector
8
8
  module Netdisco
9
- # 命令行使用的无凭据 YAML 配置,环境变量优先。
9
+ # 无凭据 YAML 配置;仅 ND_URL 允许从进程环境覆盖,其他策略由 CLI 覆盖。
10
10
  class ConfigFile
11
11
  FIELDS = {
12
- "netdisco" => { "source" => ["NETDISCO_SOURCE", :string],
13
- "query" => ["NETDISCO_QUERY", :string],
14
- "query_params" => ["NETDISCO_QUERY_PARAMS", :json_array],
15
- "url" => ["NETDISCO_URL", :string],
16
- "page_size" => ["NETDISCO_PAGE_SIZE", :integer],
17
- "max_pages" => ["NETDISCO_MAX_PAGES", :integer],
18
- "max_response_bytes" => ["NETDISCO_MAX_RESPONSE_BYTES", :integer],
19
- "max_inventory_bytes" => ["NETDISCO_MAX_INVENTORY_BYTES", :integer],
20
- "max_devices" => ["NETDISCO_MAX_DEVICES", :integer],
21
- "inventory_timeout" => ["NETDISCO_INVENTORY_TIMEOUT", :number],
22
- "allow_insecure_http" => ["NETDISCO_ALLOW_INSECURE_HTTP", :boolean] },
12
+ "netdisco" => { "source" => ["ND_SOURCE", :string],
13
+ "query" => ["ND_QUERY", :string],
14
+ "query_params" => ["ND_QUERY_PARAMS", :json_array],
15
+ "url" => ["ND_URL", :string],
16
+ "page_size" => ["ND_PAGE_SIZE", :integer],
17
+ "max_pages" => ["ND_MAX_PAGES", :integer],
18
+ "max_response_bytes" => ["ND_MAX_RESPONSE_BYTES", :integer],
19
+ "max_inventory_bytes" => ["ND_MAX_INVENTORY_BYTES", :integer],
20
+ "max_devices" => ["ND_MAX_DEVICES", :integer],
21
+ "inventory_timeout" => ["ND_INVENTORY_TIMEOUT", :number],
22
+ "allow_insecure_http" => ["ND_ALLOW_INSECURE_HTTP", :boolean],
23
+ "db_port" => ["ND_DB_PORT", :integer],
24
+ "db_sslmode" => ["ND_DB_SSLMODE", :string],
25
+ "db_sslrootcert" => ["ND_DB_SSLROOTCERT", :string],
26
+ "db_connect_timeout" => ["ND_DB_CONNECT_TIMEOUT", :integer] },
23
27
  "backup" => { "directory" => ["NC_BACKUP_DIRECTORY", :string],
24
28
  "concurrency" => ["NC_CONCURRENCY", :integer],
25
29
  "limit_per_vendor" => ["NC_SAMPLE_PER_VENDOR", :integer] },
@@ -36,14 +40,15 @@ module Net
36
40
  "host_key_policy" => ["NC_HOST_KEY_POLICY", :string],
37
41
  "log_directory" => ["NC_LOG_DIRECTORY", :string],
38
42
  "log_level" => ["NC_LOG_LEVEL", :string] },
39
- "tftp" => { "server" => ["TFTP_HOST", :string],
43
+ "tftp" => { "server" => ["NC_TFTP_HOST", :string],
44
+ "verification_root" => ["NC_TFTP_ROOT", :string],
40
45
  "vrfs" => ["NC_TFTP_VRFS", :json],
41
46
  "h3c_source_file" => ["NC_H3C_TFTP_SOURCE_FILE", :string],
42
47
  "h3c_wireless_source_file" => ["NC_H3C_WIRELESS_TFTP_SOURCE_FILE", :string],
43
48
  "huawei_source_file" => ["NC_HUAWEI_TFTP_SOURCE_FILE", :string] }
44
49
  }.freeze
45
50
 
46
- # 安全读取 YAML 配置并映射到环境变量格式。
51
+ # 安全读取 YAML 配置并编码为 Settings 的内部键值。
47
52
  def self.load(path)
48
53
  raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
49
54
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "client"
5
4
 
6
5
  # 清单连接与设备凭据来源;业务入口负责规划和执行备份。
7
6
  module Net
@@ -8,15 +8,44 @@ module Net
8
8
  module Netdisco
9
9
  # SQL 负责业务选择和列别名;适配器只管理只读查询、清单预算与连接生命周期。
10
10
  class DatabaseClient
11
+ # 只有数据库身份从环境读取;非敏感连接策略的内部选项与诊断名称另行映射。
11
12
  CONNECTION_ENV = {
12
- host: "NETDISCO_DB_HOST", port: "NETDISCO_DB_PORT", dbname: "NETDISCO_DB_NAME",
13
- user: "NETDISCO_DB_USER", password: "NETDISCO_DB_PASS",
14
- sslmode: "NETDISCO_DB_SSLMODE", sslrootcert: "NETDISCO_DB_SSLROOTCERT",
15
- connect_timeout: "NETDISCO_DB_CONNECT_TIMEOUT"
13
+ host: "ND_DB_HOST", dbname: "ND_DB_NAME", user: "ND_DB_USER", password: "ND_DB_PASS"
16
14
  }.freeze
15
+ CONNECTION_OPTIONS = CONNECTION_ENV.merge(
16
+ port: "netdisco.db_port", sslmode: "netdisco.db_sslmode", sslrootcert: "netdisco.db_sslrootcert",
17
+ connect_timeout: "netdisco.db_connect_timeout"
18
+ ).freeze
17
19
  REQUIRED_CONNECTION_KEYS = %i[host dbname user password].freeze
20
+ POLICY_KEYS = (CONNECTION_OPTIONS.keys - REQUIRED_CONNECTION_KEYS).freeze
21
+ private_constant :POLICY_KEYS
18
22
  DEFAULTS = Client::DEFAULTS.except(:allow_insecure_http).freeze
19
23
 
24
+ # 预检与实际连接共用公开策略校验;不读取身份凭据,也不访问文件或数据库。
25
+ def self.connection_policy(**options)
26
+ raise ArgumentError, "unknown PostgreSQL connection policy" unless (options.keys - POLICY_KEYS).empty?
27
+
28
+ options.each do |key, value|
29
+ valid = value.is_a?(String) && !value.empty? && !value.include?("\0")
30
+ valid ||= %i[port connect_timeout].include?(key) && value.is_a?(Integer)
31
+ raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is invalid" unless valid
32
+ end
33
+ %i[port connect_timeout].each do |key|
34
+ next unless options.key?(key)
35
+
36
+ value = options.fetch(key).to_s
37
+ maximum = key == :port ? 65_535 : 2_147_483_647
38
+ unless value.match?(/\A\d+\z/) && value.to_i.between?(1, maximum)
39
+ raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} must be a positive integer within range"
40
+ end
41
+ end
42
+ if options[:sslmode] && !%w[disable allow prefer require verify-ca verify-full].include?(options[:sslmode])
43
+ raise ArgumentError, "netdisco.db_sslmode is invalid"
44
+ end
45
+ options.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
46
+ end
47
+
48
+ # 纯校验 SQL、绑定参数和清单预算,不连接数据库或打印查询内容。
20
49
  def self.options(query:, query_params: [], **values)
21
50
  unless query.is_a?(String) && !query.strip.empty? && !query.include?("\0")
22
51
  raise ArgumentError, "query must be a nonempty SQL string without NUL"
@@ -35,6 +64,7 @@ module Net
35
64
  ).freeze
36
65
  end
37
66
 
67
+ # 复制并校验查询与连接选项;真正连接由 devices 的资源作用域管理。
38
68
  def initialize(connection_options:, **options)
39
69
  @options = self.class.options(**options)
40
70
  @connection_options = validate_connection_options!(connection_options)
@@ -48,8 +78,10 @@ module Net
48
78
  connected = false
49
79
  begin
50
80
  Timeout.timeout(budget.remaining, Client::InventoryTimeout) do
51
- # 从握手开始持有连接;PG.connect 尚未返回时被超时中断,调用方无法负责关闭它。
52
- connection = PG::Connection.connect_start(@connection_options)
81
+ # 先取得连接句柄再交付超时,确保异常到达时 ensure 能显式关闭连接。
82
+ Thread.handle_interrupt(Client::InventoryTimeout => :never) do
83
+ connection = PG::Connection.connect_start(@connection_options)
84
+ end
53
85
  # PostgreSQL NOTICE 可能包含 SQL 或数据,不交给 libpq 默认的 stderr 输出器。
54
86
  connection.set_notice_processor { |_notice| nil }
55
87
  wait_for_connection(connection, budget)
@@ -103,36 +135,22 @@ module Net
103
135
  end
104
136
  end
105
137
 
138
+ # 数据库名不得嵌入 URI/conninfo;合法选项仍受清单总期限及只读查询边界约束。
106
139
  def validate_connection_options!(options)
107
- unless options.is_a?(Hash) && (options.keys - CONNECTION_ENV.keys).empty?
140
+ unless options.is_a?(Hash) && (options.keys - CONNECTION_OPTIONS.keys).empty?
108
141
  raise ArgumentError, "connection_options must contain supported PostgreSQL connection keys"
109
142
  end
110
143
  REQUIRED_CONNECTION_KEYS.each do |key|
111
144
  value = options[key]
112
145
  unless value.is_a?(String) && !value.strip.empty?
113
- raise ArgumentError, "#{CONNECTION_ENV.fetch(key)} is required"
114
- end
115
- end
116
- options.each do |key, value|
117
- valid = value.is_a?(String) && !value.empty? && !value.include?("\0")
118
- valid ||= %i[port connect_timeout].include?(key) && value.is_a?(Integer)
119
- raise ArgumentError, "#{CONNECTION_ENV.fetch(key)} is invalid" unless valid
120
- end
121
- %i[port connect_timeout].each do |key|
122
- next unless options.key?(key)
123
-
124
- value = options.fetch(key).to_s
125
- maximum = key == :port ? 65_535 : 2_147_483_647
126
- unless value.match?(/\A\d+\z/) && value.to_i.between?(1, maximum)
127
- raise ArgumentError, "#{CONNECTION_ENV.fetch(key)} must be a positive integer within range"
146
+ raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is required"
128
147
  end
148
+ raise ArgumentError, "#{CONNECTION_OPTIONS.fetch(key)} is invalid" if value.include?("\0")
129
149
  end
130
- if options[:sslmode] && !%w[disable allow prefer require verify-ca verify-full].include?(options[:sslmode])
131
- raise ArgumentError, "NETDISCO_DB_SSLMODE is invalid"
132
- end
150
+ self.class.connection_policy(**options.slice(*POLICY_KEYS))
133
151
  # libpq 会把 dbname 中的 URI/conninfo 再解释为连接参数,不能借此绕过凭据来源。
134
152
  if options.fetch(:dbname).match?(/=|\Apostgres(?:ql)?:\/\//)
135
- raise ArgumentError, "NETDISCO_DB_NAME must be a database name, not a connection string"
153
+ raise ArgumentError, "ND_DB_NAME must be a database name, not a connection string"
136
154
  end
137
155
  options.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
138
156
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ipaddr"
4
+ require_relative "../../connector"
4
5
  require_relative "../storage/saved_config"
6
+ require_relative "client"
5
7
 
6
8
  module Net
7
9
  module Connector
@@ -15,6 +17,9 @@ module Net
15
17
  super(*frozen_values, **frozen_attributes)
16
18
  end
17
19
 
20
+ # Ruby 3.2 的 Data#with 不调用初始化;更新也须复制并冻结外部字符串。
21
+ def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
22
+
18
23
  # 将 Netdisco 清单记录转换为可规划的设备对象。
19
24
  def self.from_row(row, rules:)
20
25
  valid = row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].empty? &&
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../engine/error_metadata"
4
+ require_relative "../engine/errors"
5
+ require_relative "../device/local_backup"
6
+ require_relative "../device/tftp/receipt"
7
+ require_relative "../storage/private_file"
4
8
 
5
9
  module Net
6
10
  module Connector
@@ -26,6 +30,9 @@ module Net
26
30
  verification: verification == :device_reported ? verification : nil)
27
31
  end
28
32
 
33
+ # 更新也经过初始化白名单;Ruby 3.2 的原生 Data#with 会跳过这一边界。
34
+ def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
35
+
29
36
  # 读取已知错误的白名单属性后立即丢弃异常引用,不把回调对象保留进批次。
30
37
  def self.from(error, backup: nil, phase: nil)
31
38
  return unless error
@@ -40,6 +47,7 @@ module Net
40
47
  new(error_type: "StandardError", phase: phase)
41
48
  end
42
49
 
50
+ # 只有本库错误与匹配的业务回执才能证明提交状态,不能采信任意对象自报成功。
43
51
  def self.completion_fields(error, backup, phase)
44
52
  if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
45
53
  { artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
@@ -1,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "digest"
4
+ require_relative "../storage"
5
+ require_relative "client"
6
+ require_relative "settings"
7
+ require_relative "device"
8
+ require_relative "worker"
9
+ require_relative "result_store"
10
+ require_relative "planner"
11
+ require_relative "report"
12
+ require_relative "tftp_verification"
13
+ require_relative "tftp_archive"
5
14
 
6
15
  module Net
7
16
  module Connector
@@ -120,8 +129,8 @@ module Net
120
129
 
121
130
  rules = @rules || settings_snapshot.rules
122
131
  devices = rows.map { |row| Device.from_row(row, rules: rules) }
123
- duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
124
- devices.map { |device| duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device }.freeze
132
+ host_counts = devices.each_with_object(Hash.new(0)) { |device, counts| counts[device.host] += 1 if device.host }
133
+ devices.map { |device| host_counts[device.host] > 1 ? device.with(issue: :duplicate_host) : device }.freeze
125
134
  end
126
135
 
127
136
  def build_plan(mode, limit, settings_snapshot, allow_fixed_name_reuse: false)
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+ require_relative "device"
5
+ require_relative "batch"
6
+
3
7
  module Net
4
8
  module Connector
5
9
  module Netdisco
@@ -21,6 +25,9 @@ module Net
21
25
  # 按清单顺序返回选中的设备。
22
26
  def selected = ready.map(&:last)
23
27
 
28
+ # 更新与新建共用容器复制边界,不能因 Ruby 版本差异共享可变任务槽位。
29
+ def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
30
+
24
31
  # 在任何设备 I/O 前校验完整快照,外部构造或修改的计划也必须遵守相同约束。
25
32
  def validate!
26
33
  raise ArgumentError, "plan mode must be backup or tftp" unless %i[backup tftp].include?(mode)
@@ -68,10 +75,16 @@ module Net
68
75
 
69
76
  # 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
70
77
  def validate_duplicate_hosts!
71
- groups = inventory.group_by { |device| device.host && IPAddr.new(device.host).to_s }
72
- return unless groups.any? { |host, devices| host && devices.size > 1 && devices.any? { |device| device.issue != :duplicate_host } }
78
+ issues_by_host = {}
79
+ inventory.each do |device|
80
+ next unless device.host
73
81
 
74
- raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
82
+ host = IPAddr.new(device.host).to_s
83
+ if issues_by_host.key?(host) && (issues_by_host[host] != :duplicate_host || device.issue != :duplicate_host)
84
+ raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
85
+ end
86
+ issues_by_host[host] = device.issue
87
+ end
75
88
  end
76
89
 
77
90
  # 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "plan"
4
+ require_relative "batch"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -20,7 +21,7 @@ module Net
20
21
  end
21
22
  selected_tasks = if limit_per_vendor
22
23
  ready_tasks.group_by { |_index, device| device.vendor }.values.flat_map do |tasks|
23
- tasks.sort_by { |_index, device| device.host }.first(limit_per_vendor)
24
+ tasks.min_by(limit_per_vendor) { |_index, device| device.host }
24
25
  end
25
26
  else
26
27
  ready_tasks
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require_relative "../engine/error_metadata"
5
+ require_relative "../engine/log/event"
4
6
 
5
7
  module Net
6
8
  module Connector
7
9
  module Netdisco
8
10
  # 共享一个实例接收并发会话事件和任务回调;人类进度不混入 JSON 输出。
9
11
  class Progress
12
+ attr_reader :output_error
13
+
14
+ # 调用方保留输出 IO 所有权;同一个互斥锁保护心跳、事件和任务计数。
10
15
  def initialize(io: $stderr, enabled: true, verbose: false)
11
16
  @io, @enabled, @verbose = io, enabled, verbose
12
17
  @tty = io.respond_to?(:tty?) && io.tty?
@@ -23,6 +28,7 @@ module Net
23
28
  write(nil, "正在读取 Netdisco 设备清单")
24
29
  end
25
30
 
31
+ # 以实际选中设备初始化进度,清单中被跳过的条目不计入执行任务总数。
26
32
  def plan(plan, concurrency:, limit_per_vendor: nil)
27
33
  @mutex.synchronize do
28
34
  @total = plan.ready.size
@@ -33,6 +39,7 @@ module Net
33
39
  end
34
40
  end
35
41
 
42
+ # 任务开始时记录单调时钟与登录阶段,不把开始通知当作连接成功。
36
43
  def start(device)
37
44
  @mutex.synchronize do
38
45
  @active += 1
@@ -71,6 +78,7 @@ module Net
71
78
  write(fields[:host], message) if message
72
79
  end
73
80
 
81
+ # 只消费最终任务结果,分别展示已保存、设备报告上传和服务器核验状态。
74
82
  def result(outcome)
75
83
  @mutex.synchronize do
76
84
  @completed += 1
@@ -100,6 +108,7 @@ module Net
100
108
  end
101
109
  end
102
110
 
111
+ # 使用报告的成功策略与受控失败分类,进度百分比不代替业务成功判定。
103
112
  def finish(report)
104
113
  return unless @enabled
105
114
 
@@ -144,12 +153,13 @@ module Net
144
153
  end
145
154
  end
146
155
 
156
+ # 心跳仅在持锁后刷新显示,不改变设备任务或完成计数。
147
157
  def tick
148
158
  @mutex.synchronize { refresh(force: true) }
149
159
  end
150
160
 
151
- def location(path)
152
- write(nil, "报告:#{path}")
161
+ def location(path, label: "报告")
162
+ write(nil, "#{label}:#{path}")
153
163
  end
154
164
 
155
165
  private
@@ -178,6 +188,7 @@ module Net
178
188
  format("%02d:%02d:%02d", total / 3600, total / 60 % 60, total % 60)
179
189
  end
180
190
 
191
+ # 吞吐与预计剩余时间使用单调时钟;异构设备的估算不能视为任务期限。
181
192
  def timing_line
182
193
  elapsed = [monotonic - (@run_started || @started_at), 0].max
183
194
  rate = elapsed.positive? ? @completed / elapsed : 0
@@ -196,6 +207,7 @@ module Net
196
207
  line
197
208
  end
198
209
 
210
+ # TTY 原地刷新最多每秒五次;详细事件和重定向输出采用独立显示路径。
199
211
  def refresh(force: false)
200
212
  return unless @enabled && @tty && !@verbose
201
213
 
@@ -209,9 +221,7 @@ module Net
209
221
 
210
222
  clear_refresh
211
223
  @last_status = lines
212
- @io.write(lines.join("\n"))
213
- @io.flush
214
- @refreshing = true
224
+ @refreshing = write_output(lines.join("\n"))
215
225
  end
216
226
 
217
227
  # 留一列避免终端自动换行,窄窗口也不会留下旧进度行。
@@ -228,10 +238,11 @@ module Net
228
238
  line
229
239
  end
230
240
 
241
+ # 擦除前一次实际显示的行数,避免短文本或窄终端留下旧状态。
231
242
  def clear_refresh
232
243
  return unless @refreshing
233
244
 
234
- @io.write("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
245
+ write_output("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
235
246
  @refreshing = false
236
247
  end
237
248
 
@@ -246,8 +257,21 @@ module Net
246
257
  clear_refresh
247
258
  line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} 执行中 #{@active}] [#{host || "批次"}] #{message}"
248
259
  line = "[#{Time.now.strftime("%H:%M:%S")}] #{host ? "#{host} " : ""}#{message}" unless @verbose
249
- @io.write(line.gsub(/[[:cntrl:]]/, " ") + "\n")
260
+ write_output(line.gsub(/[[:cntrl:]]/, " ") + "\n")
261
+ end
262
+
263
+ # 显示失败只停用进度输出;批次仍保存回执和事件,并记录一次受控回调诊断。
264
+ # 所有调用均持有 @mutex,心跳与设备线程不会重复访问已失败的输出。
265
+ def write_output(text)
266
+ return false unless @enabled
267
+
268
+ @io.write(text)
250
269
  @io.flush
270
+ true
271
+ rescue IOError, SystemCallError => error
272
+ @output_error = { host: nil, error_type: ErrorMetadata.type(error.class.name) }.freeze
273
+ @enabled = false
274
+ false
251
275
  end
252
276
  end
253
277
  end
@@ -9,6 +9,7 @@ module Net
9
9
  class Report
10
10
  # 示例与批次入口共享唯一摘要结构;保存结果先于终端成功提示。
11
11
  class Files
12
+ # 文本和 JSON 分别提交;失败时尝试保存诊断,并保留已提交的 JSON 位置。
12
13
  def self.write(report, directory:, plan:, concurrency:)
13
14
  destination = File.join(directory, "summary.json")
14
15
  finalized = report.with(report_location: destination)
@@ -16,8 +17,9 @@ module Net
16
17
  Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
17
18
  finalized
18
19
  rescue StandardError => error
19
- location = committed_location(error, destination)
20
- failed = report.with_report_error(error, location: location)
20
+ location = committed_location(error, destination) || report.report_location
21
+ # 更新已有诊断时仍保留首次报告故障,以及之前已提交的报告位置。
22
+ failed = report.report_error ? report.with(report_location: location) : report.with_report_error(error, location: location)
21
23
  begin
22
24
  saved = failed.with(report_location: destination)
23
25
  Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))