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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +1 -1
  4. data/README.zh-CN.md +1 -1
  5. data/docs/VERIFICATION.md +19 -5
  6. data/docs/architecture.md +18 -5
  7. data/docs/netdisco.md +3 -3
  8. data/docs/netdisco.zh-CN.md +7 -7
  9. data/docs/templates.md +13 -7
  10. data/docs/usage.md +2 -2
  11. data/docs/usage.zh-CN.md +2 -2
  12. data/docs/vendors.md +14 -3
  13. data/examples/backup.yml +47 -8
  14. data/examples/inventory.yml +39 -0
  15. data/lib/net/connector/device/profile.rb +1 -1
  16. data/lib/net/connector/device/remote_backup/receipt.rb +4 -0
  17. data/lib/net/connector/device/remote_backup/target.rb +3 -0
  18. data/lib/net/connector/device/tftp/strategy.rb +7 -2
  19. data/lib/net/connector/device/tftp.rb +13 -4
  20. data/lib/net/connector/device/topology/audit.rb +103 -0
  21. data/lib/net/connector/device/topology/change.rb +22 -0
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +3 -9
  23. data/lib/net/connector/device/topology/interface_blocks.rb +18 -0
  24. data/lib/net/connector/device/topology/neighbor.rb +22 -0
  25. data/lib/net/connector/device/topology/plan.rb +39 -0
  26. data/lib/net/connector/device/topology/quoted_text.rb +23 -0
  27. data/lib/net/connector/device/topology/strategy.rb +4 -0
  28. data/lib/net/connector/device/topology.rb +29 -12
  29. data/lib/net/connector/netdisco/address_filter.rb +86 -0
  30. data/lib/net/connector/netdisco/batch.rb +33 -4
  31. data/lib/net/connector/netdisco/cli/backup.rb +80 -25
  32. data/lib/net/connector/netdisco/cli/help.rb +47 -0
  33. data/lib/net/connector/netdisco/cli/options.rb +111 -18
  34. data/lib/net/connector/netdisco/cli/topology.rb +358 -0
  35. data/lib/net/connector/netdisco/config/initializer.rb +146 -0
  36. data/lib/net/connector/netdisco/{config_file.rb → config.rb} +26 -4
  37. data/lib/net/connector/netdisco/device/policy.rb +77 -0
  38. data/lib/net/connector/netdisco/device.rb +14 -3
  39. data/lib/net/connector/netdisco/fleet/options.rb +93 -0
  40. data/lib/net/connector/netdisco/fleet.rb +114 -59
  41. data/lib/net/connector/netdisco/inventory/cache.rb +42 -4
  42. data/lib/net/connector/netdisco/operations/batch.rb +1 -1
  43. data/lib/net/connector/netdisco/operations/outcome.rb +2 -16
  44. data/lib/net/connector/netdisco/operations.rb +20 -38
  45. data/lib/net/connector/netdisco/plan.rb +39 -16
  46. data/lib/net/connector/netdisco/planner.rb +22 -8
  47. data/lib/net/connector/netdisco/progress.rb +45 -2
  48. data/lib/net/connector/netdisco/report.rb +14 -11
  49. data/lib/net/connector/netdisco/rules.rb +34 -17
  50. data/lib/net/connector/netdisco/settings.rb +88 -23
  51. data/lib/net/connector/netdisco/tftp/policy.rb +152 -0
  52. data/lib/net/connector/netdisco/topology/options.rb +48 -0
  53. data/lib/net/connector/netdisco.rb +1 -0
  54. data/lib/net/connector/templates/cisco/show_running_config.textfsm +1 -0
  55. data/lib/net/connector/templates/cisco_nxos/show_running_config.textfsm +1 -0
  56. data/lib/net/connector/templates/fortinet/show_full_configuration.textfsm +21 -0
  57. data/lib/net/connector/templates/h3c/display_current_configuration.textfsm +1 -0
  58. data/lib/net/connector/templates/h3c/{display_lldp_neighbor_information_list_local_first.textfsm → display_lldp_neighbor_information_list.textfsm} +7 -1
  59. data/lib/net/connector/templates/hillstone/show_configuration_running.textfsm +1 -0
  60. data/lib/net/connector/templates/huawei/display_current_configuration.textfsm +13 -0
  61. data/lib/net/connector/templates/huawei/display_lldp_neighbor_brief.textfsm +9 -0
  62. data/lib/net/connector/templates/index +22 -12
  63. data/lib/net/connector/templates/juniper/show_configuration_display_set.textfsm +4 -0
  64. data/lib/net/connector/templates/palo_alto/show.textfsm +1 -0
  65. data/lib/net/connector/templates/radware/cfg_dump.textfsm +5 -3
  66. data/lib/net/connector/vendor/fortinet/topology.rb +24 -1
  67. data/lib/net/connector/vendor/h3c/tftp.rb +30 -0
  68. data/lib/net/connector/vendor/h3c/topology.rb +4 -5
  69. data/lib/net/connector/vendor/huawei/topology.rb +48 -0
  70. data/lib/net/connector/vendor/huawei.rb +2 -0
  71. data/lib/net/connector/vendor/juniper/topology.rb +7 -6
  72. data/lib/net/connector/vendor/palo_alto/topology.rb +1 -7
  73. data/lib/net/connector/vendor/radware/tftp.rb +10 -1
  74. data/lib/net/connector/vendor/radware/topology.rb +2 -0
  75. data/lib/net/connector/version.rb +1 -1
  76. metadata +71 -52
  77. data/examples/inventory_sql.yml +0 -20
  78. 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
- result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
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.interface_key(row.fetch("INTERFACE"))
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).freeze, old_description: old.freeze,
175
- new_description: proposed.freeze, neighbor: neighbor).freeze
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
- commands.each(&:freeze)
183
- Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
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
- 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)
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 counts = outcomes.map(&:status).tally
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? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
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__)