net-connector 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/architecture.md +14 -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/error/metadata.rb +2 -1
- data/lib/net/connector/engine/execution.rb +37 -10
- data/lib/net/connector/engine/known_hosts.rb +19 -6
- 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 +109 -79
- data/lib/net/connector/netdisco/ssh/archive/protocol.rb +107 -0
- data/lib/net/connector/netdisco/ssh/archive/worker.py +35 -19
- 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/storage/atomic_file.rb +60 -3
- data/lib/net/connector/version.rb +1 -1
- metadata +11 -10
|
@@ -58,13 +58,13 @@ module Net
|
|
|
58
58
|
finish_interrupted(fleet, progress, options, batch_directory, plan, settings)
|
|
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 "Backup argument or inventory error: #{exception.message}"
|
|
65
65
|
2
|
|
66
66
|
rescue StandardError => exception
|
|
67
|
-
@error.puts "
|
|
67
|
+
@error.puts "Backup incomplete (#{exception.class})"
|
|
68
68
|
2
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -111,10 +111,10 @@ module Net
|
|
|
111
111
|
# 清单离线回退和本地保存故障单独提示,不覆盖设备操作结果。
|
|
112
112
|
def inventory_warning(fleet)
|
|
113
113
|
if fleet.inventory_status && fleet.inventory_status[:source] == :stale_cache
|
|
114
|
-
@error.puts "
|
|
114
|
+
@error.puts "Inventory refresh failed; using the previous cache (#{fleet.inventory_status[:refresh_error_code]})"
|
|
115
115
|
end
|
|
116
116
|
return unless fleet.inventory_status && fleet.inventory_status[:write_error_type]
|
|
117
|
-
@error.puts "
|
|
117
|
+
@error.puts "Inventory cache write failed (#{fleet.inventory_status[:write_error_type]})"
|
|
118
118
|
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -188,7 +188,7 @@ module Net
|
|
|
188
188
|
progress.finish(report)
|
|
189
189
|
progress.location(report.report_location) if report.report_location
|
|
190
190
|
summary_path = File.join(batch_directory, "summary.txt")
|
|
191
|
-
progress.location(summary_path, label: "
|
|
191
|
+
progress.location(summary_path, label: "Text report") if File.file?(summary_path)
|
|
192
192
|
updated = record_progress_error(report, progress)
|
|
193
193
|
unless updated.equal?(report)
|
|
194
194
|
# 末尾显示也可能首次失败,只补写诊断,不重复设备任务或事件。
|
|
@@ -18,10 +18,10 @@ module Net
|
|
|
18
18
|
begin
|
|
19
19
|
values = JSON.parse(input.gets("\n") || "")
|
|
20
20
|
rescue JSON::ParserError
|
|
21
|
-
raise ArgumentError, "
|
|
21
|
+
raise ArgumentError, "Invalid credentials JSON", cause: nil
|
|
22
22
|
end
|
|
23
23
|
keys = %w[netdisco_username netdisco_password device_username device_password]
|
|
24
|
-
raise ArgumentError, "
|
|
24
|
+
raise ArgumentError, "Four nonempty credential strings are required" unless values.is_a?(Hash) && keys.all? { |key| values[key].is_a?(String) && !values[key].strip.empty? }
|
|
25
25
|
|
|
26
26
|
inventory = settings.inventory(credentials: {
|
|
27
27
|
username: values.fetch("netdisco_username"), password: values.fetch("netdisco_password")
|
|
@@ -24,25 +24,25 @@ module Net
|
|
|
24
24
|
def self.examples(program:, mode:)
|
|
25
25
|
command = "ruby #{File.basename(program)}"
|
|
26
26
|
cases = if mode == :topology
|
|
27
|
-
[["
|
|
28
|
-
["
|
|
29
|
-
["
|
|
30
|
-
["
|
|
27
|
+
[["Preview filtered inventory", "--inventory-only --name edge --cache-only"],
|
|
28
|
+
["Discover neighbors", "--discover --hosts 192.0.2.10-20"],
|
|
29
|
+
["Generate a read-only plan", "--plan --host 192.0.2.10"],
|
|
30
|
+
["Explicitly apply, read back and save", "--apply --host 192.0.2.10"]]
|
|
31
31
|
elsif mode == :tftp
|
|
32
|
-
[["
|
|
33
|
-
["
|
|
34
|
-
["
|
|
35
|
-
["
|
|
32
|
+
[["Preview devices selected for upload", "--plan --hosts 192.0.2.10-20"],
|
|
33
|
+
["Upload from one device using the configured server", "--host 192.0.2.10"],
|
|
34
|
+
["Upload concurrently from an IP range", "--hosts 192.0.2.10-20 --parallel 8"],
|
|
35
|
+
["Upload and verify server files", "--host 192.0.2.10 --tftp-root /srv/tftp --success-policy verified"]]
|
|
36
36
|
else
|
|
37
|
-
[["
|
|
38
|
-
["
|
|
39
|
-
["
|
|
40
|
-
["
|
|
37
|
+
[["Back up using the configuration", ""], ["Preview an IP range", "--plan --hosts 192.0.2.10-20"],
|
|
38
|
+
["Back up one device configuration", "--host 192.0.2.10"],
|
|
39
|
+
["Filter by name and exclude devices", "--name szx10 --exclude-name csw,asw,wlc --parallel 8"],
|
|
40
|
+
["Preview database inventory", "--config examples/inventory.yml --plan"]]
|
|
41
41
|
end
|
|
42
|
-
cases.unshift(["
|
|
43
|
-
["
|
|
44
|
-
*cases.map { |label, arguments| " #{label}
|
|
45
|
-
"
|
|
42
|
+
cases.unshift(["Create missing configuration and exit", "--init --non-interactive"])
|
|
43
|
+
["Common examples (replace IPs with actual inventory addresses):",
|
|
44
|
+
*cases.map { |label, arguments| " #{label}:\n #{command} #{arguments}" },
|
|
45
|
+
"Intersect different inclusion types; exclusions take precedence. Repeat options or separate values with commas."].join("\n")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -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
|