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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +1 -1
  4. data/README.zh-CN.md +1 -1
  5. data/docs/VERIFICATION.md +5 -5
  6. data/docs/architecture.md +5 -2
  7. data/docs/netdisco.md +3 -3
  8. data/docs/netdisco.zh-CN.md +7 -7
  9. data/docs/usage.md +2 -2
  10. data/docs/usage.zh-CN.md +2 -2
  11. data/examples/backup.yml +47 -8
  12. data/examples/inventory.yml +39 -0
  13. data/lib/net/connector/device/tftp/strategy.rb +7 -2
  14. data/lib/net/connector/device/tftp.rb +13 -4
  15. data/lib/net/connector/device/topology/audit.rb +103 -0
  16. data/lib/net/connector/device/topology/plan.rb +4 -4
  17. data/lib/net/connector/device/topology.rb +23 -1
  18. data/lib/net/connector/netdisco/address_filter.rb +86 -0
  19. data/lib/net/connector/netdisco/cli/backup.rb +80 -25
  20. data/lib/net/connector/netdisco/cli/help.rb +47 -0
  21. data/lib/net/connector/netdisco/cli/options.rb +111 -18
  22. data/lib/net/connector/netdisco/cli/topology.rb +358 -0
  23. data/lib/net/connector/netdisco/config/initializer.rb +146 -0
  24. data/lib/net/connector/netdisco/{config_file.rb → config.rb} +26 -4
  25. data/lib/net/connector/netdisco/device/policy.rb +77 -0
  26. data/lib/net/connector/netdisco/device.rb +14 -3
  27. data/lib/net/connector/netdisco/fleet/options.rb +93 -0
  28. data/lib/net/connector/netdisco/fleet.rb +114 -59
  29. data/lib/net/connector/netdisco/inventory/cache.rb +42 -4
  30. data/lib/net/connector/netdisco/operations/batch.rb +1 -1
  31. data/lib/net/connector/netdisco/operations/outcome.rb +2 -2
  32. data/lib/net/connector/netdisco/operations.rb +8 -8
  33. data/lib/net/connector/netdisco/plan.rb +39 -16
  34. data/lib/net/connector/netdisco/planner.rb +22 -8
  35. data/lib/net/connector/netdisco/progress.rb +45 -2
  36. data/lib/net/connector/netdisco/rules.rb +34 -17
  37. data/lib/net/connector/netdisco/settings.rb +88 -23
  38. data/lib/net/connector/netdisco/tftp/policy.rb +152 -0
  39. data/lib/net/connector/netdisco/topology/options.rb +48 -0
  40. data/lib/net/connector/netdisco.rb +1 -0
  41. data/lib/net/connector/vendor/h3c/tftp.rb +30 -0
  42. data/lib/net/connector/vendor/radware/tftp.rb +10 -1
  43. data/lib/net/connector/version.rb +1 -1
  44. metadata +60 -49
  45. 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.reading_inventory
36
- limit = settings.limit_per_vendor
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(preview))
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
- raise ArgumentError, "verified policy requires a local TFTP directory" if success_policy == :verified && !server_root
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
- batch_directory = Storage::BatchDirectory.create(settings.backup_directory)
85
- if @mode == :tftp && !settings.log_directory
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 || (raise ArgumentError, "YAML tftp.server is required")
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 = @mode == :backup ? device.backup_filename(style: :hostname_ip) : device.tftp_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 "../config_file"
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
- opts.banner = "用法:ruby #{File.basename(program)} [选项](默认全量)"
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.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
39
- opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
40
- opts.on("--no-progress", "关闭进度显示") { values[:no_progress] = true }
41
- opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
42
- opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
43
- opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
44
- opts.on("-h", "--help", "显示帮助") { output.puts opts; values[:help] = true }
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? ? ConfigFile.load(path) : {}
74
- keys = ConfigFile::FIELDS.values.flat_map { |fields| fields.values.map(&:first) }
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