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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +1 -1
- data/README.zh-CN.md +1 -1
- data/docs/VERIFICATION.md +14 -5
- data/docs/architecture.md +9 -4
- 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/base.rb +3 -3
- data/lib/net/connector/device/running_config.rb +1 -1
- data/lib/net/connector/device/scope.rb +5 -1
- data/lib/net/connector/device/tftp/strategy.rb +8 -3
- 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/engine/error/metadata.rb +10 -2
- data/lib/net/connector/engine/errors.rb +4 -2
- data/lib/net/connector/engine/known_hosts.rb +40 -21
- 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/inventory/http.rb +35 -21
- 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/worker.rb +2 -1
- 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,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
|
-
|
|
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.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|