net-connector 0.12.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 +21 -0
- data/README.md +1 -1
- data/README.zh-CN.md +1 -1
- data/docs/VERIFICATION.md +19 -5
- data/docs/architecture.md +18 -5
- data/docs/netdisco.md +3 -3
- data/docs/netdisco.zh-CN.md +7 -7
- data/docs/templates.md +13 -7
- data/docs/usage.md +2 -2
- data/docs/usage.zh-CN.md +2 -2
- data/docs/vendors.md +14 -3
- data/examples/backup.yml +47 -8
- data/examples/inventory.yml +39 -0
- data/lib/net/connector/device/profile.rb +1 -1
- data/lib/net/connector/device/remote_backup/receipt.rb +4 -0
- data/lib/net/connector/device/remote_backup/target.rb +3 -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/change.rb +22 -0
- data/lib/net/connector/device/topology/immediate_strategy.rb +3 -9
- data/lib/net/connector/device/topology/interface_blocks.rb +18 -0
- data/lib/net/connector/device/topology/neighbor.rb +22 -0
- data/lib/net/connector/device/topology/plan.rb +39 -0
- data/lib/net/connector/device/topology/quoted_text.rb +23 -0
- data/lib/net/connector/device/topology/strategy.rb +4 -0
- data/lib/net/connector/device/topology.rb +29 -12
- data/lib/net/connector/netdisco/address_filter.rb +86 -0
- data/lib/net/connector/netdisco/batch.rb +33 -4
- 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 -16
- data/lib/net/connector/netdisco/operations.rb +20 -38
- 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/report.rb +14 -11
- 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/templates/cisco/show_running_config.textfsm +1 -0
- data/lib/net/connector/templates/cisco_nxos/show_running_config.textfsm +1 -0
- data/lib/net/connector/templates/fortinet/show_full_configuration.textfsm +21 -0
- data/lib/net/connector/templates/h3c/display_current_configuration.textfsm +1 -0
- data/lib/net/connector/templates/h3c/{display_lldp_neighbor_information_list_local_first.textfsm → display_lldp_neighbor_information_list.textfsm} +7 -1
- data/lib/net/connector/templates/hillstone/show_configuration_running.textfsm +1 -0
- data/lib/net/connector/templates/huawei/display_current_configuration.textfsm +13 -0
- data/lib/net/connector/templates/huawei/display_lldp_neighbor_brief.textfsm +9 -0
- data/lib/net/connector/templates/index +22 -12
- data/lib/net/connector/templates/juniper/show_configuration_display_set.textfsm +4 -0
- data/lib/net/connector/templates/palo_alto/show.textfsm +1 -0
- data/lib/net/connector/templates/radware/cfg_dump.textfsm +5 -3
- data/lib/net/connector/vendor/fortinet/topology.rb +24 -1
- data/lib/net/connector/vendor/h3c/tftp.rb +30 -0
- data/lib/net/connector/vendor/h3c/topology.rb +4 -5
- data/lib/net/connector/vendor/huawei/topology.rb +48 -0
- data/lib/net/connector/vendor/huawei.rb +2 -0
- data/lib/net/connector/vendor/juniper/topology.rb +7 -6
- data/lib/net/connector/vendor/palo_alto/topology.rb +1 -7
- data/lib/net/connector/vendor/radware/tftp.rb +10 -1
- data/lib/net/connector/vendor/radware/topology.rb +2 -0
- data/lib/net/connector/version.rb +1 -1
- metadata +71 -52
- data/examples/inventory_sql.yml +0 -20
- data/lib/net/connector/templates/h3c/display_lldp_neighbor_information_list_name_first.textfsm +0 -8
|
@@ -11,8 +11,8 @@ module Net
|
|
|
11
11
|
module Capability
|
|
12
12
|
# 要求设备直接向 TFTP 服务器导出原生配置。
|
|
13
13
|
# 完成仅表示设备报告传输成功,未读取服务器端文件。
|
|
14
|
-
def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
|
|
15
|
-
Tftp.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
|
|
14
|
+
def tftp_backup(host:, path: nil, source_file: nil, vrf: nil, source_interface: nil)
|
|
15
|
+
Tftp.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf, source_interface: source_interface)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ module Net
|
|
|
20
20
|
def initialize(device) = @device = device
|
|
21
21
|
|
|
22
22
|
# 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
|
|
23
|
-
def call(host:, path: nil, source_file: nil, vrf: nil)
|
|
23
|
+
def call(host:, path: nil, source_file: nil, vrf: nil, source_interface: nil)
|
|
24
24
|
receipt = nil
|
|
25
25
|
strategy_class = @device.profile.tftp_strategy
|
|
26
26
|
unless strategy_class
|
|
@@ -34,12 +34,21 @@ module Net
|
|
|
34
34
|
source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
|
|
35
35
|
vrf = vrf&.dup&.freeze
|
|
36
36
|
target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
|
|
37
|
+
source_interface = source_interface.dup.freeze if source_interface.is_a?(String)
|
|
38
|
+
unless source_interface.nil?
|
|
39
|
+
unless strategy.respond_to?(:validate_source_interface!)
|
|
40
|
+
raise ArgumentError, "source_interface is not supported by this TFTP strategy"
|
|
41
|
+
end
|
|
42
|
+
strategy.validate_source_interface!(target, source_interface: source_interface)
|
|
43
|
+
end
|
|
37
44
|
strategy.validate_options!(target, source_file: source_file, vrf: vrf)
|
|
38
45
|
@device.with_operation(:tftp_backup) do
|
|
39
46
|
source = strategy.resolve_source_file(source_file)
|
|
40
47
|
source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
|
|
41
48
|
target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
|
|
42
|
-
|
|
49
|
+
transfer_options = { source_file: source, vrf: vrf }
|
|
50
|
+
transfer_options[:source_interface] = source_interface unless source_interface.nil?
|
|
51
|
+
result = @device.execute_operation(strategy.script(target, **transfer_options),
|
|
43
52
|
name: :tftp_backup, privilege: false)
|
|
44
53
|
confirm_transfer!(strategy, target, result)
|
|
45
54
|
# 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
|
|
@@ -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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "neighbor"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Topology
|
|
8
|
+
# 变更的描述和接口归本对象所有;邻居值对象可安全共享。
|
|
9
|
+
Change = Data.define(:interface, :old_description, :new_description, :neighbor) do
|
|
10
|
+
def initialize(interface:, old_description:, new_description:, neighbor:)
|
|
11
|
+
fields = { interface: interface, old_description: old_description, new_description: new_description }
|
|
12
|
+
raise ArgumentError, "description change fields must be Strings" unless fields.values.all?(String)
|
|
13
|
+
raise ArgumentError, "description change requires a Neighbor" unless neighbor.instance_of?(Neighbor)
|
|
14
|
+
|
|
15
|
+
super(**fields.transform_values { |value| value.frozen? ? value : value.dup.freeze }, neighbor: neighbor)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "strategy"
|
|
4
|
+
require_relative "interface_blocks"
|
|
4
5
|
require_relative "../../engine/terminal/renderer"
|
|
5
6
|
|
|
6
7
|
module Net
|
|
@@ -8,18 +9,11 @@ module Net
|
|
|
8
9
|
class Topology
|
|
9
10
|
# 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
|
|
10
11
|
class ImmediateStrategy < Strategy
|
|
12
|
+
include InterfaceBlocks
|
|
13
|
+
|
|
11
14
|
def verification_commands = @device.config_commands
|
|
12
15
|
def persistence_commands = @device.profile.save_commands
|
|
13
16
|
|
|
14
|
-
# TextFSM 不能静默跳过未知接口头;否则目标行正确也不足以证明读回证据完整。
|
|
15
|
-
def validate_descriptions!(config, rows)
|
|
16
|
-
expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
|
|
17
|
-
return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
|
|
18
|
-
|
|
19
|
-
raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
|
|
20
|
-
host: @device.host, phase: :verify)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
17
|
# 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
|
|
24
18
|
def persistence_confirmed?(result)
|
|
25
19
|
!result.steps.empty? && result.steps.all? do |step|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
class Topology
|
|
6
|
+
# interface 块方言共用完整性校验;解析器不能静默遗漏未知接口头。
|
|
7
|
+
module InterfaceBlocks
|
|
8
|
+
def validate_descriptions!(config, rows)
|
|
9
|
+
expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
|
|
10
|
+
return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
|
|
11
|
+
|
|
12
|
+
raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
|
|
13
|
+
host: @device.host, phase: :verify)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
class Topology
|
|
6
|
+
# 邻居身份拥有独立字符串,格式化回调不能改写发现证据。
|
|
7
|
+
Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol) do
|
|
8
|
+
def initialize(local_interface:, neighbor_name:, neighbor_interface:, chassis_id:, protocol:)
|
|
9
|
+
fields = { local_interface: local_interface, neighbor_name: neighbor_name,
|
|
10
|
+
neighbor_interface: neighbor_interface, chassis_id: chassis_id }
|
|
11
|
+
raise ArgumentError, "neighbor fields must be Strings" unless fields.values.all?(String)
|
|
12
|
+
raise ArgumentError, "neighbor protocol must be a Symbol" unless protocol.is_a?(Symbol)
|
|
13
|
+
|
|
14
|
+
super(**fields.transform_values { |value| value.frozen? ? value : value.dup.freeze }, protocol: protocol)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Ruby 3.2 的 Data#with 不调用初始化,更新也必须重新建立快照。
|
|
18
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "change"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Topology
|
|
8
|
+
# 审批树在领域边界固定;单设备与批量操作共享同一不可变计划。
|
|
9
|
+
Plan = Data.define(:host, :vendor, :evidence, :changes, :commands, :scope) do
|
|
10
|
+
def initialize(host:, vendor:, evidence:, changes:, commands:, scope: :device)
|
|
11
|
+
unless host.is_a?(String) && vendor.is_a?(Symbol) && evidence.is_a?(Hash) &&
|
|
12
|
+
changes.is_a?(Array) && changes.all? { |change| change.instance_of?(Change) } &&
|
|
13
|
+
commands.is_a?(Array) && commands.all?(String) && %i[device interfaces].include?(scope)
|
|
14
|
+
raise ArgumentError, "description plan contains invalid fields"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
super(host: host.frozen? ? host : host.dup.freeze, vendor: vendor,
|
|
18
|
+
evidence: copy_evidence(evidence), changes: changes.dup.freeze,
|
|
19
|
+
commands: commands.map { |command| command.frozen? ? command : command.dup.freeze }.freeze, scope: scope)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def copy_evidence(evidence)
|
|
27
|
+
evidence.to_h do |interface, fields|
|
|
28
|
+
unless interface.is_a?(String) && fields.is_a?(Array) && fields.size == 3 && fields.all?(String)
|
|
29
|
+
raise ArgumentError, "description plan contains invalid evidence"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
[interface.frozen? ? interface : interface.dup.freeze,
|
|
33
|
+
fields.map { |field| field.frozen? ? field : field.dup.freeze }.freeze]
|
|
34
|
+
end.freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "../../engine/errors"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class Topology
|
|
9
|
+
# 配置字段的引号解码是纯文本边界,不执行 shell,也不把原文写入错误。
|
|
10
|
+
module QuotedText
|
|
11
|
+
def self.decode(value, host:, split_unquoted: false)
|
|
12
|
+
value = value.strip
|
|
13
|
+
return value unless split_unquoted || value.start_with?('"')
|
|
14
|
+
|
|
15
|
+
Shellwords.split(value).join(" ")
|
|
16
|
+
rescue ArgumentError
|
|
17
|
+
raise ParsingError.new("interface description is not a complete single-line value",
|
|
18
|
+
code: :unrecognized_output, host: host, phase: :parse), cause: nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
require_relative "interface_description"
|
|
5
|
+
require_relative "quoted_text"
|
|
5
6
|
|
|
6
7
|
module Net
|
|
7
8
|
module Connector
|
|
@@ -56,6 +57,9 @@ module Net
|
|
|
56
57
|
# 在下发前允许厂商调整命令对象及超时。
|
|
57
58
|
def script_command(command) = command
|
|
58
59
|
|
|
60
|
+
# 描述记录默认表示物理接口;厂商可保留逻辑单元的独立身份。
|
|
61
|
+
def description_key(row) = interface_key(row.fetch("INTERFACE"))
|
|
62
|
+
|
|
59
63
|
# 将发现接口名归一为查找当前配置所用的键。
|
|
60
64
|
def interface_key(name) = InterfaceName.key(name)
|
|
61
65
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "topology/strategy"
|
|
4
4
|
require_relative "topology/stages"
|
|
5
|
+
require_relative "topology/plan"
|
|
6
|
+
require_relative "topology/audit"
|
|
5
7
|
|
|
6
8
|
module Net
|
|
7
9
|
module Connector
|
|
@@ -25,16 +27,17 @@ module Net
|
|
|
25
27
|
Topology.new(self).plan_interface_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
|
|
26
28
|
end
|
|
27
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
|
+
|
|
28
35
|
# 明确确认且现场证据未变化时执行接口描述计划。
|
|
29
36
|
def apply_interface_descriptions(plan, confirmed: false)
|
|
30
37
|
Topology.new(self).apply_interface_descriptions(plan, confirmed: confirmed)
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
|
|
34
|
-
Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol)
|
|
35
|
-
Change = Data.define(:interface, :old_description, :new_description, :neighbor)
|
|
36
|
-
Plan = Data.define(:host, :vendor, :evidence, :changes, :commands)
|
|
37
|
-
|
|
38
41
|
# 仅承载厂商档案的离线上下文,不能连接、查询或下发设备命令。
|
|
39
42
|
PlanningContext = Data.define(:host, :vendor, :profile) do
|
|
40
43
|
def config_commands = profile.config_commands
|
|
@@ -129,7 +132,7 @@ module Net
|
|
|
129
132
|
rows = @parser.call(config, template: template, host: @device.host)
|
|
130
133
|
@strategy.validate_descriptions!(config, rows)
|
|
131
134
|
rows.each_with_object({}) do |row, found|
|
|
132
|
-
interface = @strategy.
|
|
135
|
+
interface = @strategy.description_key(row)
|
|
133
136
|
description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
|
|
134
137
|
previous = found[interface]
|
|
135
138
|
if previous && !previous.empty? && !description.empty? && previous != description
|
|
@@ -154,12 +157,22 @@ module Net
|
|
|
154
157
|
end
|
|
155
158
|
end
|
|
156
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
|
+
|
|
157
171
|
# 比对邻居和现有描述,生成包含确切命令的只读变更计划。
|
|
158
172
|
def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
|
|
159
173
|
validate_change_support!(:plan) if @strategy.supports?(:neighbors)
|
|
160
174
|
discovered = neighbors
|
|
161
175
|
current = interface_descriptions
|
|
162
|
-
current.each_value(&:freeze)
|
|
163
176
|
evidence = evidence_for(discovered, current)
|
|
164
177
|
changes = discovered.filter_map do |neighbor|
|
|
165
178
|
old = current.fetch(@strategy.interface_key(neighbor.local_interface))
|
|
@@ -171,17 +184,16 @@ module Net
|
|
|
171
184
|
InterfaceDescription.validate!(proposed)
|
|
172
185
|
next if old == proposed
|
|
173
186
|
|
|
174
|
-
Change.new(interface: @strategy.configuration_interface(neighbor.local_interface)
|
|
175
|
-
new_description: proposed
|
|
187
|
+
Change.new(interface: @strategy.configuration_interface(neighbor.local_interface), old_description: old,
|
|
188
|
+
new_description: proposed, neighbor: neighbor)
|
|
176
189
|
end.freeze
|
|
177
190
|
commands = if changes.empty?
|
|
178
191
|
[]
|
|
179
192
|
else
|
|
180
193
|
stage_scripts(changes).values.flat_map { |script| script.map(&:text) }
|
|
181
194
|
end
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
changes: changes, commands: commands.freeze).freeze
|
|
195
|
+
Plan.new(host: @device.host, vendor: @device.vendor, evidence: evidence,
|
|
196
|
+
changes: changes, commands: commands)
|
|
185
197
|
end
|
|
186
198
|
|
|
187
199
|
# 只有明确确认且邻居和旧描述未变化时,才下发计划中的命令。
|
|
@@ -251,7 +263,12 @@ module Net
|
|
|
251
263
|
# 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
|
|
252
264
|
def revalidate_plan!(plan)
|
|
253
265
|
discovered = neighbors
|
|
254
|
-
|
|
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)
|
|
255
272
|
current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
|
|
256
273
|
same_neighbors = plan.changes.all? do |change|
|
|
257
274
|
current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
|
|
@@ -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
|
|
@@ -15,9 +15,10 @@ module Net
|
|
|
15
15
|
unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
|
|
16
16
|
raise ArgumentError, "duration_ms must be nonnegative and finite"
|
|
17
17
|
end
|
|
18
|
+
raise ArgumentError, "status must be a Symbol or String" unless status.is_a?(Symbol) || status.is_a?(String)
|
|
18
19
|
raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
|
|
19
20
|
|
|
20
|
-
super(device: device, status: status, backup: backup, error_code: error_code,
|
|
21
|
+
super(device: device, status: status.is_a?(String) ? status.dup.freeze : status, backup: backup, error_code: error_code,
|
|
21
22
|
error_type: error_type.is_a?(String) ? error_type.dup.freeze : error_type,
|
|
22
23
|
started_at: started_at&.dup&.freeze, finished_at: finished_at&.dup&.freeze,
|
|
23
24
|
duration_ms: duration_ms, diagnostic: diagnostic)
|
|
@@ -46,17 +47,45 @@ module Net
|
|
|
46
47
|
# 汇总一次批量任务的设备结果、回调故障和报告写入状态。
|
|
47
48
|
Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
|
|
48
49
|
:report_location, :report_error) do
|
|
49
|
-
#
|
|
50
|
-
def
|
|
50
|
+
# 批次拥有容器与审计元数据;调用方只能通过 with 构造新快照。
|
|
51
|
+
def initialize(mode:, outcomes:, started_at:, finished_at:, callback_errors:, report_location:, report_error:)
|
|
52
|
+
unless outcomes.is_a?(Array) && outcomes.all? { |outcome| outcome.instance_of?(Outcome) } && callback_errors.is_a?(Array)
|
|
53
|
+
raise ArgumentError, "batch requires outcome and callback error Arrays"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
outcomes = outcomes.dup.freeze
|
|
57
|
+
@counts = outcomes.each_with_object({}) { |outcome, found| found[outcome.status] = found.fetch(outcome.status, 0) + 1 }.freeze
|
|
58
|
+
super(mode: mode, outcomes: outcomes,
|
|
59
|
+
started_at: started_at&.dup&.freeze, finished_at: finished_at&.dup&.freeze,
|
|
60
|
+
callback_errors: callback_errors.map { |entry| copy_callback_error(entry) }.freeze,
|
|
61
|
+
report_location: report_location&.dup&.freeze, report_error: report_error&.dup&.freeze)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
65
|
+
|
|
66
|
+
# 只累计最终计数,不创建与清单等长的中间状态数组。
|
|
67
|
+
def counts = @counts.dup
|
|
51
68
|
|
|
52
69
|
# 仅在所有设备成功且回调、报告均正常时判定整批成功。
|
|
53
|
-
def success? = !outcomes.empty? &&
|
|
70
|
+
def success? = !outcomes.empty? && @counts.fetch(:backed_up, 0) + @counts.fetch(:reported_uploaded, 0) == outcomes.size &&
|
|
71
|
+
callback_errors.empty? && report_error.nil?
|
|
54
72
|
|
|
55
73
|
# 空清单与已尝试但未全部成功的批次分别标记。
|
|
56
74
|
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
57
75
|
|
|
58
76
|
# 显式从原始执行结果构造报告。
|
|
59
77
|
def build_report(policy: :strict) = Report.new(self, policy: policy)
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def copy_callback_error(entry)
|
|
82
|
+
unless entry.is_a?(Hash) && (entry[:host].nil? || entry[:host].is_a?(String))
|
|
83
|
+
raise ArgumentError, "callback errors must contain a host and error type"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
{ host: entry[:host]&.dup&.freeze, error_type: Error::Metadata.type(entry[:error_type]) }.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
60
89
|
end
|
|
61
90
|
|
|
62
91
|
autoload :Report, File.expand_path("report", __dir__)
|