net-connector 0.13.0 → 0.14.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +1 -1
  4. data/README.zh-CN.md +1 -1
  5. data/docs/VERIFICATION.md +14 -5
  6. data/docs/architecture.md +9 -4
  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/base.rb +3 -3
  14. data/lib/net/connector/device/running_config.rb +1 -1
  15. data/lib/net/connector/device/scope.rb +5 -1
  16. data/lib/net/connector/device/tftp/strategy.rb +8 -3
  17. data/lib/net/connector/device/tftp.rb +13 -4
  18. data/lib/net/connector/device/topology/audit.rb +103 -0
  19. data/lib/net/connector/device/topology/plan.rb +4 -4
  20. data/lib/net/connector/device/topology.rb +23 -1
  21. data/lib/net/connector/engine/error/metadata.rb +10 -2
  22. data/lib/net/connector/engine/errors.rb +4 -2
  23. data/lib/net/connector/engine/known_hosts.rb +40 -21
  24. data/lib/net/connector/netdisco/address_filter.rb +86 -0
  25. data/lib/net/connector/netdisco/cli/backup.rb +80 -25
  26. data/lib/net/connector/netdisco/cli/help.rb +47 -0
  27. data/lib/net/connector/netdisco/cli/options.rb +111 -18
  28. data/lib/net/connector/netdisco/cli/topology.rb +358 -0
  29. data/lib/net/connector/netdisco/config/initializer.rb +146 -0
  30. data/lib/net/connector/netdisco/{config_file.rb → config.rb} +26 -4
  31. data/lib/net/connector/netdisco/device/policy.rb +77 -0
  32. data/lib/net/connector/netdisco/device.rb +14 -3
  33. data/lib/net/connector/netdisco/fleet/options.rb +93 -0
  34. data/lib/net/connector/netdisco/fleet.rb +114 -59
  35. data/lib/net/connector/netdisco/inventory/cache.rb +42 -4
  36. data/lib/net/connector/netdisco/inventory/http.rb +35 -21
  37. data/lib/net/connector/netdisco/operations/batch.rb +1 -1
  38. data/lib/net/connector/netdisco/operations/outcome.rb +2 -2
  39. data/lib/net/connector/netdisco/operations.rb +8 -8
  40. data/lib/net/connector/netdisco/plan.rb +39 -16
  41. data/lib/net/connector/netdisco/planner.rb +22 -8
  42. data/lib/net/connector/netdisco/progress.rb +45 -2
  43. data/lib/net/connector/netdisco/rules.rb +34 -17
  44. data/lib/net/connector/netdisco/settings.rb +88 -23
  45. data/lib/net/connector/netdisco/tftp/policy.rb +152 -0
  46. data/lib/net/connector/netdisco/topology/options.rb +48 -0
  47. data/lib/net/connector/netdisco/worker.rb +2 -1
  48. data/lib/net/connector/netdisco.rb +1 -0
  49. data/lib/net/connector/vendor/h3c/tftp.rb +30 -0
  50. data/lib/net/connector/vendor/radware/tftp.rb +10 -1
  51. data/lib/net/connector/version.rb +1 -1
  52. metadata +60 -49
  53. data/examples/inventory_sql.yml +0 -20
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "plan"
4
+ require_relative "stages"
5
+
6
+ module Net
7
+ module Connector
8
+ class Topology
9
+ # 逐接口区分可核实变更、未核实证据和只读平台;计划只包含明确获准的接口范围。
10
+ Audit = Data.define(:plan, :comparisons, :issues, :manual_required) do
11
+ def initialize(plan:, comparisons:, issues:, manual_required:)
12
+ raise ArgumentError, "audit plan must be a topology plan or nil" unless plan.nil? || plan.instance_of?(Plan)
13
+ unless [true, false].include?(manual_required) && comparisons.is_a?(Array) && issues.is_a?(Array)
14
+ raise ArgumentError, "invalid topology audit"
15
+ end
16
+ super(plan: plan, comparisons: copy_rows(comparisons), issues: copy_rows(issues), manual_required: manual_required)
17
+ end
18
+
19
+ def complete? = issues.empty?
20
+ def changes = comparisons.count { |entry| !%i[matches unverified].include?(entry[:status]) }
21
+ def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
22
+
23
+ private
24
+
25
+ def copy_rows(rows)
26
+ rows.map do |row|
27
+ unless row.is_a?(Hash) && row.keys.all?(Symbol) && row.values.all? { |value| value.nil? || value.is_a?(String) || value.is_a?(Symbol) }
28
+ raise ArgumentError, "audit rows must contain scalar metadata"
29
+ end
30
+ row.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
31
+ end.freeze
32
+ end
33
+ end
34
+
35
+ # 纯计划构造器不连接设备;协议命令仍由策略及 Stages 生成。
36
+ class AuditBuilder
37
+ def initialize(host:, vendor:, strategy:, neighbors:, descriptions:, formatter:)
38
+ @host, @vendor, @strategy = host, vendor, strategy
39
+ @neighbors, @descriptions, @formatter = neighbors, descriptions, formatter
40
+ @comparisons, @issues, @evidence, @changes = [], [], {}, []
41
+ end
42
+
43
+ def call
44
+ groups = @neighbors.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
45
+ groups.each { |interface, peers| inspect_interface(interface, peers) }
46
+ writable = @strategy.supports?(:interface_description_changes)
47
+ plan = build_plan if writable
48
+ Audit.new(plan: plan, comparisons: @comparisons, issues: @issues,
49
+ manual_required: !writable && @comparisons.any? { |entry| entry[:status] != :matches })
50
+ end
51
+
52
+ private
53
+
54
+ def inspect_interface(interface, peers)
55
+ peer = peers.first
56
+ current = @descriptions[interface]
57
+ row = { interface: peer.local_interface, neighbor: peer.neighbor_name, port: peer.neighbor_interface,
58
+ current: current, expected: nil, status: :unverified }
59
+ @comparisons << row
60
+ if peers.size != 1 || peer.neighbor_name.empty? || peer.neighbor_interface.empty?
61
+ return reject_interface(row, :ambiguous_neighbor)
62
+ end
63
+ return reject_interface(row, :interface_missing) unless @descriptions.key?(interface)
64
+
65
+ proposed = @formatter.call(peer)
66
+ InterfaceDescription.validate!(proposed)
67
+ local = @strategy.configuration_interface(peer.local_interface)
68
+ InterfaceDescription.validate_interface!(local)
69
+ row.merge!(expected: proposed, status: compare_status(current, proposed))
70
+ @evidence[interface] = [peer.neighbor_name, peer.neighbor_interface, current]
71
+ return if current == proposed
72
+
73
+ @changes << Change.new(interface: local, old_description: current, new_description: proposed, neighbor: peer)
74
+ rescue ArgumentError
75
+ reject_interface(row, :unsafe_description)
76
+ end
77
+
78
+ def reject_interface(row, code)
79
+ row[:reason] = code
80
+ @issues << { interface: row[:interface], code: code }
81
+ nil
82
+ end
83
+
84
+ def compare_status(current, proposed)
85
+ return :matches if current == proposed
86
+ return :missing_description if current.empty?
87
+
88
+ :content_mismatch
89
+ end
90
+
91
+ def build_plan
92
+ commands = if @changes.empty?
93
+ []
94
+ else
95
+ Stages.build(strategy: @strategy, host: @host, changes: @changes).values.flat_map { |script| script.map(&:text) }
96
+ end
97
+ Plan.new(host: @host, vendor: @vendor, evidence: @evidence, changes: @changes, commands: commands, scope: :interfaces)
98
+ end
99
+ end
100
+ private_constant :AuditBuilder
101
+ end
102
+ end
103
+ end
@@ -6,17 +6,17 @@ module Net
6
6
  module Connector
7
7
  class Topology
8
8
  # 审批树在领域边界固定;单设备与批量操作共享同一不可变计划。
9
- Plan = Data.define(:host, :vendor, :evidence, :changes, :commands) do
10
- def initialize(host:, vendor:, evidence:, changes:, commands:)
9
+ Plan = Data.define(:host, :vendor, :evidence, :changes, :commands, :scope) do
10
+ def initialize(host:, vendor:, evidence:, changes:, commands:, scope: :device)
11
11
  unless host.is_a?(String) && vendor.is_a?(Symbol) && evidence.is_a?(Hash) &&
12
12
  changes.is_a?(Array) && changes.all? { |change| change.instance_of?(Change) } &&
13
- commands.is_a?(Array) && commands.all?(String)
13
+ commands.is_a?(Array) && commands.all?(String) && %i[device interfaces].include?(scope)
14
14
  raise ArgumentError, "description plan contains invalid fields"
15
15
  end
16
16
 
17
17
  super(host: host.frozen? ? host : host.dup.freeze, vendor: vendor,
18
18
  evidence: copy_evidence(evidence), changes: changes.dup.freeze,
19
- commands: commands.map { |command| command.frozen? ? command : command.dup.freeze }.freeze)
19
+ commands: commands.map { |command| command.frozen? ? command : command.dup.freeze }.freeze, scope: scope)
20
20
  end
21
21
 
22
22
  def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
@@ -3,6 +3,7 @@
3
3
  require_relative "topology/strategy"
4
4
  require_relative "topology/stages"
5
5
  require_relative "topology/plan"
6
+ require_relative "topology/audit"
6
7
 
7
8
  module Net
8
9
  module Connector
@@ -26,6 +27,11 @@ module Net
26
27
  Topology.new(self).plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
27
28
  end
28
29
 
30
+ # 核实各接口,保留缺失/冲突证据并为可确认的接口构造独立计划。
31
+ def audit_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
32
+ Topology.new(self).audit_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
33
+ end
34
+
29
35
  # 明确确认且现场证据未变化时执行接口描述计划。
30
36
  def apply_interface_descriptions(plan, confirmed: false)
31
37
  Topology.new(self).apply_interface_descriptions(plan, confirmed: confirmed)
@@ -151,6 +157,17 @@ module Net
151
157
  end
152
158
  end
153
159
 
160
+ def audit_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
161
+ unless [abbreviate, lowercase].all? { |value| value == true || value == false }
162
+ raise ArgumentError, "description formatting options must be boolean"
163
+ end
164
+ formatter ||= ->(peer) { InterfaceDescription.format(peer, abbreviate: abbreviate, lowercase: lowercase) }
165
+ @device.with_operation(:plan) do
166
+ AuditBuilder.new(host: @device.host, vendor: @device.vendor, strategy: @strategy,
167
+ neighbors: neighbors, descriptions: interface_descriptions, formatter: formatter).call
168
+ end
169
+ end
170
+
154
171
  # 比对邻居和现有描述,生成包含确切命令的只读变更计划。
155
172
  def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
156
173
  validate_change_support!(:plan) if @strategy.supports?(:neighbors)
@@ -246,7 +263,12 @@ module Net
246
263
  # 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
247
264
  def revalidate_plan!(plan)
248
265
  discovered = neighbors
249
- evidence = evidence_for(discovered, interface_descriptions)
266
+ current = interface_descriptions
267
+ if plan.scope == :interfaces
268
+ discovered = discovered.select { |neighbor| plan.evidence.key?(@strategy.interface_key(neighbor.local_interface)) }
269
+ current = current.slice(*plan.evidence.keys)
270
+ end
271
+ evidence = evidence_for(discovered, current)
250
272
  current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
251
273
  same_neighbors = plan.changes.all? do |change|
252
274
  current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
@@ -8,7 +8,7 @@ module Net
8
8
  # 日志与报告共用固定诊断词表;未知元数据可能来自配置正文,不能作为自由文本输出。
9
9
  module Metadata
10
10
  CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
11
- PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
11
+ PromptError ConnectionClosed TransportError DeviceError ConfigurationError ScriptError InternalError
12
12
  OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
13
13
  BackupPersistenceError BackupFinalizeError TftpCompletionError].freeze
14
14
  TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
@@ -24,7 +24,7 @@ module Net
24
24
  Net::Connector::Netdisco::Tftp::Archive::Unavailable Net::Connector::Netdisco::Tftp::Archive::Failure]).freeze
25
25
  CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
26
26
  %i[authentication_failed connection_failed connection_refused no_route connection_reset
27
- connection_timeout host_key_changed host_key_untrusted known_hosts_busy rsa_too_small unsupported_cipher
27
+ connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe rsa_too_small unsupported_cipher
28
28
  ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
29
29
  description_unconfirmed description_unsupported incomplete_configuration interface_missing
30
30
  neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
@@ -46,6 +46,14 @@ module Net
46
46
 
47
47
  def known_type?(name) = name.instance_of?(String) && TYPES.include?(name)
48
48
 
49
+ # 显示名白名单不授予复制私有字段的权限;外部子类可以重写 Class.name。
50
+ def trusted_error_class?(type)
51
+ name = type.name
52
+ known_type?(name) && Object.const_get(name).equal?(type)
53
+ rescue NameError
54
+ false
55
+ end
56
+
49
57
  def type(name)
50
58
  return if name.nil?
51
59
 
@@ -16,7 +16,7 @@ module Net
16
16
  @host = host
17
17
  @phase = phase
18
18
  @command = command
19
- @output = output.freeze
19
+ @output = output.dup.freeze
20
20
  @source = source
21
21
  @line = line
22
22
  @underlying = underlying
@@ -27,7 +27,7 @@ module Net
27
27
  def with_diagnostics(message:, **context)
28
28
  context = { code: code, host: host, phase: phase, command: command, output: output,
29
29
  source: source, line: line, underlying: underlying }.merge(context)
30
- return self.class.new(message, **context) unless Error::Metadata.known_type?(self.class.name)
30
+ return self.class.new(message, **context) unless Error::Metadata.trusted_error_class?(self.class)
31
31
 
32
32
  copy = self.class.allocate
33
33
  instance_variables.each { |name| copy.instance_variable_set(name, instance_variable_get(name)) }
@@ -57,6 +57,8 @@ module Net
57
57
 
58
58
  class DeviceError < Error; end
59
59
 
60
+ class ConfigurationError < Error; end
61
+
60
62
  class ScriptError < Error; end
61
63
 
62
64
  class InternalError < Error; end
@@ -31,28 +31,28 @@ module Net
31
31
  end
32
32
 
33
33
  def commit
34
- additions = File.binread(path).lines("\n") - @original.lines("\n")
35
- return if additions.empty?
36
-
37
- with_lock do
38
- current = read_shared
39
- Tempfile.create([".nc-known-hosts-merge-", ""], File.dirname(@destination)) do |file|
40
- file.write(current)
41
- file.flush
42
- reject_changed_key!(file.path) unless @replace
43
- remove_host(file.path) if @replace
44
- contents = File.binread(file.path)
45
- entries = contents.lines("\n")
46
- additions.each { |line| entries << line unless entries.include?(line) }
47
- File.open(file.path, "wb", 0o600) do |replacement|
48
- replacement.write(entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join(""))
49
- replacement.flush
50
- replacement.fsync
34
+ # 纯会话引擎加载不触及文件业务;实际登记信任时才加载持久化协议。
35
+ require_relative "../storage/atomic_file"
36
+ begin
37
+ additions = File.binread(path).lines("\n") - @original.lines("\n")
38
+ return if additions.empty?
39
+
40
+ with_lock do
41
+ current = read_shared
42
+ Tempfile.create([".nc-known-hosts-merge-", ""], File.dirname(@destination)) do |file|
43
+ file.write(current)
44
+ file.flush
45
+ reject_changed_key!(file.path) unless @replace
46
+ remove_host(file.path) if @replace
47
+ contents = merge_entries(File.binread(file.path), additions)
48
+ Storage::AtomicFile.write(@destination, contents)
49
+ ensure
50
+ File.unlink(file.path + ".old") if file && File.exist?(file.path + ".old")
51
51
  end
52
- File.rename(file.path, @destination)
53
- ensure
54
- File.unlink(file.path + ".old") if file && File.exist?(file.path + ".old")
55
52
  end
53
+ rescue Storage::AtomicFile::WriteError => error
54
+ # 已替换但目录未同步时报告完成边界,不能把新信任误当作尚未写入。
55
+ raise ConnectionError.new(error.message, code: error.code, host: @host, phase: :login), cause: nil
56
56
  end
57
57
  end
58
58
 
@@ -71,6 +71,19 @@ module Net
71
71
 
72
72
  private
73
73
 
74
+ # 保留已有条目和顺序;新增条目只登记一次,避免每条新增密钥扫描整份清单。
75
+ def merge_entries(contents, additions)
76
+ entries = contents.lines("\n")
77
+ present = entries.each_with_object({}) { |line, index| index[line.freeze] = true }
78
+ additions.each do |line|
79
+ next if present.key?(line)
80
+
81
+ entries << line
82
+ present[line.freeze] = true
83
+ end
84
+ entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join("")
85
+ end
86
+
74
87
  # 临时文件返回后先登记所有权,阻塞构造期间仍允许取消。
75
88
  def create_snapshot
76
89
  failure = nil
@@ -110,12 +123,18 @@ module Net
110
123
 
111
124
  def read_shared
112
125
  File.open(@destination, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
113
- raise IOError, "known_hosts must be a regular file" unless file.stat.file?
126
+ raise unsafe_file_error, cause: nil unless file.stat.file?
114
127
 
115
128
  file.read
116
129
  end
117
130
  rescue Errno::ENOENT
118
131
  ""
132
+ rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
133
+ raise unsafe_file_error, cause: nil
134
+ end
135
+
136
+ def unsafe_file_error
137
+ ConnectionError.new("known_hosts must be a managed regular file", code: :known_hosts_unsafe, host: @host, phase: :login)
119
138
  end
120
139
 
121
140
  def reject_changed_key!(current_path)
@@ -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