net-connector 0.13.0 → 0.14.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 +11 -0
- data/README.md +1 -1
- data/README.zh-CN.md +1 -1
- data/docs/VERIFICATION.md +5 -5
- data/docs/architecture.md +5 -2
- data/docs/netdisco.md +3 -3
- data/docs/netdisco.zh-CN.md +7 -7
- data/docs/usage.md +2 -2
- data/docs/usage.zh-CN.md +2 -2
- data/examples/backup.yml +47 -8
- data/examples/inventory.yml +39 -0
- data/lib/net/connector/device/tftp/strategy.rb +7 -2
- data/lib/net/connector/device/tftp.rb +13 -4
- data/lib/net/connector/device/topology/audit.rb +103 -0
- data/lib/net/connector/device/topology/plan.rb +4 -4
- data/lib/net/connector/device/topology.rb +23 -1
- data/lib/net/connector/netdisco/address_filter.rb +86 -0
- data/lib/net/connector/netdisco/cli/backup.rb +80 -25
- data/lib/net/connector/netdisco/cli/help.rb +47 -0
- data/lib/net/connector/netdisco/cli/options.rb +111 -18
- data/lib/net/connector/netdisco/cli/topology.rb +358 -0
- data/lib/net/connector/netdisco/config/initializer.rb +146 -0
- data/lib/net/connector/netdisco/{config_file.rb → config.rb} +26 -4
- data/lib/net/connector/netdisco/device/policy.rb +77 -0
- data/lib/net/connector/netdisco/device.rb +14 -3
- data/lib/net/connector/netdisco/fleet/options.rb +93 -0
- data/lib/net/connector/netdisco/fleet.rb +114 -59
- data/lib/net/connector/netdisco/inventory/cache.rb +42 -4
- data/lib/net/connector/netdisco/operations/batch.rb +1 -1
- data/lib/net/connector/netdisco/operations/outcome.rb +2 -2
- data/lib/net/connector/netdisco/operations.rb +8 -8
- data/lib/net/connector/netdisco/plan.rb +39 -16
- data/lib/net/connector/netdisco/planner.rb +22 -8
- data/lib/net/connector/netdisco/progress.rb +45 -2
- data/lib/net/connector/netdisco/rules.rb +34 -17
- data/lib/net/connector/netdisco/settings.rb +88 -23
- data/lib/net/connector/netdisco/tftp/policy.rb +152 -0
- data/lib/net/connector/netdisco/topology/options.rb +48 -0
- data/lib/net/connector/netdisco.rb +1 -0
- data/lib/net/connector/vendor/h3c/tftp.rb +30 -0
- data/lib/net/connector/vendor/radware/tftp.rb +10 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +60 -49
- data/examples/inventory_sql.yml +0 -20
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Netdisco
|
|
8
|
+
# 精确地址用索引,网段和范围只保存端点;绝不展开地址或发起扫描。
|
|
9
|
+
class AddressFilter
|
|
10
|
+
def initialize(values)
|
|
11
|
+
raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
|
|
12
|
+
|
|
13
|
+
exact = {}
|
|
14
|
+
ranges = {}
|
|
15
|
+
values.each do |value|
|
|
16
|
+
raise ArgumentError, "host entries must be nonempty strings" unless value.is_a?(String) && !value.empty?
|
|
17
|
+
|
|
18
|
+
if value.include?("/") || value.split("%", 2).first.include?("-")
|
|
19
|
+
first, last = endpoints(value)
|
|
20
|
+
(ranges[first.family] ||= []) << [first.to_i, last.to_i]
|
|
21
|
+
else
|
|
22
|
+
exact[IPAddr.new(value).to_s.freeze] = true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
@exact = exact.freeze
|
|
26
|
+
@ranges = ranges.transform_values { |items| merge_ranges(items) }.freeze
|
|
27
|
+
freeze
|
|
28
|
+
rescue IPAddr::Error
|
|
29
|
+
raise ArgumentError, "host entries must be IP addresses, networks or ordered ranges", cause: nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def empty? = @exact.empty? && @ranges.empty?
|
|
33
|
+
|
|
34
|
+
def include?(host)
|
|
35
|
+
return false if empty?
|
|
36
|
+
return true if @exact.key?(host)
|
|
37
|
+
return false unless host.is_a?(String) && !host.empty?
|
|
38
|
+
|
|
39
|
+
address = IPAddr.new(host)
|
|
40
|
+
return true if @exact.key?(address.to_s)
|
|
41
|
+
|
|
42
|
+
intervals = @ranges[address.family]
|
|
43
|
+
return false unless intervals
|
|
44
|
+
|
|
45
|
+
value = address.to_i
|
|
46
|
+
interval = intervals.bsearch { |_, last| last >= value }
|
|
47
|
+
interval && interval.first <= value ? true : false
|
|
48
|
+
rescue IPAddr::Error
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def endpoints(value)
|
|
55
|
+
if value.include?("/")
|
|
56
|
+
range = IPAddr.new(value).to_range
|
|
57
|
+
return [range.begin, range.end]
|
|
58
|
+
end
|
|
59
|
+
first, last = value.split("-", 2)
|
|
60
|
+
first = IPAddr.new(first)
|
|
61
|
+
if first.ipv4? && last&.match?(/\A\d+\z/)
|
|
62
|
+
last = first.to_s.rpartition(".").first + ".#{last}"
|
|
63
|
+
end
|
|
64
|
+
last = IPAddr.new(last)
|
|
65
|
+
unless first.family == last.family && first.to_i <= last.to_i && !(first.ipv6? && (first.zone_id || last.zone_id))
|
|
66
|
+
raise ArgumentError, "host ranges require ordered endpoints of the same address family", cause: nil
|
|
67
|
+
end
|
|
68
|
+
[first, last]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 合并重叠范围,使每台设备匹配的成本不随网段大小增长。
|
|
72
|
+
def merge_ranges(items)
|
|
73
|
+
merged = []
|
|
74
|
+
items.sort.each do |first, last|
|
|
75
|
+
if !merged.empty? && first <= merged.last.last + 1
|
|
76
|
+
merged.last[1] = [merged.last.last, last].max
|
|
77
|
+
else
|
|
78
|
+
merged << [first, last]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
merged.each(&:freeze).freeze
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -5,6 +5,7 @@ require "socket"
|
|
|
5
5
|
require_relative "options"
|
|
6
6
|
require_relative "credentials"
|
|
7
7
|
require_relative "../fleet"
|
|
8
|
+
require_relative "../config/initializer"
|
|
8
9
|
require_relative "../progress"
|
|
9
10
|
require_relative "../report/files"
|
|
10
11
|
require_relative "../../storage/batch_directory"
|
|
@@ -16,42 +17,41 @@ module Net
|
|
|
16
17
|
# 终端备份入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
|
|
17
18
|
class Backup
|
|
18
19
|
# 保存批次模式与调用方 IO,构造阶段不读取清单、不创建目录。
|
|
19
|
-
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr
|
|
20
|
+
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr,
|
|
21
|
+
default_config: nil, default_directory: nil, load_env: false)
|
|
20
22
|
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
21
23
|
|
|
22
24
|
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
25
|
+
@default_config, @default_directory = default_config, default_directory
|
|
26
|
+
@load_env = load_env
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# 先固定并校验策略,再预览、执行和保存报告;成功判定使用最终报告。
|
|
26
30
|
def run
|
|
27
|
-
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
|
|
31
|
+
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error, mode: @mode)
|
|
28
32
|
return 0 if options[:help]
|
|
33
|
+
apply_defaults(options)
|
|
34
|
+
return 0 if initialize_config?(options)
|
|
29
35
|
|
|
30
36
|
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
31
37
|
inventory, credentials = Credentials.resolve(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
32
38
|
progress = Progress.new(io: @error, enabled: !options[:no_progress], verbose: options[:verbose])
|
|
33
39
|
resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
|
|
34
40
|
fleet = Fleet.new(inventory: inventory, settings: settings, credentials: resolver, report_store: nil)
|
|
35
|
-
progress
|
|
36
|
-
|
|
37
|
-
refresh = options[:force_refresh] ? { force_refresh: true } : {}
|
|
38
|
-
plan = if @mode == :backup
|
|
39
|
-
fleet.plan_backup(limit_per_vendor: limit, **refresh)
|
|
40
|
-
else
|
|
41
|
-
fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?, **refresh)
|
|
42
|
-
end
|
|
43
|
-
inventory_warning(fleet)
|
|
44
|
-
progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
|
|
45
|
-
preview = plan_summary(plan)
|
|
46
|
-
@output.puts JSON.generate(plan: preview) if options[:json]
|
|
47
|
-
@output.flush
|
|
41
|
+
plan = read_plan(fleet, settings, options, progress, server_root)
|
|
42
|
+
return preview_status(plan, options) if options[:plan] || options[:inventory_only]
|
|
48
43
|
return 1 if options[:stdin_credentials] && @input.gets("\n")&.strip != "RUN"
|
|
49
44
|
|
|
50
|
-
Storage::AtomicFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(
|
|
45
|
+
Storage::AtomicFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(plan_summary(plan)))
|
|
51
46
|
report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
52
47
|
report = record_progress_error(report, progress)
|
|
53
48
|
report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
|
|
54
49
|
finish(report, progress, options, batch_directory, plan, settings.concurrency)
|
|
50
|
+
rescue Interrupt
|
|
51
|
+
130
|
|
52
|
+
rescue EOFError
|
|
53
|
+
@error.puts "配置输入未完成,未创建文件。"
|
|
54
|
+
2
|
|
55
55
|
rescue ArgumentError, Inventory::Error => exception
|
|
56
56
|
@error.puts "备份参数或清单错误:#{exception.message}"
|
|
57
57
|
2
|
|
@@ -62,6 +62,53 @@ module Net
|
|
|
62
62
|
|
|
63
63
|
private
|
|
64
64
|
|
|
65
|
+
def read_plan(fleet, settings, options, progress, server_root)
|
|
66
|
+
progress.reading_inventory
|
|
67
|
+
limit = settings.limit_per_vendor
|
|
68
|
+
refresh = options[:force_refresh] ? { force_refresh: true } : {}
|
|
69
|
+
plan = if @mode == :backup || options[:inventory_only]
|
|
70
|
+
fleet.plan_backup(limit_per_vendor: limit, **refresh)
|
|
71
|
+
else
|
|
72
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit,
|
|
73
|
+
allow_fixed_name_reuse: !server_root.nil? || !settings.tftp_verification_roots.empty?, **refresh)
|
|
74
|
+
end
|
|
75
|
+
inventory_warning(fleet)
|
|
76
|
+
progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
|
|
77
|
+
preview = plan_summary(plan)
|
|
78
|
+
@output.puts JSON.generate(plan: preview) if options[:json]
|
|
79
|
+
@output.flush
|
|
80
|
+
plan
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def preview_status(plan, options)
|
|
84
|
+
@output.puts(plan.selected.map { |device| "#{device.host}\t#{device.name}\t#{device.vendor}" }) unless options[:json]
|
|
85
|
+
plan.selected.empty? || plan.outcomes.compact.any? { |item| !%i[filtered sample_limit].include?(item.status) } ? 1 : 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_defaults(options)
|
|
89
|
+
values = options.fetch(:environment)
|
|
90
|
+
values["NC_CONFIG"] = @default_config if @default_config && !values.key?("NC_CONFIG") && !@env.key?("NC_CONFIG")
|
|
91
|
+
return unless @default_directory && !values.key?("NC_BACKUP_DIRECTORY")
|
|
92
|
+
|
|
93
|
+
path = values["NC_CONFIG"] || @env["NC_CONFIG"]
|
|
94
|
+
document = path && File.file?(path) ? Config.load(path) : {}
|
|
95
|
+
values["NC_BACKUP_DIRECTORY"] = @default_directory unless document.key?("NC_BACKUP_DIRECTORY")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def initialize_config?(options)
|
|
99
|
+
path = options.fetch(:environment)["NC_CONFIG"] || @env["NC_CONFIG"]
|
|
100
|
+
return false unless path && !File.exist?(path) && !File.symlink?(path)
|
|
101
|
+
|
|
102
|
+
document = { "backup" => { "directory" => @default_directory || "./backups", "concurrency" => 4 },
|
|
103
|
+
"netdisco" => { "source" => "http", "cache_mode" => "prefer" },
|
|
104
|
+
"inventory" => { "include_hosts" => [], "exclude_hosts" => [], "include_names" => [],
|
|
105
|
+
"exclude_names" => [], "include_vendors" => [], "exclude_vendors" => [], "name_match" => "contains" },
|
|
106
|
+
"ssh" => { "protocol" => "ssh", "host_key_policy" => "accept_new" } }
|
|
107
|
+
Config::Initializer.new(input: @input, output: @error).create(path: path, document: document, interactive: options[:interactive])
|
|
108
|
+
@error.puts "已初始化配置:#{path};检查配置后再次运行。"
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
65
112
|
# 清单离线回退和本地保存故障单独提示,不覆盖设备操作结果。
|
|
66
113
|
def inventory_warning(fleet)
|
|
67
114
|
if fleet.inventory_status && fleet.inventory_status[:source] == :stale_cache
|
|
@@ -74,15 +121,18 @@ module Net
|
|
|
74
121
|
|
|
75
122
|
# 预检完成后才创建批次目录;补充日志路径时保持原策略快照与动态凭据来源。
|
|
76
123
|
def prepare(options)
|
|
77
|
-
settings = CLI::Options.settings(options, env: @env).for_run(mode: @mode)
|
|
124
|
+
settings = CLI::Options.settings(options, env: @env, load_env: @load_env).for_run(mode: @mode)
|
|
78
125
|
success_policy = options.fetch(:success_policy, :selected)
|
|
79
126
|
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
80
127
|
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
81
|
-
server_root = tftp_root(settings, options) if @mode == :tftp
|
|
82
|
-
|
|
128
|
+
server_root = tftp_root(settings, options) if @mode == :tftp && !options[:inventory_only]
|
|
129
|
+
if success_policy == :verified && !server_root && settings.tftp_verification_roots.empty?
|
|
130
|
+
raise ArgumentError, "verified policy requires a local TFTP directory"
|
|
131
|
+
end
|
|
83
132
|
Tftp::Verifier.new(root: server_root) if server_root
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
settings = settings.with_overrides("NC_TFTP_ROOT" => server_root) if server_root
|
|
134
|
+
batch_directory = Storage::BatchDirectory.create(settings.backup_directory) unless options[:plan] || options[:inventory_only]
|
|
135
|
+
if batch_directory && @mode == :tftp && !settings.log_directory
|
|
86
136
|
settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
|
|
87
137
|
end
|
|
88
138
|
[settings, success_policy, server_root, batch_directory]
|
|
@@ -148,9 +198,12 @@ module Net
|
|
|
148
198
|
|
|
149
199
|
# 优先使用显式核验根目录;仅服务器是本机地址时探测本机惯用 TFTP 目录。
|
|
150
200
|
def tftp_root(settings, options)
|
|
151
|
-
server = settings.tftp_server
|
|
201
|
+
server = settings.tftp_server
|
|
202
|
+
if !server && settings.tftp_servers.empty?
|
|
203
|
+
raise ArgumentError, "YAML tftp.server or tftp.servers is required"
|
|
204
|
+
end
|
|
152
205
|
return options[:tftp_root] || settings.tftp_root if options[:tftp_root] || settings.tftp_root
|
|
153
|
-
return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
206
|
+
return unless server && Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
154
207
|
|
|
155
208
|
candidate = File.join(Dir.home, "Documents", "TFTP")
|
|
156
209
|
candidate if File.directory?(candidate)
|
|
@@ -162,8 +215,10 @@ module Net
|
|
|
162
215
|
vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
|
|
163
216
|
issues: plan.inventory.reject(&:ready?).group_by(&:issue).transform_values(&:size),
|
|
164
217
|
selected: plan.selected.map do |device|
|
|
165
|
-
filename =
|
|
166
|
-
{ host: device.host, name: device.name, vendor: device.vendor, backup_filename: filename }
|
|
218
|
+
filename = plan.mode == :backup ? device.backup_filename(style: :hostname_ip) : device.tftp_filename
|
|
219
|
+
entry = { host: device.host, name: device.name, vendor: device.vendor, backup_filename: filename }
|
|
220
|
+
entry[:transfer] = plan.transfers.fetch(device.host).to_h if plan.mode == :tftp && plan.transfers.key?(device.host)
|
|
221
|
+
entry
|
|
167
222
|
end }
|
|
168
223
|
end
|
|
169
224
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
module Netdisco
|
|
6
|
+
class CLI
|
|
7
|
+
# 简要帮助从实际解析器筛选;范例按执行模式生成完整命令。
|
|
8
|
+
module Help
|
|
9
|
+
COMMON = %w[config directory interactive non-interactive host hosts name exclude-name vendor
|
|
10
|
+
plan inventory-only concurrency refresh-inventory json quiet no-progress help help-all examples].freeze
|
|
11
|
+
|
|
12
|
+
def self.common(parser)
|
|
13
|
+
keep = true
|
|
14
|
+
parser.to_s.lines.select do |line|
|
|
15
|
+
flag = line[/--([a-z-]+)/, 1]
|
|
16
|
+
keep = COMMON.include?(flag) if flag
|
|
17
|
+
keep
|
|
18
|
+
end.join
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.examples(program:, mode:)
|
|
22
|
+
command = "ruby #{File.basename(program)}"
|
|
23
|
+
cases = if mode == :topology
|
|
24
|
+
[["预览过滤后的清单", "--inventory-only --name edge --cache-only"],
|
|
25
|
+
["发现邻居", "--discover --hosts 192.0.2.10-20"],
|
|
26
|
+
["生成只读计划", "--plan --host 192.0.2.10"],
|
|
27
|
+
["明确应用并读回保存", "--apply --host 192.0.2.10"]]
|
|
28
|
+
elsif mode == :tftp
|
|
29
|
+
[["预览待上传设备", "--plan --hosts 192.0.2.10-20"],
|
|
30
|
+
["单台上传,服务器沿用配置", "--host 192.0.2.10"],
|
|
31
|
+
["连续 IP 范围并发上传", "--hosts 192.0.2.10-20 --concurrency 8"],
|
|
32
|
+
["上传并核验服务器文件", "--host 192.0.2.10 --tftp-root /srv/tftp --success-policy verified"]]
|
|
33
|
+
else
|
|
34
|
+
[["沿用配置执行备份", ""], ["预览连续 IP 范围", "--plan --hosts 192.0.2.10-20"],
|
|
35
|
+
["单台设备配置备份", "--host 192.0.2.10"],
|
|
36
|
+
["按名称筛选并排除设备", "--name szx10 --exclude-name csw,asw,wlc --concurrency 8"],
|
|
37
|
+
["使用数据库清单预览", "--config examples/inventory.yml --plan"]]
|
|
38
|
+
end
|
|
39
|
+
["常用示例(IP 请替换为实际清单地址):",
|
|
40
|
+
*cases.map { |label, arguments| " #{label}:\n #{command} #{arguments}" },
|
|
41
|
+
"包含条件取交集,排除优先;同类参数可逗号分隔或重复传参。"].join("\n")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require_relative "../settings"
|
|
5
5
|
require_relative "../planner"
|
|
6
|
-
require_relative "../
|
|
6
|
+
require_relative "../config"
|
|
7
|
+
require_relative "help"
|
|
7
8
|
|
|
8
9
|
module Net
|
|
9
10
|
module Connector
|
|
@@ -12,9 +13,9 @@ module Net
|
|
|
12
13
|
# 可注入参数与输入输出的备份选项,不退出宿主进程。
|
|
13
14
|
class Options
|
|
14
15
|
# 复制参数数组,返回选项或帮助标记;非法输入抛错,不退出宿主进程。
|
|
15
|
-
def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
|
|
16
|
+
def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME, mode: :backup, &additional)
|
|
16
17
|
values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
|
|
17
|
-
parser = parser_for(values, output: output, program: program)
|
|
18
|
+
parser = parser_for(values, output: output, program: program, mode: mode, &additional)
|
|
18
19
|
remaining = parser.parse!(argv.dup)
|
|
19
20
|
return values if values[:help]
|
|
20
21
|
raise OptionParser::InvalidArgument unless remaining.empty?
|
|
@@ -30,29 +31,78 @@ module Net
|
|
|
30
31
|
raise ArgumentError, "参数无效:并发数须为 1 至 50,抽样须为 1 至 5;使用 --help 查看参数", cause: nil
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def self.parser_for(values, output:, program
|
|
34
|
+
def self.parser_for(values, output:, program:, mode:, &additional)
|
|
35
|
+
raise ArgumentError, "unknown CLI mode" unless %i[backup tftp topology].include?(mode)
|
|
36
|
+
|
|
34
37
|
OptionParser.new do |opts|
|
|
35
|
-
|
|
38
|
+
default = mode == :topology ? "默认只读规划" : "默认沿用配置"
|
|
39
|
+
opts.banner = "用法:ruby #{File.basename(program)} [选项](#{default})\n命令行参数优先;未指定的项沿用配置及默认值。无筛选时选择全部就绪设备。"
|
|
36
40
|
add_settings(opts, values)
|
|
37
|
-
add_execution(opts, values)
|
|
38
|
-
opts
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
opts
|
|
41
|
+
add_execution(opts, values, mode: mode)
|
|
42
|
+
add_terminal_options(opts, values)
|
|
43
|
+
if mode == :topology
|
|
44
|
+
add_topology(opts, values)
|
|
45
|
+
else
|
|
46
|
+
opts.on("--plan", "--inventory-only", "预览过滤后的清单,不连接设备") { values[:inventory_only] = true }
|
|
47
|
+
end
|
|
48
|
+
additional&.call(opts, values)
|
|
49
|
+
opts.on("--examples", "查看常用命令范例,不执行任务") do
|
|
50
|
+
output.puts Help.examples(program: program, mode: mode)
|
|
51
|
+
values[:help] = true
|
|
52
|
+
end
|
|
53
|
+
opts.on("-h", "--help", "显示常用选项") do
|
|
54
|
+
output.puts(mode == :topology ? opts : Help.common(opts))
|
|
55
|
+
values[:help] = true
|
|
56
|
+
end
|
|
57
|
+
opts.on("--help-all", "显示完整选项(凭据、抽样、SSH、自动化等)") { output.puts opts; values[:help] = true }
|
|
45
58
|
end
|
|
46
59
|
end
|
|
47
60
|
|
|
61
|
+
def self.add_terminal_options(opts, values)
|
|
62
|
+
opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
|
|
63
|
+
opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
|
|
64
|
+
opts.on("--quiet", "--no-progress", "关闭进度;可配合 --json") { values[:no_progress] = true }
|
|
65
|
+
opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
|
|
66
|
+
opts.on("--json", "在 stdout 输出 JSON;默认显示可读摘要") { values[:json] = true }
|
|
67
|
+
opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
|
|
68
|
+
opts.on("--interactive", "缺少配置时交互填写,已有配置保持原样") { values[:interactive] = true }
|
|
69
|
+
opts.on("--non-interactive", "缺少配置时使用默认值,不进行问答") { values[:interactive] = false }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.add_topology(opts, values)
|
|
73
|
+
{ discover: "发现邻居并检查描述", plan: "生成配置计划", apply: "修正、读回并保存" }.each do |mode, description|
|
|
74
|
+
opts.on("--#{mode}", description) do
|
|
75
|
+
raise ArgumentError, "topology modes are mutually exclusive" if values[:topology_mode]
|
|
76
|
+
|
|
77
|
+
values[:topology_mode] = mode
|
|
78
|
+
values[:apply] = true if mode == :apply
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
opts.on("--inventory-only", "只读取并过滤清单,不连接设备") { values[:inventory_only] = true }
|
|
82
|
+
opts.on("--limit N", Integer, "最多选择 N 台可发现设备") do |value|
|
|
83
|
+
raise OptionParser::InvalidArgument unless value.positive?
|
|
84
|
+
|
|
85
|
+
values[:topology_limit] = value
|
|
86
|
+
end
|
|
87
|
+
opts.on("--description-format TEXT", "模板仅支持 %{neighbor}、%{port}") { |value| values[:environment]["NC_TOPOLOGY_DESCRIPTION_FORMAT"] = value }
|
|
88
|
+
opts.on("--full-port-names", "不缩写邻居端口") { values[:environment]["NC_TOPOLOGY_ABBREVIATE"] = "false" }
|
|
89
|
+
opts.on("--lowercase-ports", "邻居端口转换成小写") { values[:environment]["NC_TOPOLOGY_LOWERCASE"] = "true" }
|
|
90
|
+
end
|
|
91
|
+
|
|
48
92
|
# 正式 CLI 与批次示例共用并发、抽样、核验和成功策略选项。
|
|
49
|
-
def self.add_execution(opts, values)
|
|
93
|
+
def self.add_execution(opts, values, mode: nil)
|
|
50
94
|
opts.on("--refresh-inventory", "强制刷新清单;远端失败时回退已验证历史") { values[:force_refresh] = true }
|
|
95
|
+
opts.on("--cache-only", "仅使用已验证的清单缓存,不查询远端") { values[:environment]["ND_CACHE_MODE"] = "only" }
|
|
96
|
+
opts.on("--cache-mode MODE", %w[prefer only refresh], "清单缓存 prefer / only / refresh") do |value|
|
|
97
|
+
values[:environment]["ND_CACHE_MODE"] = value
|
|
98
|
+
end
|
|
51
99
|
opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
|
|
52
100
|
raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
|
|
53
101
|
|
|
54
102
|
values[:environment]["NC_CONCURRENCY"] = count.to_s
|
|
55
103
|
end
|
|
104
|
+
return if mode == :topology
|
|
105
|
+
|
|
56
106
|
opts.on("--sample N", "--limit-per-vendor N", Integer, "每厂商抽样 1 至 5 台") do |count|
|
|
57
107
|
Planner.validate_limit!(count)
|
|
58
108
|
values[:limit] = count
|
|
@@ -65,18 +115,26 @@ module Net
|
|
|
65
115
|
end
|
|
66
116
|
|
|
67
117
|
# 公开 CLI 参数进入 overrides,凭据留在 environment;内部键不会重新开放已移除环境设置。
|
|
68
|
-
def self.settings(options, env: ENV)
|
|
118
|
+
def self.settings(options, env: ENV, load_env: false)
|
|
69
119
|
raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
|
|
120
|
+
if options[:host] && options.fetch(:environment).key?("NC_INCLUDE_HOSTS")
|
|
121
|
+
raise ArgumentError, "--host cannot be combined with --hosts"
|
|
122
|
+
end
|
|
70
123
|
|
|
71
124
|
environment = environment(options, env: env)
|
|
72
125
|
path = environment["NC_CONFIG"]
|
|
73
|
-
defaults = path && !path.empty? ?
|
|
74
|
-
keys =
|
|
126
|
+
defaults = path && !path.empty? ? Config.load(path) : {}
|
|
127
|
+
keys = Config::FIELDS.values.flat_map { |fields| fields.values.map(&:first) }
|
|
128
|
+
if load_env
|
|
129
|
+
# 显式启用时,公开环境值只填补 YAML;CLI 覆盖与凭据来源仍分开处理。
|
|
130
|
+
defaults = env.to_h.slice(*keys).merge(defaults)
|
|
131
|
+
environment = environment.reject { |key, _| keys.include?(key) }
|
|
132
|
+
end
|
|
75
133
|
overrides = options.fetch(:environment).slice(*keys)
|
|
76
134
|
environment = environment.reject { |key, _| overrides.key?(key) && !Settings::ENVIRONMENT_KEYS.include?(key) }
|
|
77
135
|
{ source: "ND_SOURCE", query: "ND_QUERY", query_params: "ND_QUERY_PARAMS",
|
|
78
136
|
max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
|
|
79
|
-
limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
|
|
137
|
+
limit: "NC_SAMPLE_PER_VENDOR", tftp_root: "NC_TFTP_ROOT" }.each do |option, key|
|
|
80
138
|
overrides[key] = options[option].to_s if options.key?(option)
|
|
81
139
|
end
|
|
82
140
|
overrides["NC_SAMPLE_PER_VENDOR"] = nil if options[:all]
|
|
@@ -85,6 +143,7 @@ module Net
|
|
|
85
143
|
|
|
86
144
|
# 共用配置与凭据参数登记;具体入口决定后续预览、执行或导出。
|
|
87
145
|
def self.add_settings(opts, values)
|
|
146
|
+
add_filters(opts, values)
|
|
88
147
|
{
|
|
89
148
|
"--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
|
|
90
149
|
"--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
|
|
@@ -109,6 +168,40 @@ module Net
|
|
|
109
168
|
end
|
|
110
169
|
end
|
|
111
170
|
|
|
171
|
+
# 同类 CLI 筛选替换 YAML;重复参数在本次调用内合并,绝不把范围展开为地址列表。
|
|
172
|
+
def self.add_filters(opts, values)
|
|
173
|
+
{
|
|
174
|
+
"--hosts PATTERNS" => ["NC_INCLUDE_HOSTS", "包含地址、网段或范围,逗号分隔"],
|
|
175
|
+
"--exclude-hosts PATTERNS" => ["NC_EXCLUDE_HOSTS", "排除地址、网段或范围"],
|
|
176
|
+
"--name PATTERNS" => ["NC_INCLUDE_NAMES", "包含设备名称,可重复或逗号分隔"],
|
|
177
|
+
"--exclude-name PATTERNS" => ["NC_EXCLUDE_NAMES", "排除设备名称,可重复或逗号分隔"],
|
|
178
|
+
"--vendor NAMES" => ["NC_INCLUDE_VENDORS", "包含连接器厂商名"],
|
|
179
|
+
"--exclude-vendor NAMES" => ["NC_EXCLUDE_VENDORS", "排除连接器厂商名"]
|
|
180
|
+
}.each do |flag, (key, description)|
|
|
181
|
+
opts.on(flag, description) do |value|
|
|
182
|
+
items = value.split(",", -1).map(&:strip)
|
|
183
|
+
raise OptionParser::InvalidArgument if items.empty? || items.any?(&:empty?)
|
|
184
|
+
|
|
185
|
+
previous = values[:environment][key]&.split(",") || []
|
|
186
|
+
values[:environment][key] = (previous + items).uniq.join(",")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
opts.on("--name-match MODE", %w[contains prefix glob], "名称匹配方式:contains / prefix / glob") do |value|
|
|
190
|
+
values[:environment]["NC_NAME_MATCH"] = value
|
|
191
|
+
end
|
|
192
|
+
opts.on("--host IP", "选择单台设备,可重复") do |value|
|
|
193
|
+
raise OptionParser::InvalidArgument if value.empty? || value.match?(/[\/,]/)
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
address = IPAddr.new(value).to_s
|
|
197
|
+
rescue IPAddr::Error
|
|
198
|
+
raise OptionParser::InvalidArgument
|
|
199
|
+
end
|
|
200
|
+
values[:host] = [*values[:host]&.split(","), address].uniq.join(",")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
private_class_method :add_filters
|
|
204
|
+
|
|
112
205
|
# 复制原环境并合并显式凭据,清除被通用凭据覆盖的厂商值及旧 API key。
|
|
113
206
|
def self.environment(options, env: ENV)
|
|
114
207
|
result = env.to_h.dup
|