net-connector 0.17.2 → 0.18.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 +7 -0
- data/docs/architecture.md +6 -0
- data/docs/usage.md +4 -0
- data/docs/usage.zh-CN.md +4 -0
- data/lib/net/connector/collect/progress.rb +21 -21
- data/lib/net/connector/device/base.rb +12 -8
- data/lib/net/connector/device/save_config.rb +2 -2
- data/lib/net/connector/engine/execution.rb +37 -10
- data/lib/net/connector/engine/log/messages.rb +16 -16
- data/lib/net/connector/engine/session.rb +5 -4
- data/lib/net/connector/engine/transports.rb +60 -41
- data/lib/net/connector/inventory/filters.rb +2 -2
- data/lib/net/connector/netdisco/cli/backup.rb +6 -6
- data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
- data/lib/net/connector/netdisco/cli/help.rb +16 -16
- data/lib/net/connector/netdisco/cli/options.rb +44 -44
- data/lib/net/connector/netdisco/cli/setup.rb +18 -18
- data/lib/net/connector/netdisco/cli/topology.rb +21 -21
- data/lib/net/connector/netdisco/cli.rb +24 -24
- data/lib/net/connector/netdisco/config/defaults.rb +1 -1
- data/lib/net/connector/netdisco/config/initializer.rb +13 -13
- data/lib/net/connector/netdisco/progress.rb +17 -17
- data/lib/net/connector/netdisco/report/text.rb +15 -15
- data/lib/net/connector/netdisco/ssh/archive/client.rb +4 -4
- data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
- data/lib/net/connector/netdisco/topology/files.rb +8 -8
- data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
- data/lib/net/connector/progress.rb +36 -36
- data/lib/net/connector/resources.rb +1 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +6 -6
|
@@ -25,15 +25,15 @@ module Net
|
|
|
25
25
|
raise OptionParser::InvalidArgument unless remaining.empty?
|
|
26
26
|
credentials = values[:environment].keys.any? { |key| key.match?(/USERNAME|PASSWORD/) }
|
|
27
27
|
if values[:stdin_credentials] && (credentials || values[:ask_password] || values[:ask_netdisco_password])
|
|
28
|
-
raise ArgumentError, "--stdin-credentials
|
|
28
|
+
raise ArgumentError, "--stdin-credentials cannot be combined with command-line credentials or password prompts"
|
|
29
29
|
end
|
|
30
|
-
read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "
|
|
31
|
-
read_password(values, :ask_netdisco_password, "ND_PASSWORD", "Netdisco
|
|
30
|
+
read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "Device password", input: input, output: error)
|
|
31
|
+
read_password(values, :ask_netdisco_password, "ND_PASSWORD", "Netdisco password", input: input, output: error)
|
|
32
32
|
|
|
33
33
|
values
|
|
34
34
|
rescue OptionParser::ParseError
|
|
35
|
-
limits = mode == :topology ? "
|
|
36
|
-
raise ArgumentError, "
|
|
35
|
+
limits = mode == :topology ? "Concurrency must be 1 to 500; device limit must be a positive integer" : "Concurrency must be 1 to 500; sample size must be 1 to 5"
|
|
36
|
+
raise ArgumentError, "Invalid arguments: #{limits}; use --help for options", cause: nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# 组装当前模式的选项、帮助和扩展参数,解析器自身不执行设备任务。
|
|
@@ -41,80 +41,80 @@ module Net
|
|
|
41
41
|
raise ArgumentError, "unknown CLI mode" unless %i[backup tftp topology].include?(mode)
|
|
42
42
|
|
|
43
43
|
OptionParser.new do |opts|
|
|
44
|
-
default = mode == :topology ? "
|
|
45
|
-
opts.banner = "
|
|
44
|
+
default = mode == :topology ? "read-only planning by default" : "use the configuration by default"
|
|
45
|
+
opts.banner = "Usage: ruby #{File.basename(program)} [options] (#{default})\nCommand-line options take precedence; unspecified values use configuration and defaults. Without filters, all ready devices are selected."
|
|
46
46
|
add_settings(opts, values, mode: mode)
|
|
47
47
|
add_execution(opts, values, mode: mode)
|
|
48
48
|
add_terminal_options(opts, values)
|
|
49
49
|
if mode == :topology
|
|
50
50
|
add_topology(opts, values)
|
|
51
51
|
else
|
|
52
|
-
opts.on("--plan", "--inventory-only", "
|
|
52
|
+
opts.on("--plan", "--inventory-only", "Preview filtered inventory without connecting to devices") { values[:inventory_only] = true }
|
|
53
53
|
end
|
|
54
54
|
additional&.call(opts, values)
|
|
55
|
-
opts.on("--examples", "
|
|
55
|
+
opts.on("--examples", "Show common command examples without executing tasks") do
|
|
56
56
|
output.puts Help.examples(program: program, mode: mode)
|
|
57
57
|
values[:help] = true
|
|
58
58
|
end
|
|
59
|
-
opts.on("-h", "--help", "
|
|
59
|
+
opts.on("-h", "--help", "Show common options") do
|
|
60
60
|
output.puts Help.common(opts, mode: mode)
|
|
61
61
|
values[:help] = true
|
|
62
62
|
end
|
|
63
|
-
opts.on("--help-all", "
|
|
63
|
+
opts.on("--help-all", "Show all options, including credentials, SSH and automation") { output.puts opts; values[:help] = true }
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
# 登记进度、JSON、密码输入及配置问答等终端选项。
|
|
68
68
|
def self.add_terminal_options(opts, values)
|
|
69
|
-
opts.on("--ask-password", "
|
|
70
|
-
opts.on("--ask-netdisco-password", "
|
|
69
|
+
opts.on("--ask-password", "Prompt for the device password without echoing input") { values[:ask_password] = true }
|
|
70
|
+
opts.on("--ask-netdisco-password", "Prompt for the Netdisco password without echoing input") { values[:ask_netdisco_password] = true }
|
|
71
71
|
CtxCli::Options.output(opts, values, quiet: :no_progress)
|
|
72
|
-
opts.on("--stdin-credentials", "
|
|
72
|
+
opts.on("--stdin-credentials", "Read credentials from stdin and wait for RUN") { values[:stdin_credentials] = true }
|
|
73
73
|
CtxCli::Options.initialization(opts, values)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# 登记互斥的拓扑执行模式和格式化参数,应用模式必须显式选择。
|
|
77
77
|
def self.add_topology(opts, values)
|
|
78
|
-
{ discover: "
|
|
78
|
+
{ discover: "Discover neighbors and check descriptions", plan: "Generate a configuration plan", apply: "Apply corrections, read back and save" }.each do |mode, description|
|
|
79
79
|
opts.on("--#{mode}", description) do
|
|
80
|
-
raise ArgumentError, "--discover
|
|
80
|
+
raise ArgumentError, "--discover, --plan and --apply are mutually exclusive" if values[:topology_mode]
|
|
81
81
|
|
|
82
82
|
values[:topology_mode] = mode
|
|
83
83
|
values[:apply] = true if mode == :apply
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
|
-
opts.on("--inventory-only", "
|
|
87
|
-
opts.on("--limit N", Integer, "
|
|
86
|
+
opts.on("--inventory-only", "Read and filter inventory without connecting to devices") { values[:inventory_only] = true }
|
|
87
|
+
opts.on("--limit N", Integer, "Select at most N discoverable devices") do |value|
|
|
88
88
|
raise OptionParser::InvalidArgument unless value.positive?
|
|
89
89
|
|
|
90
90
|
values[:topology_limit] = value
|
|
91
91
|
end
|
|
92
|
-
opts.on("--description-format TEXT", "
|
|
93
|
-
opts.on("--full-port-names", "
|
|
94
|
-
opts.on("--lowercase-ports", "
|
|
92
|
+
opts.on("--description-format TEXT", "Template supports only %{neighbor} and %{port}") { |value| values[:environment]["NC_TOPOLOGY_DESCRIPTION_FORMAT"] = value }
|
|
93
|
+
opts.on("--full-port-names", "Keep full neighbor port names") { values[:environment]["NC_TOPOLOGY_ABBREVIATE"] = "false" }
|
|
94
|
+
opts.on("--lowercase-ports", "Convert neighbor port names to lowercase") { values[:environment]["NC_TOPOLOGY_LOWERCASE"] = "true" }
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
# 正式 CLI 与批次示例共用并发、抽样、核验和成功策略选项。
|
|
98
98
|
def self.add_execution(opts, values, mode: nil)
|
|
99
|
-
opts.on("--refresh-inventory", "
|
|
100
|
-
opts.on("--cache-only", "
|
|
101
|
-
opts.on("--cache-mode MODE", %w[prefer only refresh], "
|
|
99
|
+
opts.on("--refresh-inventory", "Force inventory refresh; fall back to verified history on remote failure") { values[:force_refresh] = true }
|
|
100
|
+
opts.on("--cache-only", "Use only verified cached inventory without querying the remote source") { values[:environment]["ND_CACHE_MODE"] = "only" }
|
|
101
|
+
opts.on("--cache-mode MODE", %w[prefer only refresh], "Inventory cache: prefer / only / refresh") do |value|
|
|
102
102
|
values[:environment]["ND_CACHE_MODE"] = value
|
|
103
103
|
end
|
|
104
|
-
opts.on("-p", "--parallel N", Integer, "
|
|
104
|
+
opts.on("-p", "--parallel N", Integer, "Concurrent devices, 1 to 500") do |count|
|
|
105
105
|
raise OptionParser::InvalidArgument unless (1..Settings::MAX_PARALLEL).cover?(count)
|
|
106
106
|
|
|
107
107
|
values[:environment]["NC_PARALLEL"] = count.to_s
|
|
108
108
|
end
|
|
109
109
|
return if mode == :topology
|
|
110
110
|
|
|
111
|
-
opts.on("--sample N", "--limit-per-vendor N", Integer, "
|
|
111
|
+
opts.on("--sample N", "--limit-per-vendor N", Integer, "Sample 1 to 5 devices per vendor") do |count|
|
|
112
112
|
Planner.validate_limit!(count)
|
|
113
113
|
values[:limit] = count
|
|
114
114
|
end
|
|
115
|
-
opts.on("--tftp-root DIR", "
|
|
116
|
-
opts.on("--all", "
|
|
117
|
-
opts.on("--success-policy POLICY", %w[strict selected verified], "
|
|
115
|
+
opts.on("--tftp-root DIR", "Local directory containing TFTP server files") { |path| values[:tftp_root] = path }
|
|
116
|
+
opts.on("--all", "Select all ready devices") { values[:all] = true }
|
|
117
|
+
opts.on("--success-policy POLICY", %w[strict selected verified], "Success policy: strict / selected / verified (TFTP file verification)") do |policy|
|
|
118
118
|
values[:success_policy] = policy.to_sym
|
|
119
119
|
end
|
|
120
120
|
end
|
|
@@ -136,7 +136,7 @@ module Net
|
|
|
136
136
|
|
|
137
137
|
# CLI > 原进程环境 > YAML > .env;公开环境字段转为内部策略,凭据仍独立读取。
|
|
138
138
|
def self.settings(options, env: ENV, mode: :backup, configuration: nil, defaults: {})
|
|
139
|
-
raise ArgumentError, "--all
|
|
139
|
+
raise ArgumentError, "--all cannot be combined with sampling" if options[:all] && options[:limit]
|
|
140
140
|
if options[:host] && options.fetch(:environment).key?("NC_INCLUDE_HOSTS")
|
|
141
141
|
raise ArgumentError, "--host cannot be combined with --hosts"
|
|
142
142
|
end
|
|
@@ -180,14 +180,14 @@ module Net
|
|
|
180
180
|
def self.add_settings(opts, values, mode: nil)
|
|
181
181
|
add_filters(opts, values)
|
|
182
182
|
{
|
|
183
|
-
"--username USER" => ["NC_DEVICE_USERNAME", "
|
|
184
|
-
"--password PASSWORD" => ["NC_DEVICE_PASSWORD", "
|
|
185
|
-
"--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "
|
|
186
|
-
"--netdisco-url URL" => ["ND_URL", "Netdisco
|
|
187
|
-
"--netdisco-username USER" => ["ND_USERNAME", "Netdisco
|
|
188
|
-
"--netdisco-password PASSWORD" => ["ND_PASSWORD", "Netdisco
|
|
189
|
-
"--directory DIR" => ["NC_BACKUP_DIRECTORY", "#{mode == :topology ? "
|
|
190
|
-
"--config FILE" => ["NC_CONFIG", "YAML
|
|
183
|
+
"--username USER" => ["NC_DEVICE_USERNAME", "Device login username"],
|
|
184
|
+
"--password PASSWORD" => ["NC_DEVICE_PASSWORD", "Device login password (or use --ask-password)"],
|
|
185
|
+
"--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "Device privilege password"],
|
|
186
|
+
"--netdisco-url URL" => ["ND_URL", "Netdisco URL"],
|
|
187
|
+
"--netdisco-username USER" => ["ND_USERNAME", "Netdisco username"],
|
|
188
|
+
"--netdisco-password PASSWORD" => ["ND_PASSWORD", "Netdisco password"],
|
|
189
|
+
"--directory DIR" => ["NC_BACKUP_DIRECTORY", "#{mode == :topology ? "Topology report" : "Backup"} directory, relative to the startup directory"],
|
|
190
|
+
"--config FILE" => ["NC_CONFIG", "YAML configuration, relative to the startup directory"]
|
|
191
191
|
}.each do |flag, (key, description)|
|
|
192
192
|
opts.on(flag, description) do |value|
|
|
193
193
|
raise OptionParser::InvalidArgument if value.empty?
|
|
@@ -195,12 +195,12 @@ module Net
|
|
|
195
195
|
values[:environment][key] = value
|
|
196
196
|
end
|
|
197
197
|
end
|
|
198
|
-
opts.on("--env-file FILE", "
|
|
199
|
-
opts.on("--show-config", "
|
|
200
|
-
opts.on("--host-key-policy POLICY", %w[strict accept_new replace], "
|
|
198
|
+
opts.on("--env-file FILE", "Environment file loaded last, relative to the startup directory") { |path| values[:env_file] = path }
|
|
199
|
+
opts.on("--show-config", "Show effective nonsensitive settings and configuration sources") { values[:show_config] = true }
|
|
200
|
+
opts.on("--host-key-policy POLICY", %w[strict accept_new replace], "Host key policy: strict / accept_new / replace (automatically replace changed keys)") do |policy|
|
|
201
201
|
values[:environment]["NC_HOST_KEY_POLICY"] = policy
|
|
202
202
|
end
|
|
203
|
-
opts.on("--known-hosts FILE", "
|
|
203
|
+
opts.on("--known-hosts FILE", "File for persistent host keys") do |path|
|
|
204
204
|
values[:environment]["NC_KNOWN_HOSTS"] = path
|
|
205
205
|
end
|
|
206
206
|
end
|
|
@@ -217,7 +217,7 @@ module Net
|
|
|
217
217
|
values[:environment][environment_key] = value
|
|
218
218
|
end
|
|
219
219
|
end
|
|
220
|
-
opts.on("--host IP", "
|
|
220
|
+
opts.on("--host IP", "Select one device; may be repeated") do |value|
|
|
221
221
|
raise OptionParser::InvalidArgument if value.empty? || value.match?(/[\/,]/)
|
|
222
222
|
|
|
223
223
|
begin
|
|
@@ -254,7 +254,7 @@ module Net
|
|
|
254
254
|
def self.read_password(values, flag, key, label, input:, output:)
|
|
255
255
|
return unless values[flag]
|
|
256
256
|
|
|
257
|
-
raise ArgumentError, "
|
|
257
|
+
raise ArgumentError, "Password prompts cannot be combined with explicit passwords" if values[:environment].key?(key)
|
|
258
258
|
values[:environment][key] = CtxCli::Terminal.new(input: input, out: output).password(label: label)
|
|
259
259
|
end
|
|
260
260
|
private_class_method :read_password, :parser_for
|
|
@@ -12,8 +12,8 @@ module Net
|
|
|
12
12
|
# 首次运行的文件检查和创建问答;完成初始化后必须退出,不触发设备任务。
|
|
13
13
|
class Setup
|
|
14
14
|
ENVIRONMENT_TEMPLATE = <<~TEXT.freeze
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# Process environment takes precedence over this file; CLI > original process environment > YAML > .env > built-in defaults.
|
|
16
|
+
# Uncomment and fill in credentials for the authentication method in use; current process passwords are not saved automatically.
|
|
17
17
|
# ND_URL=
|
|
18
18
|
# ND_USERNAME=
|
|
19
19
|
# ND_PASSWORD=
|
|
@@ -21,16 +21,16 @@ module Net
|
|
|
21
21
|
# NC_DEVICE_USERNAME=
|
|
22
22
|
# NC_DEVICE_PASSWORD=
|
|
23
23
|
# NC_ENABLE_PASSWORD=
|
|
24
|
-
# PostgreSQL
|
|
24
|
+
# PostgreSQL inventory uses ND_DB_HOST / ND_DB_NAME / ND_DB_USER / ND_DB_PASS.
|
|
25
25
|
# ND_DB_HOST=
|
|
26
26
|
# ND_DB_NAME=
|
|
27
27
|
# ND_DB_USER=
|
|
28
28
|
# ND_DB_PASS=
|
|
29
|
-
# TFTP SSH
|
|
30
|
-
#
|
|
29
|
+
# TFTP SSH archiving uses existing keys/agent, or the password below.
|
|
30
|
+
# Wrap passwords containing # or $ in single quotes, for example KEY='your-password'.
|
|
31
31
|
# NC_TFTP_SSH_PASSWORD=
|
|
32
|
-
#
|
|
33
|
-
#
|
|
32
|
+
# Prefer each entrypoint's YAML for common options; use -p / --config / --directory at runtime.
|
|
33
|
+
# Without NC_CONFIG, configuration is discovered in system, user, entrypoint and startup directories, in that order.
|
|
34
34
|
TEXT
|
|
35
35
|
|
|
36
36
|
# 保存调用方 IO 与路径;不读取标准输入、不加载凭据、不创建文件。
|
|
@@ -51,7 +51,7 @@ module Net
|
|
|
51
51
|
missing_config = path && missing?(path)
|
|
52
52
|
missing_env = @env_path && missing?(@env_path)
|
|
53
53
|
return !!@options[:init] unless missing_config || missing_env
|
|
54
|
-
raise ArgumentError, "--interactive
|
|
54
|
+
raise ArgumentError, "--interactive requires an interactive terminal; use --init --non-interactive for automation" if @options[:interactive] && !terminal?
|
|
55
55
|
|
|
56
56
|
create_config = missing_config && approved?(path, required: true)
|
|
57
57
|
create_env = missing_env && approved?(@env_path, required: false)
|
|
@@ -59,12 +59,12 @@ module Net
|
|
|
59
59
|
if create_env
|
|
60
60
|
FileUtils.mkdir_p(File.dirname(File.expand_path(@env_path)), mode: 0o700)
|
|
61
61
|
@initializer.create_environment(path: @env_path, contents: ENVIRONMENT_TEMPLATE)
|
|
62
|
-
@output.puts "
|
|
62
|
+
@output.puts "Environment template initialized: #{@env_path}(passwords were not written)"
|
|
63
63
|
end
|
|
64
64
|
stopped = create_config || create_env || @options[:init]
|
|
65
|
-
@output.puts "
|
|
66
|
-
@output.puts "HTTP
|
|
67
|
-
@output.puts "TFTP
|
|
65
|
+
@output.puts "Check configuration and credentials, then run again; inventory was not read and no devices were connected." if stopped
|
|
66
|
+
@output.puts "HTTP inventory requires netdisco.url or ND_URL and ND_API_KEY or ND_USERNAME / ND_PASSWORD; device credentials support common and per-vendor settings." if create_config && document.dig("netdisco", "source") == "http"
|
|
67
|
+
@output.puts "For TFTP, set tftp.server or tftp.servers and the file verification directory for your environment; server, VRF and source interface are not inferred." if create_config && @mode == :tftp
|
|
68
68
|
!!stopped
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -83,14 +83,14 @@ module Net
|
|
|
83
83
|
end
|
|
84
84
|
FileUtils.mkdir_p(File.dirname(File.expand_path(path)), mode: 0o700)
|
|
85
85
|
@initializer.create(path: path, document: document, interactive: questionnaire?, mode: @mode, inherited_parallel: inherited_parallel)
|
|
86
|
-
@output.puts "
|
|
86
|
+
@output.puts "Configuration initialized: #{path}"
|
|
87
87
|
document
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# 缺失与无效文件分开处理,拒绝符号链接、目录及不可读取文件。
|
|
91
91
|
def missing?(path)
|
|
92
92
|
stat = File.lstat(path)
|
|
93
|
-
raise ArgumentError, "
|
|
93
|
+
raise ArgumentError, "Configuration path must be a readable regular file: #{path}" unless stat.file? && File.readable?(path)
|
|
94
94
|
|
|
95
95
|
false
|
|
96
96
|
rescue Errno::ENOENT
|
|
@@ -114,12 +114,12 @@ module Net
|
|
|
114
114
|
def approved?(path, required:)
|
|
115
115
|
return true if @options[:init]
|
|
116
116
|
unless terminal? && @options[:interactive] != false
|
|
117
|
-
raise ArgumentError, "
|
|
117
|
+
raise ArgumentError, "Missing configuration: #{path}; first run --init --non-interactive to create it, or use --config to select an existing file" if required
|
|
118
118
|
|
|
119
119
|
return false
|
|
120
120
|
end
|
|
121
121
|
loop do
|
|
122
|
-
@output.print "
|
|
122
|
+
@output.print "Not found: #{path}; create it? [y/N]: "
|
|
123
123
|
@output.flush
|
|
124
124
|
line = @input.gets("\n")
|
|
125
125
|
raise EOFError unless line
|
|
@@ -127,11 +127,11 @@ module Net
|
|
|
127
127
|
answer = line.strip.downcase
|
|
128
128
|
return true if %w[y yes].include?(answer)
|
|
129
129
|
if answer.empty? || %w[n no].include?(answer)
|
|
130
|
-
raise ArgumentError, "
|
|
130
|
+
raise ArgumentError, "Required configuration was not created: #{path}; stopped" if required
|
|
131
131
|
|
|
132
132
|
return false
|
|
133
133
|
end
|
|
134
|
-
@output.puts "
|
|
134
|
+
@output.puts "Enter y or n."
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
@@ -43,7 +43,7 @@ module Net
|
|
|
43
43
|
return 0
|
|
44
44
|
end
|
|
45
45
|
policy = settings.topology_options
|
|
46
|
-
raise ArgumentError, "
|
|
46
|
+
raise ArgumentError, "Applying configuration requires explicit --apply; --plan only generates a plan and --discover only reads device state" if policy.mode == :apply && !options[:apply]
|
|
47
47
|
|
|
48
48
|
inventory, credentials = CLI::Credentials.resolve(settings, input: @input, stdin_credentials: options[:stdin_credentials])
|
|
49
49
|
@progress = Progress.new(io: @error, enabled: !options[:no_progress], verbose: options[:verbose])
|
|
@@ -58,13 +58,13 @@ module Net
|
|
|
58
58
|
rescue Interrupt
|
|
59
59
|
130
|
|
60
60
|
rescue EOFError
|
|
61
|
-
@error.puts "
|
|
61
|
+
@error.puts "Configuration setup is incomplete; stopped. Check the files listed above."
|
|
62
62
|
2
|
|
63
63
|
rescue ArgumentError, Inventory::Error => exception
|
|
64
|
-
@error.puts "
|
|
64
|
+
@error.puts "Topology argument or inventory error: #{exception.message}"
|
|
65
65
|
2
|
|
66
66
|
rescue StandardError => exception
|
|
67
|
-
@error.puts "
|
|
67
|
+
@error.puts "Topology incomplete (#{Error::Metadata.type(exception.class.name) || "StandardError"})"
|
|
68
68
|
2
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -108,8 +108,8 @@ module Net
|
|
|
108
108
|
status = fleet.inventory_status
|
|
109
109
|
return unless status
|
|
110
110
|
|
|
111
|
-
@error.puts "
|
|
112
|
-
@error.puts "
|
|
111
|
+
@error.puts "Inventory refresh failed; using the previous cache (#{status[:refresh_error_code]})" if status[:source] == :stale_cache
|
|
112
|
+
@error.puts "Inventory cache write failed (#{status[:write_error_type]})" if status[:write_error_type]
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
# 仅预览就绪清单,不建立设备连接;无选中设备时返回未完成。
|
|
@@ -125,7 +125,7 @@ module Net
|
|
|
125
125
|
# 先发现并保存审批计划,仅在显式应用模式执行变更,失败后保存已有任务回执。
|
|
126
126
|
# 发现结果先落盘并生成校验摘要,再进入应用;取消只汇总回执,不重新下发配置。
|
|
127
127
|
def execute(devices, settings, credentials, policy, options)
|
|
128
|
-
raise ArgumentError, "
|
|
128
|
+
raise ArgumentError, "Set the topology report directory with topology.directory or --directory" unless policy.directory
|
|
129
129
|
|
|
130
130
|
directory = Storage::BatchDirectory.create(policy.directory)
|
|
131
131
|
selected = devices.select(&:ready?)
|
|
@@ -137,12 +137,12 @@ module Net
|
|
|
137
137
|
@events_mutex = Mutex.new
|
|
138
138
|
@task_results = {}
|
|
139
139
|
credentials = observed_credentials(credentials)
|
|
140
|
-
@progress.operation_plan(selected, parallel: policy.parallel, label: "
|
|
140
|
+
@progress.operation_plan(selected, parallel: policy.parallel, label: "Topology discovery/planning")
|
|
141
141
|
outcomes = @progress.with_updates { collect(selected, settings, credentials, policy, directory) }
|
|
142
142
|
save_plan(directory, outcomes)
|
|
143
143
|
if policy.mode == :apply
|
|
144
144
|
applying = outcomes.filter_map { |outcome| @devices_by_host[outcome[:host]] if %i[planned partial_planned].include?(outcome[:status]) }
|
|
145
|
-
@progress.operation_plan(applying, parallel: policy.parallel, label: "
|
|
145
|
+
@progress.operation_plan(applying, parallel: policy.parallel, label: "Topology apply")
|
|
146
146
|
outcomes = @progress.with_updates { apply_plans(outcomes, settings, credentials, policy, directory) }
|
|
147
147
|
end
|
|
148
148
|
summary = build_summary(devices, outcomes, policy, directory)
|
|
@@ -266,7 +266,7 @@ module Net
|
|
|
266
266
|
begin
|
|
267
267
|
# 阻塞 I/O 仍可取消;完成值或同步失败先固定,再交付返回间隙中的异步中断。
|
|
268
268
|
Thread.handle_interrupt(Exception => :on_blocking) do
|
|
269
|
-
@progress.start(device, label: "
|
|
269
|
+
@progress.start(device, label: "Topology operation")
|
|
270
270
|
result = yield
|
|
271
271
|
rescue StandardError => failure
|
|
272
272
|
result = { host: device.host, vendor: device.vendor, status: :failed, diagnostic: Diagnostic.from(failure)&.to_h }
|
|
@@ -375,7 +375,7 @@ module Net
|
|
|
375
375
|
summary[:devices].each { |outcome| Netdisco::Topology::Files.write_report(outcome) }
|
|
376
376
|
lines = result_lines(summary) + summary[:devices].flat_map do |item|
|
|
377
377
|
["#{item[:host]} #{item[:name].to_s.gsub(/[[:cntrl:]]/, " ")} #{Netdisco::Topology::Presentation.status(item[:status], changes: item.fetch(:changes, 0))} changes=#{item.fetch(:changes, 0)}",
|
|
378
|
-
"
|
|
378
|
+
" Device report: #{item[:device_report]}", " Command file: #{item[:script_file] || "not generated"}"] +
|
|
379
379
|
item.fetch(:neighbors, []).map do |row|
|
|
380
380
|
" #{row[:interface]} #{row[:status]} current=#{row[:current].inspect} expected=#{row[:expected].inspect}"
|
|
381
381
|
end + item.fetch(:issues, []).map { |issue| " #{issue[:interface]} #{Netdisco::Topology::Presentation.reason(issue[:code])}" } +
|
|
@@ -398,10 +398,10 @@ module Net
|
|
|
398
398
|
# 默认只打印设备、接口两层摘要;逐设备及命令详情已经写入独立报告。
|
|
399
399
|
def result_lines(data)
|
|
400
400
|
stats = data.fetch(:statistics)
|
|
401
|
-
["
|
|
402
|
-
"
|
|
403
|
-
"
|
|
404
|
-
"
|
|
401
|
+
["Result: #{Netdisco::Topology::Presentation.result(data)}; mode: #{data[:mode]}",
|
|
402
|
+
"Devices: collected #{stats[:collected]}; unchanged #{stats[:unchanged]}; with candidate changes #{stats[:change_devices]}; failed #{stats[:failed]} devices",
|
|
403
|
+
"Interfaces: candidate changes #{stats[:changes]}; unverified #{stats[:review_interfaces]} (across #{stats[:review_devices]} devices)",
|
|
404
|
+
"Unverified reasons: #{Netdisco::Topology::Presentation.issues(data[:devices].flat_map { |item| item.fetch(:issues, []) })}"]
|
|
405
405
|
end
|
|
406
406
|
|
|
407
407
|
# 按 JSON 或安全文本格式输出设备结果及报告位置。
|
|
@@ -410,7 +410,7 @@ module Net
|
|
|
410
410
|
@output.puts JSON.generate(data)
|
|
411
411
|
else
|
|
412
412
|
if data.key?(:total)
|
|
413
|
-
@output.puts "
|
|
413
|
+
@output.puts "Inventory #{data[:total]} devices; selected #{data[:ready]}; skipped #{data[:total] - data[:ready]} devices"
|
|
414
414
|
end
|
|
415
415
|
if data.key?(:total)
|
|
416
416
|
data.fetch(:devices).each do |item|
|
|
@@ -421,11 +421,11 @@ module Net
|
|
|
421
421
|
if data[:directory]
|
|
422
422
|
@output.puts result_lines(data)
|
|
423
423
|
scripts = data[:devices].count { |item| item[:script_file] }
|
|
424
|
-
@output.puts "
|
|
425
|
-
@output.puts "
|
|
426
|
-
@output.puts "
|
|
427
|
-
@output.puts "
|
|
428
|
-
@output.puts "
|
|
424
|
+
@output.puts "Command file: #{File.join(data[:directory], "scripts")}(#{scripts} devices with changes)" if scripts.positive?
|
|
425
|
+
@output.puts "Device report: #{File.join(data[:directory], "devices")}"
|
|
426
|
+
@output.puts "Plan: #{File.join(data[:directory], "plan.json")}"
|
|
427
|
+
@output.puts "Report: #{File.join(data[:directory], "summary.json")}"
|
|
428
|
+
@output.puts "Text report: #{File.join(data[:directory], "summary.txt")}"
|
|
429
429
|
end
|
|
430
430
|
end
|
|
431
431
|
end
|
|
@@ -59,14 +59,14 @@ module Net
|
|
|
59
59
|
@error.puts "net-backup: #{message}"
|
|
60
60
|
2
|
|
61
61
|
rescue OptionParser::ParseError
|
|
62
|
-
@error.puts "net-backup:
|
|
62
|
+
@error.puts "net-backup: invalid arguments; use --help for options"
|
|
63
63
|
2
|
|
64
64
|
rescue ArgumentError, KeyError, Errno::ENOENT,
|
|
65
65
|
Psych::Exception, JSON::ParserError, Inventory::Error => exception
|
|
66
66
|
@error.puts "net-backup: #{exception.message}"
|
|
67
67
|
2
|
|
68
68
|
rescue StandardError => exception
|
|
69
|
-
@error.puts "net-backup
|
|
69
|
+
@error.puts "net-backup failed (#{exception.class})"
|
|
70
70
|
2
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -111,7 +111,7 @@ module Net
|
|
|
111
111
|
fleet.plan_backup(limit_per_vendor: limit, **refresh)
|
|
112
112
|
end
|
|
113
113
|
options[:host]&.split(",")&.each do |host|
|
|
114
|
-
raise ArgumentError, "
|
|
114
|
+
raise ArgumentError, "Device not found in inventory: #{host}" unless plan.inventory.any? { |device| device.host == host }
|
|
115
115
|
end
|
|
116
116
|
plan
|
|
117
117
|
end
|
|
@@ -120,9 +120,9 @@ module Net
|
|
|
120
120
|
def inventory_warning(fleet)
|
|
121
121
|
status = fleet.inventory_status if fleet.respond_to?(:inventory_status)
|
|
122
122
|
if status && status[:source] == :stale_cache
|
|
123
|
-
@error.puts "net-backup:
|
|
123
|
+
@error.puts "net-backup: inventory refresh failed; using the previous cache (#{status[:refresh_error_code]})"
|
|
124
124
|
end
|
|
125
|
-
@error.puts "net-backup:
|
|
125
|
+
@error.puts "net-backup: inventory cache write failed (#{status[:write_error_type]})" if status && status[:write_error_type]
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
# 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
|
|
@@ -143,25 +143,25 @@ module Net
|
|
|
143
143
|
def parse_options
|
|
144
144
|
options = { environment: {} }
|
|
145
145
|
parser = OptionParser.new do |args|
|
|
146
|
-
args.banner = "
|
|
146
|
+
args.banner = "Usage: net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
|
|
147
147
|
Options.add_settings(args, options)
|
|
148
148
|
Options.add_execution(args, options)
|
|
149
|
-
args.on("--init", "
|
|
150
|
-
args.on("--interactive", "
|
|
151
|
-
args.on("--non-interactive", "
|
|
152
|
-
args.on("--source SOURCE", %w[http postgres], "
|
|
153
|
-
args.on("--query SQL", "PostgreSQL
|
|
154
|
-
args.on("--query-params JSON", "
|
|
155
|
-
args.on("--plan", "
|
|
156
|
-
args.on("--tftp", "
|
|
157
|
-
args.on("--export IP", "
|
|
158
|
-
args.on("--output FILE", "
|
|
159
|
-
args.on("--max-script-output-bytes N", Integer, "
|
|
160
|
-
args.on("-v", "--version", "
|
|
149
|
+
args.on("--init", "Create missing configuration and environment templates in the user directory, then exit") { options[:init] = true }
|
|
150
|
+
args.on("--interactive", "Create missing configuration through terminal prompts") { options[:interactive] = true }
|
|
151
|
+
args.on("--non-interactive", "Disable configuration prompts") { options[:interactive] = false }
|
|
152
|
+
args.on("--source SOURCE", %w[http postgres], "Inventory source: http (default) or postgres") { |value| options[:source] = value }
|
|
153
|
+
args.on("--query SQL", "PostgreSQL inventory query using $1 and $2 bound parameters") { |value| options[:query] = value }
|
|
154
|
+
args.on("--query-params JSON", "JSON array of query parameters") { |value| options[:query_params] = value }
|
|
155
|
+
args.on("--plan", "Preview device inventory without connecting to devices") { options[:plan] = true }
|
|
156
|
+
args.on("--tftp", "Have devices initiate TFTP backups") { options[:tftp] = true }
|
|
157
|
+
args.on("--export IP", "Export a saved local configuration") { |value| options[:export] = value }
|
|
158
|
+
args.on("--output FILE", "Write exported configuration to a private file") { |value| options[:output] = value }
|
|
159
|
+
args.on("--max-script-output-bytes N", Integer, "Cumulative response byte limit per script") { |value| options[:max_script_output_bytes] = value }
|
|
160
|
+
args.on("-v", "--version", "Show version") do
|
|
161
161
|
@output.puts Net::Connector::VERSION
|
|
162
162
|
options[:done] = true
|
|
163
163
|
end
|
|
164
|
-
args.on("-h", "--help", "
|
|
164
|
+
args.on("-h", "--help", "Show help") do
|
|
165
165
|
@output.puts args
|
|
166
166
|
options[:done] = true
|
|
167
167
|
end
|
|
@@ -179,15 +179,15 @@ module Net
|
|
|
179
179
|
Planner.validate_limit!(options[:limit])
|
|
180
180
|
raise ArgumentError, "--tftp-root requires --tftp" if options[:tftp_root] && !options[:tftp]
|
|
181
181
|
modes = [:show_config, :plan, :export].count { |key| options[key] }
|
|
182
|
-
raise ArgumentError, "--show-config
|
|
183
|
-
raise ArgumentError, "--output
|
|
184
|
-
raise ArgumentError, "--all
|
|
182
|
+
raise ArgumentError, "Select only one of --show-config, --plan and --export" if modes > 1
|
|
183
|
+
raise ArgumentError, "--output requires --export" if options[:output] && !options[:export]
|
|
184
|
+
raise ArgumentError, "--all cannot be combined with --limit-per-vendor" if options[:all] && options[:limit]
|
|
185
185
|
if options[:export] && (options[:source] || options[:query] || options[:query_params])
|
|
186
|
-
raise ArgumentError, "--export
|
|
186
|
+
raise ArgumentError, "--export cannot be combined with inventory query options"
|
|
187
187
|
end
|
|
188
188
|
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:environment].key?("NC_PARALLEL") || options[:host] ||
|
|
189
189
|
options[:success_policy] || options[:tftp_root] || options[:max_script_output_bytes] || options[:force_refresh])
|
|
190
|
-
raise ArgumentError, "--export
|
|
190
|
+
raise ArgumentError, "--export cannot be combined with backup options"
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
@@ -48,7 +48,7 @@ module Net
|
|
|
48
48
|
else value
|
|
49
49
|
end
|
|
50
50
|
rescue ArgumentError, TypeError, JSON::ParserError
|
|
51
|
-
raise ArgumentError, "
|
|
51
|
+
raise ArgumentError, "Initialization parameter #{key} has an invalid format", cause: nil
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# 同一字段声明同时约束加载与生成,不持久化密码或未知环境变量。
|
|
@@ -29,7 +29,7 @@ module Net
|
|
|
29
29
|
values = YAML.safe_load(YAML.dump(document), aliases: false)
|
|
30
30
|
validate!(values)
|
|
31
31
|
interactive = CtxCli::Terminal.tty?(@input, @output) if interactive.nil?
|
|
32
|
-
configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("
|
|
32
|
+
configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("Start interactive configuration? [y/N]", "n").casecmp?("y")
|
|
33
33
|
validate!(values)
|
|
34
34
|
name = mode == :topology ? "topology" : "backup"
|
|
35
35
|
write(path, Net::Connector::Config.dump(values, name: name, path: path))
|
|
@@ -54,7 +54,7 @@ module Net
|
|
|
54
54
|
|
|
55
55
|
# 显示有效默认值;空输入可由字段选择沿用或保留继承,输入结束则中止生成。
|
|
56
56
|
def answer(label, default, blank: default)
|
|
57
|
-
@output.print "#{label}#{default.nil? ? "" : " [#{default}]"}
|
|
57
|
+
@output.print "#{label}#{default.nil? ? "" : " [#{default}]"}: "
|
|
58
58
|
@output.flush
|
|
59
59
|
line = @input.gets("\n")
|
|
60
60
|
raise EOFError, "configuration input ended before completion" unless line
|
|
@@ -67,7 +67,7 @@ module Net
|
|
|
67
67
|
def field(document, section, key, label, default, list: false)
|
|
68
68
|
values = (document[section] ||= {})
|
|
69
69
|
previous = values.fetch(key, default)
|
|
70
|
-
label = "#{label}
|
|
70
|
+
label = "#{label}(leave blank to inherit)" if key == "parallel" && !values.key?(key)
|
|
71
71
|
loop do
|
|
72
72
|
displayed = previous.is_a?(Array) ? previous.join(",") : previous&.to_s
|
|
73
73
|
text = answer(label, displayed, blank: key == "parallel" ? nil : displayed)
|
|
@@ -86,30 +86,30 @@ module Net
|
|
|
86
86
|
values[key] = value
|
|
87
87
|
return
|
|
88
88
|
rescue ArgumentError
|
|
89
|
-
@output.puts "
|
|
89
|
+
@output.puts "Invalid value; enter it again."
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# 按清单来源和业务模式逐项填写非敏感配置。
|
|
95
95
|
def configure(document, mode: nil, inherited_parallel:)
|
|
96
|
-
field(document, "netdisco", "source", "
|
|
96
|
+
field(document, "netdisco", "source", "Inventory source http/postgres", "http")
|
|
97
97
|
if document.dig("netdisco", "source") == "http"
|
|
98
|
-
field(document, "netdisco", "url", "
|
|
98
|
+
field(document, "netdisco", "url", "Inventory URL", mode.nil? ? "https://netdisco.example.invalid" : nil)
|
|
99
99
|
end
|
|
100
100
|
%w[include_hosts exclude_hosts include_names exclude_names include_models exclude_models].each do |key|
|
|
101
101
|
field(document, "inventory", key, key, [], list: true)
|
|
102
102
|
end
|
|
103
|
-
field(document, "inventory", "name_match", "
|
|
104
|
-
field(document, "inventory", "model_match", "
|
|
103
|
+
field(document, "inventory", "name_match", "Name matching contains/prefix/glob", "contains")
|
|
104
|
+
field(document, "inventory", "model_match", "Model matching contains/prefix/glob", "contains")
|
|
105
105
|
%w[include_vendors exclude_vendors].each { |key| field(document, "inventory", key, key, [], list: true) }
|
|
106
106
|
if document.key?("topology")
|
|
107
|
-
field(document, "topology", "parallel", "
|
|
108
|
-
field(document, "topology", "directory", "
|
|
107
|
+
field(document, "topology", "parallel", "Topology concurrency 1..500", inherited_parallel)
|
|
108
|
+
field(document, "topology", "directory", "Topology report directory", "./topologys")
|
|
109
109
|
else
|
|
110
|
-
field(document, "backup", "parallel", "
|
|
111
|
-
field(document, "backup", "directory", "
|
|
112
|
-
field(document, "tftp", "server", "TFTP
|
|
110
|
+
field(document, "backup", "parallel", "Concurrency 1..500", inherited_parallel)
|
|
111
|
+
field(document, "backup", "directory", "Backup directory", "./backups")
|
|
112
|
+
field(document, "tftp", "server", "TFTP server", nil) if mode.nil? || mode == :tftp
|
|
113
113
|
end
|
|
114
114
|
end
|
|
115
115
|
|