net-connector 0.4.0 → 0.4.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -68
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +140 -95
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -5
  7. data/docs/architecture.md +321 -248
  8. data/lib/net/connector/device/base.rb +12 -4
  9. data/lib/net/connector/device/interface_description.rb +3 -0
  10. data/lib/net/connector/device/profile/builder.rb +236 -0
  11. data/lib/net/connector/device/profile.rb +2 -227
  12. data/lib/net/connector/device/running_config/strategy.rb +5 -0
  13. data/lib/net/connector/device/running_config.rb +7 -0
  14. data/lib/net/connector/engine/command.rb +14 -2
  15. data/lib/net/connector/engine/configuration.rb +10 -7
  16. data/lib/net/connector/engine/dialogue.rb +53 -28
  17. data/lib/net/connector/engine/errors.rb +41 -54
  18. data/lib/net/connector/engine/execution.rb +32 -5
  19. data/lib/net/connector/engine/log.rb +23 -17
  20. data/lib/net/connector/engine/session.rb +39 -7
  21. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  22. data/lib/net/connector/netdisco/batch.rb +31 -3
  23. data/lib/net/connector/netdisco/cli.rb +92 -50
  24. data/lib/net/connector/netdisco/client.rb +206 -73
  25. data/lib/net/connector/netdisco/config_file.rb +26 -4
  26. data/lib/net/connector/netdisco/device.rb +4 -8
  27. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  28. data/lib/net/connector/netdisco/fleet.rb +105 -108
  29. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  30. data/lib/net/connector/netdisco/plan.rb +77 -0
  31. data/lib/net/connector/netdisco/planner.rb +12 -23
  32. data/lib/net/connector/netdisco/report.rb +94 -0
  33. data/lib/net/connector/netdisco/rules.rb +28 -5
  34. data/lib/net/connector/netdisco/settings.rb +194 -85
  35. data/lib/net/connector/netdisco/worker.rb +43 -27
  36. data/lib/net/connector/operations/backup_lock.rb +116 -0
  37. data/lib/net/connector/operations/local_backup.rb +49 -9
  38. data/lib/net/connector/operations/parse_output.rb +20 -3
  39. data/lib/net/connector/operations/private_file.rb +94 -5
  40. data/lib/net/connector/operations/safe_file.rb +62 -0
  41. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  42. data/lib/net/connector/operations/saved_config.rb +40 -10
  43. data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
  44. data/lib/net/connector/operations/tftp/strategy.rb +9 -11
  45. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  46. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  47. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  48. data/lib/net/connector/operations/topology/strategy.rb +34 -0
  49. data/lib/net/connector/operations/topology.rb +98 -29
  50. data/lib/net/connector/operations.rb +6 -0
  51. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  52. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  53. data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
  54. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
  55. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  56. data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
  57. data/lib/net/connector/vendor/h3c/topology.rb +17 -3
  58. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  59. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
  60. data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
  61. data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
  62. data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
  63. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
  64. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
  65. data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
  66. data/lib/net/connector/vendor/radware/topology.rb +3 -0
  67. data/lib/net/connector/version.rb +1 -1
  68. metadata +60 -28
@@ -3,7 +3,8 @@
3
3
  require "fileutils"
4
4
  require "ipaddr"
5
5
  require_relative "private_file"
6
- require_relative "parse_output"
6
+ require_relative "safe_file"
7
+ require_relative "saved_config/legacy_index"
7
8
 
8
9
  module Net
9
10
  module Connector
@@ -19,34 +20,61 @@ module Net
19
20
  raise ArgumentError, "host must be an IP address"
20
21
  end
21
22
 
22
- # 保存已有配置文件所在目录。
23
- def initialize(directory:)
23
+ # 默认每次实时查找;批次显式 indexed 时共享一份惰性的旧命名文件快照。
24
+ def initialize(directory:, indexed: false)
24
25
  raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
26
+ raise ArgumentError, "indexed must be true or false" unless [true, false].include?(indexed)
25
27
 
26
- @directory = File.expand_path(directory)
28
+ @directory = File.expand_path(directory).freeze
29
+ @legacy_index = LegacyIndex.new(@directory) if indexed
27
30
  end
28
31
 
29
32
  # 优先查找固定文件名;仅在不存在时兼容唯一的旧命名文件。
30
33
  def find(host, required: true)
34
+ with_file(host, required: required) { |path, _file, _stat| path }
35
+ end
36
+
37
+ # 比较基线也从经过 fstat 的同一 FD 读取,不在 find 后重新按路径打开。
38
+ def fingerprint(host, required: true)
39
+ with_file(host, required: required) { |path, file, stat| SafeFile.fingerprint_io(path, file, stat) }
40
+ end
41
+
42
+ def read(host)
43
+ with_file(host) { |_path, file, _stat| file.read }
44
+ end
45
+
46
+ def resolve_path(host, required:)
31
47
  filename = self.class.filename(host)
32
48
  path = File.join(@directory, filename)
33
49
  unless File.exist?(path) || File.symlink?(path)
34
- entries = Dir.children(@directory).select { |name| name.end_with?("-#{filename}") }
50
+ entries = if @legacy_index
51
+ @legacy_index.candidates(filename)
52
+ else
53
+ Dir.children(@directory).select { |name| name.end_with?("-#{filename}") }
54
+ end
35
55
  return nil if entries.empty? && !required
36
56
 
37
57
  raise ArgumentError, "no saved configuration for #{host}" if entries.empty?
38
58
  raise ArgumentError, "multiple saved configurations for #{host}" if entries.size > 1
39
59
 
40
- path = File.join(@directory, entries.first)
60
+ path = @legacy_index ? entries.first : File.join(@directory, entries.first)
41
61
  end
42
- raise ArgumentError, "saved configuration is not a regular file" unless File.file?(path) && !File.symlink?(path)
43
-
44
62
  path
45
63
  end
46
64
 
65
+ def with_file(host, required: true)
66
+ path = resolve_path(host, required: required)
67
+ return nil unless path
68
+ return @legacy_index.open(path) { |name, file, stat| yield name, file, stat } if path.is_a?(LegacyIndex::Entry)
69
+
70
+ SafeFile.open(path) { |file, stat| yield path, file, stat }
71
+ end
72
+
73
+ private :resolve_path, :with_file
74
+
47
75
  # 将已保存配置写入目标文件或标准输出。
48
76
  def export(host:, output: nil, io: $stdout)
49
- contents = File.binread(find(host))
77
+ contents = read(host)
50
78
  if output
51
79
  raise ArgumentError, "output must be a nonempty String" unless output.is_a?(String) && !output.empty?
52
80
 
@@ -63,8 +91,10 @@ module Net
63
91
 
64
92
  # 使用指定模板解析已有配置,无需访问设备或 Netdisco。
65
93
  def parse(host:, template:, template_dir: nil)
94
+ require_relative "parse_output"
95
+
66
96
  parser = ParseOutput.new(template_dir: template_dir)
67
- parser.call(File.binread(find(host)), template: template, host: host)
97
+ parser.call(read(host), template: template, host: host)
68
98
  end
69
99
  end
70
100
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ module Tftp
9
+ # H3C 和华为共用已保存文件上传流程,源文件发现仍由厂商策略负责。
10
+ class FileUpload < Strategy
11
+ # 未指定目标名时,设备使用配置源文件的 basename。
12
+ def default_path(source_file) = File.basename(source_file)
13
+
14
+ # H3C 探测源文件之前先拒绝不支持的参数;探测本身不能放进纯校验钩子。
15
+ def validate_options!(_target, source_file:, vrf: nil)
16
+ raise ArgumentError, "TFTP file upload does not use vrf" unless vrf.nil?
17
+
18
+ TftpTarget.validate_source_file!(source_file) unless source_file.nil?
19
+ end
20
+
21
+ # 指定文件的内容与格式未知,不能仅凭 .cfg 后缀声称是当前运行配置。
22
+ def receipt_metadata(_target, source_file:, **)
23
+ { configuration_kind: :saved_file, source_file: source_file, format: :unknown }
24
+ end
25
+
26
+ # 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
27
+ def script(target, source_file:, vrf: nil)
28
+ validate_options!(target, source_file: source_file, vrf: vrf)
29
+ TftpTarget.validate_source_file!(source_file)
30
+
31
+ destination = target.explicit_path? ? " #{target.path}" : ""
32
+ Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
33
+ end
34
+
35
+ # 只接受明确的成功消息、非零传输字节数或完整进度行。
36
+ def complete?(result)
37
+ completion_lines(result).any? { |line| completed_upload?(line) }
38
+ end
39
+
40
+ private
41
+
42
+ # 两个厂商的上传完成消息使用相同的文本及进度格式。
43
+ def completed_upload?(line)
44
+ line.match?(/\A
45
+ (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
46
+ (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
47
+ \z/ix) ||
48
+ line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
49
+ completed_progress?(line)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -13,6 +13,14 @@ module Net
13
13
  autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
14
14
 
15
15
  class Strategy
16
+ # 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
17
+ def self.file_extension = "cfg"
18
+
19
+ # 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
20
+ def self.filename(host, label: nil)
21
+ TftpTarget.filename(host, extension: file_extension, label: label)
22
+ end
23
+
16
24
  # 保存 TFTP 厂商策略使用的设备对象。
17
25
  def initialize(device)
18
26
  @device = device
@@ -23,7 +31,7 @@ module Net
23
31
  def source_file(value) = value
24
32
 
25
33
  # 根据设备地址生成默认远端文件名。
26
- def default_path(_source_file) = TftpTarget.filename(@device.host, extension: "cfg")
34
+ def default_path(_source_file) = self.class.filename(@device.host)
27
35
 
28
36
  # 返回目标中指定的远端文件名。
29
37
  def remote_path(target, _result) = target.path
@@ -53,16 +61,6 @@ module Net
53
61
  line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
54
62
  end
55
63
 
56
- # H3C 和华为的上传完成消息使用相同的文本及进度格式。
57
- def completed_upload?(line)
58
- line.match?(/\A
59
- (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
60
- (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
61
- \z/ix) ||
62
- line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
63
- completed_progress?(line)
64
- end
65
-
66
64
  # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
67
65
  def copied_bytes?(line)
68
66
  line.match?(/\A
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "tftp/strategy"
5
+ require_relative "tftp_receipt"
5
6
 
6
7
  module Net
7
8
  module Connector
@@ -38,6 +39,10 @@ module Net
38
39
 
39
40
  # 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
40
41
  def self.filename(host, extension:, label: nil)
42
+ if label && (!label.is_a?(String) || !label.ascii_only?)
43
+ raise ArgumentError, "TFTP label must be an ASCII String"
44
+ end
45
+
41
46
  address = host.gsub(/[^A-Za-z0-9._-]/, "_")
42
47
  suffix = "#{address}.#{extension}"
43
48
  if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
@@ -64,42 +69,100 @@ module Net
64
69
  # 保存执行 TFTP 导出的设备对象。
65
70
  def initialize(device) = @device = device
66
71
 
67
- # 执行设备原生 TFTP 导出并核对完成回显。
72
+ # 保留原有返回结构;扩展元数据由 call_receipt 单独提供。
68
73
  def call(host:, path: nil, source_file: nil, vrf: nil)
74
+ call_receipt(host: host, path: path, source_file: source_file, vrf: vrf).transfer
75
+ end
76
+
77
+ # 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
78
+ def call_receipt(host:, path: nil, source_file: nil, vrf: nil)
79
+ receipt = nil
69
80
  strategy_class = @device.profile.tftp_strategy
70
81
  unless strategy_class
71
82
  raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
72
83
  host: @device.host, phase: :tftp_backup)
73
84
  end
74
85
  strategy = strategy_class.new(@device)
75
- if vrf && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
86
+ if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
76
87
  raise ArgumentError, "vrf must be a single safe name"
77
88
  end
78
- # H3C 在解析源文件时可能连接设备,因此先验证调用方提供的目标。
79
- target = TftpTarget.new(host: host, path: path || "preflight.cfg", explicit_path: !path.nil?)
80
- source_file = strategy.source_file(source_file)
81
- target = TftpTarget.new(host: host, path: strategy.default_path(source_file), explicit_path: false) unless path
82
- result = @device.execute_operation(strategy.script(target, source_file: source_file, vrf: vrf),
83
- name: :tftp_backup, privilege: false)
84
- result.value!
89
+ source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
90
+ vrf = vrf&.dup&.freeze
91
+ target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
92
+ if strategy_hook?(strategy, :validate_options!)
93
+ strategy.validate_options!(target, source_file: source_file, vrf: vrf)
94
+ end
95
+ @device.with_operation(:tftp_backup) do
96
+ source = strategy.source_file(source_file)
97
+ source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
98
+ target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
99
+ result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
100
+ name: :tftp_backup, privilege: false)
101
+ confirm_transfer!(strategy, target, result)
102
+ # 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
103
+ transfer = Net::Connector::TftpBackup.new(server: target.host, path: nil, completed_at: Time.now.utc)
104
+ requested_path = target.explicit_path? ? target.path : nil
105
+ receipt = TftpReceipt.new(transfer: transfer, actual_path: nil, requested_path: requested_path, source_file: source)
106
+ actual_path = remote_path(strategy, target, result, receipt)
107
+ receipt = TftpReceipt.new(transfer: transfer.with(path: actual_path), actual_path: actual_path,
108
+ requested_path: requested_path, source_file: source)
109
+ receipt = receipt_metadata(strategy, target, source, !source_file.nil?, receipt)
110
+ if target.explicit_path? && target.path != receipt.actual_path
111
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_mismatch, host: @device.host), cause: nil
112
+ end
113
+ # 已完成步骤仍可能伴随后处理/清理错误;上面的回执要先构造,再重新抛出。
114
+ result.value! if result.failure?
115
+ @device.record_event("tftp_backup", status: "reported_uploaded", server: target.host, path: receipt.actual_path)
116
+ receipt
117
+ end
118
+ rescue StandardError => error
119
+ raise unless receipt
120
+ raise if error.instance_of?(TftpCompletionError) && error.receipt.equal?(receipt)
121
+
122
+ raise TftpCompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
123
+ end
124
+
125
+ private
126
+
127
+ # 旧扩展若重写 script,不继承父厂商新增的参数限制或配置来源声明。
128
+ # 同时重写相应钩子才表示扩展已为自己的脚本实现这项契约。
129
+ def strategy_hook?(strategy, name)
130
+ strategy.respond_to?(name) && !!(strategy.method(name).owner <= strategy.method(:script).owner)
131
+ end
132
+
133
+ def receipt_metadata(strategy, target, source_file, explicit_source, receipt)
134
+ return receipt unless strategy_hook?(strategy, :receipt_metadata)
135
+
136
+ metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
137
+ unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
138
+ raise ArgumentError, "invalid TFTP strategy receipt metadata"
139
+ end
140
+ TftpReceipt.new(transfer: receipt.transfer, actual_path: receipt.actual_path,
141
+ **{ source_file: source_file, requested_path: target.path }.merge(metadata))
142
+ end
143
+
144
+ def remote_path(strategy, target, result, receipt)
145
+ TftpTarget.validate_path!(strategy.remote_path(target, result))
146
+ rescue StandardError => error
147
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_unconfirmed,
148
+ host: @device.host, underlying: error), cause: nil
149
+ end
150
+
151
+ # 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
152
+ def confirm_transfer!(strategy, target, result)
85
153
  if transfer_failed?(result)
86
154
  @device.record_event("tftp_backup", level: :error, status: "transfer_failed", server: target.host)
87
155
  raise DeviceError.new("device reported TFTP backup failure",
88
156
  code: :transfer_failed, host: @device.host, phase: :tftp_backup)
89
157
  end
90
158
  unless strategy.complete?(result)
159
+ result.value! if result.failure?
91
160
  @device.record_event("tftp_backup", level: :error, status: "transfer_unconfirmed", server: target.host)
92
161
  raise DeviceError.new("device did not confirm TFTP backup completion",
93
162
  code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
94
163
  end
95
-
96
- remote_path = strategy.remote_path(target, result)
97
- @device.record_event("tftp_backup", status: "reported_uploaded", server: target.host, path: remote_path)
98
- Net::Connector::TftpBackup.new(server: target.host, path: remote_path, completed_at: Time.now.utc)
99
164
  end
100
165
 
101
- private
102
-
103
166
  # 识别设备回显中的传输失败信息。
104
167
  def transfer_failed?(result)
105
168
  result.steps.any? do |step|
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../engine/errors"
4
+
5
+ module Net
6
+ module Connector
7
+ # 元数据采用组合,不改变 TftpBackup 的位置参数、成员或解构契约。
8
+ class TftpReceipt
9
+ KINDS = %i[running startup saved_file native_archive unknown].freeze
10
+ FORMATS = %i[cfg xml dat tgz unknown].freeze
11
+ private_constant :KINDS, :FORMATS
12
+
13
+ attr_reader :transfer, :configuration_kind, :source_file, :format, :requested_path, :actual_path,
14
+ :verification, :server_sha256
15
+
16
+ def initialize(transfer:, configuration_kind: :unknown, source_file: nil, format: :unknown,
17
+ requested_path: nil, actual_path:, verification: :device_reported, server_sha256: nil)
18
+ unless transfer.instance_of?(TftpBackup) && transfer.completed_at.is_a?(Time) && transfer.path == actual_path
19
+ raise ArgumentError, "TFTP receipt requires a matching transfer"
20
+ end
21
+ TftpTarget.new(host: transfer.server, path: actual_path.nil? ? "unconfirmed" : actual_path)
22
+ TftpTarget.validate_path!(requested_path) unless requested_path.nil?
23
+ TftpTarget.validate_source_file!(source_file) unless source_file.nil?
24
+ unless KINDS.include?(configuration_kind) && FORMATS.include?(format)
25
+ raise ArgumentError, "invalid TFTP configuration kind or format"
26
+ end
27
+ valid_verification = (verification == :device_reported && server_sha256.nil?) ||
28
+ (verification == :server_verified && !actual_path.nil? && server_sha256.is_a?(String) &&
29
+ server_sha256.match?(/\A[0-9a-f]{64}\z/))
30
+ raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
31
+
32
+ @actual_path, @requested_path = actual_path&.dup&.freeze, requested_path&.dup&.freeze
33
+ @source_file, @server_sha256 = source_file&.dup&.freeze, server_sha256&.dup&.freeze
34
+ @configuration_kind, @format, @verification = configuration_kind, format, verification
35
+ @transfer = transfer.with(server: transfer.server.dup.freeze, path: @actual_path,
36
+ completed_at: transfer.completed_at.dup.freeze)
37
+ freeze
38
+ end
39
+
40
+ # 摘要不展开设备输出或调用方的源文件名。
41
+ def inspect = "#<#{self.class} kind=#{configuration_kind} format=#{format} verification=#{verification}>"
42
+ end
43
+
44
+ # 上传已确认后的失败不能被解释为“尚未上传”,也不能携带原始诊断正文。
45
+ class TftpCompletionError < Error
46
+ MESSAGES = {
47
+ transfer_finalize_failed: "device reported TFTP completion but finalization failed",
48
+ transfer_path_unconfirmed: "device reported TFTP completion but its remote path is unconfirmed",
49
+ transfer_path_mismatch: "device reported TFTP completion at a different remote path"
50
+ }.freeze
51
+ private_constant :MESSAGES
52
+
53
+ attr_reader :receipt, :underlying_type
54
+
55
+ def initialize(receipt:, code: :transfer_finalize_failed, host: nil, underlying: nil)
56
+ unless receipt.instance_of?(TftpReceipt) && MESSAGES.key?(code)
57
+ raise ArgumentError, "TFTP completion error requires a transfer receipt and a known failure code"
58
+ end
59
+
60
+ @receipt = receipt
61
+ type = if underlying.is_a?(Error) && underlying.underlying.instance_of?(UnderlyingError)
62
+ underlying.underlying.type
63
+ else
64
+ underlying&.class&.name
65
+ end
66
+ @underlying_type = type&.match?(/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/) && type.bytesize <= 128 ? type.freeze : nil
67
+ super(MESSAGES.fetch(code), code: code, host: host, phase: :tftp_backup)
68
+ end
69
+
70
+ def transfer = receipt.transfer
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+ require_relative "../../engine/terminal_renderer"
5
+
6
+ module Net
7
+ module Connector
8
+ module Operations
9
+ class Topology
10
+ # 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
11
+ class ImmediateStrategy < Strategy
12
+ def verification_commands = @device.config_commands
13
+ def persistence_commands = @device.profile.save_commands
14
+
15
+ # 保留旧扩展查询入口;公共执行流程使用独立阶段,不再执行这份合并列表。
16
+ def finish_commands = leave_configuration + persistence_commands
17
+
18
+ # TextFSM 不能静默跳过未知接口头;否则目标行正确也不足以证明读回证据完整。
19
+ def validate_descriptions!(config, rows)
20
+ expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
21
+ return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
22
+
23
+ raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
24
+ host: @device.host, phase: :verify)
25
+ end
26
+
27
+ # 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
28
+ def persistence_confirmed?(result)
29
+ !result.steps.empty? && result.steps.all? do |step|
30
+ raw = step.output.delete_suffix(step.prompt.to_s)
31
+ rendered = TerminalRenderer.render(raw)
32
+ failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
33
+ next false if raw.match?(failure) || rendered.match?(failure)
34
+
35
+ rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
36
+ end
37
+ end
38
+
39
+ # 没有明确完成证据的策略不能借用一个泛化的成功关键词。
40
+ def persistence_pattern = /\A\b\B\z/
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -15,24 +15,58 @@ module Net
15
15
 
16
16
  # 厂商拓扑规则;默认不提供任何设备能力。
17
17
  class Strategy
18
+ # 保存设备供厂商规则查询档案和会话状态。
18
19
  def initialize(device)
19
20
  @device = device
20
21
  end
21
22
 
23
+ # 声明策略类支持的拓扑能力,默认全部关闭。
22
24
  def self.supports?(_capability) = false
25
+
26
+ # 将实例级能力查询交给策略类声明。
23
27
  def supports?(capability) = self.class.supports?(capability)
24
28
 
29
+ # 返回读取邻居的命令,未定义时不支持发现。
25
30
  def neighbor_command = nil
31
+
32
+ # 根据原始输出选择邻居解析模板。
26
33
  def neighbor_template(_output) = nil
34
+
35
+ # 返回当前接口描述所用的解析模板。
27
36
  def description_template = nil
37
+
38
+ # 返回输出中应解析出的邻居数量,供完整性校验使用。
28
39
  def expected_neighbor_count(_output, _template) = 0
40
+
41
+ # 只有明确的空表证据才允许返回空邻居列表。
29
42
  def empty_neighbor_output?(_output) = false
43
+
44
+ # 返回进入设备配置视图的命令。
30
45
  def enter_configuration = nil
46
+
47
+ # 返回离开配置视图和保存配置所需的命令。
31
48
  def finish_commands = []
49
+
50
+ # 未声明分阶段契约的旧策略仍可读取,但不能推断其退出、验证和保存的安全边界。
51
+ def leave_configuration = nil
52
+ def verification_commands = nil
53
+ def persistence_commands = nil
54
+ def persistence_confirmed?(_result) = false
55
+ def change_error_code = :description_unsupported
56
+
57
+ # 只读策略可以没有完整性扩展;立即生效的接口块模型会进一步校验解析数量。
58
+ def validate_descriptions!(_config, _rows) end
59
+
60
+ # 为一条接口描述变更生成厂商命令。
32
61
  def change_commands(_change) = []
62
+
63
+ # 标明邻居发现使用的协议。
33
64
  def protocol = :lldp
65
+
66
+ # 在下发前允许厂商调整命令对象及超时。
34
67
  def script_command(command) = command
35
68
 
69
+ # 将发现接口名归一为查找当前配置所用的键。
36
70
  def interface_key(name) = InterfaceName.key(name)
37
71
 
38
72
  # 将发现协议常用的接口简称展开为配置视图可识别的名称。