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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/CONTRIBUTING.md +12 -0
- data/README.md +27 -424
- data/README.zh-CN.md +64 -0
- data/{exe/net-connector-backup → bin/net-backup} +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +27 -5
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +12 -5
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +23 -16
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/authentication.rb +1 -0
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -134
- data/lib/net/connector/engine/errors.rb +0 -92
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +27 -6
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/redactor.rb +98 -0
- data/lib/net/connector/engine/response_reader.rb +139 -0
- data/lib/net/connector/engine/session.rb +20 -7
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +35 -8
- data/lib/net/connector/netdisco/cli/options.rb +20 -8
- data/lib/net/connector/netdisco/cli.rb +14 -11
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/connection.rb +0 -1
- data/lib/net/connector/netdisco/database_client.rb +44 -26
- data/lib/net/connector/netdisco/device.rb +5 -0
- data/lib/net/connector/netdisco/diagnostic.rb +8 -0
- data/lib/net/connector/netdisco/fleet.rb +12 -3
- data/lib/net/connector/netdisco/plan.rb +16 -3
- data/lib/net/connector/netdisco/planner.rb +2 -1
- data/lib/net/connector/netdisco/progress.rb +31 -7
- data/lib/net/connector/netdisco/report/files.rb +4 -2
- data/lib/net/connector/netdisco/result_store.rb +6 -7
- data/lib/net/connector/netdisco/rules.rb +8 -5
- data/lib/net/connector/netdisco/settings.rb +83 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
- data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
- data/lib/net/connector/netdisco/worker.rb +3 -1
- data/lib/net/connector/netdisco.rb +1 -21
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +12 -20
- 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, "
|
|
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
|
-
|
|
67
|
-
|
|
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" => ["
|
|
82
|
-
"--netdisco-username USER" => ["
|
|
83
|
-
"--netdisco-password PASSWORD" => ["
|
|
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?("
|
|
115
|
-
result.delete("
|
|
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, "
|
|
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
|
-
|
|
50
|
-
summary =
|
|
52
|
+
report = backup(fleet, plan, settings, options)
|
|
53
|
+
summary = report.summary
|
|
51
54
|
@output.puts JSON.pretty_generate(summary)
|
|
52
|
-
|
|
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-
|
|
58
|
+
@error.puts "net-backup: #{message}"
|
|
56
59
|
2
|
|
57
60
|
rescue OptionParser::ParseError
|
|
58
|
-
@error.puts "net-
|
|
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-
|
|
65
|
+
@error.puts "net-backup: #{exception.message}"
|
|
63
66
|
2
|
|
64
67
|
rescue StandardError => exception
|
|
65
|
-
@error.puts "net-
|
|
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] ||
|
|
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-
|
|
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
|
-
#
|
|
9
|
+
# 无凭据 YAML 配置;仅 ND_URL 允许从进程环境覆盖,其他策略由 CLI 覆盖。
|
|
10
10
|
class ConfigFile
|
|
11
11
|
FIELDS = {
|
|
12
|
-
"netdisco" => { "source" => ["
|
|
13
|
-
"query" => ["
|
|
14
|
-
"query_params" => ["
|
|
15
|
-
"url" => ["
|
|
16
|
-
"page_size" => ["
|
|
17
|
-
"max_pages" => ["
|
|
18
|
-
"max_response_bytes" => ["
|
|
19
|
-
"max_inventory_bytes" => ["
|
|
20
|
-
"max_devices" => ["
|
|
21
|
-
"inventory_timeout" => ["
|
|
22
|
-
"allow_insecure_http" => ["
|
|
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" => ["
|
|
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
|
|
|
@@ -8,15 +8,44 @@ module Net
|
|
|
8
8
|
module Netdisco
|
|
9
9
|
# SQL 负责业务选择和列别名;适配器只管理只读查询、清单预算与连接生命周期。
|
|
10
10
|
class DatabaseClient
|
|
11
|
+
# 只有数据库身份从环境读取;非敏感连接策略的内部选项与诊断名称另行映射。
|
|
11
12
|
CONNECTION_ENV = {
|
|
12
|
-
host: "
|
|
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
|
-
#
|
|
52
|
-
|
|
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 -
|
|
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, "#{
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
devices.map { |device|
|
|
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
|
-
|
|
72
|
-
|
|
78
|
+
issues_by_host = {}
|
|
79
|
+
inventory.each do |device|
|
|
80
|
+
next unless device.host
|
|
73
81
|
|
|
74
|
-
|
|
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.
|
|
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, "
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|