net-connector 0.4.2 → 0.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +42 -19
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +96 -64
  6. data/lib/net/connector/device/base.rb +27 -107
  7. data/lib/net/connector/device/local_backup.rb +96 -0
  8. data/lib/net/connector/device/profile.rb +8 -3
  9. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  10. data/lib/net/connector/device/running_config.rb +44 -5
  11. data/lib/net/connector/device/save_config.rb +21 -0
  12. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  13. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  14. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  15. data/lib/net/connector/device/tftp/target.rb +63 -0
  16. data/lib/net/connector/device/tftp.rb +117 -0
  17. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  18. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  19. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  20. data/lib/net/connector/device/topology/strategy.rb +75 -0
  21. data/lib/net/connector/device/topology.rb +277 -0
  22. data/lib/net/connector/engine/authentication.rb +1 -1
  23. data/lib/net/connector/engine/configuration.rb +1 -1
  24. data/lib/net/connector/engine/dialogue.rb +17 -17
  25. data/lib/net/connector/engine/error_metadata.rb +50 -0
  26. data/lib/net/connector/engine/errors.rb +15 -1
  27. data/lib/net/connector/engine/execution.rb +15 -8
  28. data/lib/net/connector/engine/log/event.rb +66 -0
  29. data/lib/net/connector/engine/log/formatter.rb +16 -0
  30. data/lib/net/connector/engine/log/messages.rb +52 -0
  31. data/lib/net/connector/engine/log/stream.rb +56 -0
  32. data/lib/net/connector/engine/log.rb +105 -84
  33. data/lib/net/connector/engine/session.rb +83 -42
  34. data/lib/net/connector/engine/terminal_text.rb +33 -0
  35. data/lib/net/connector/engine/transport.rb +2 -2
  36. data/lib/net/connector/netdisco/batch.rb +9 -45
  37. data/lib/net/connector/netdisco/cli.rb +7 -9
  38. data/lib/net/connector/netdisco/client.rb +2 -2
  39. data/lib/net/connector/netdisco/device.rb +4 -5
  40. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  41. data/lib/net/connector/netdisco/fleet.rb +26 -39
  42. data/lib/net/connector/netdisco/report.rb +37 -16
  43. data/lib/net/connector/netdisco/result_store.rb +2 -2
  44. data/lib/net/connector/netdisco/settings.rb +0 -6
  45. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  46. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  47. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  48. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  49. data/lib/net/connector/storage.rb +15 -0
  50. data/lib/net/connector/textfsm.rb +72 -0
  51. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  53. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  55. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  56. data/lib/net/connector/vendor/h3c.rb +3 -3
  57. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  59. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  60. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  61. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  63. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  65. data/lib/net/connector/version.rb +1 -1
  66. data/lib/net/connector.rb +1 -6
  67. metadata +25 -39
  68. data/lib/net/connector/device/interface_description.rb +0 -44
  69. data/lib/net/connector/device/interface_name.rb +0 -56
  70. data/lib/net/connector/engine/base.rb +0 -4
  71. data/lib/net/connector/engine/log_messages.rb +0 -47
  72. data/lib/net/connector/engine/profile.rb +0 -4
  73. data/lib/net/connector/engine.rb +0 -4
  74. data/lib/net/connector/operations/local_backup.rb +0 -89
  75. data/lib/net/connector/operations/parse_output.rb +0 -77
  76. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  77. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  78. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  79. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  80. data/lib/net/connector/operations/running_config.rb +0 -16
  81. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  82. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  83. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  84. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  85. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  86. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  87. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  88. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  89. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  90. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  91. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  92. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  93. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  94. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  96. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/topology/radware.rb +0 -11
  98. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  99. data/lib/net/connector/operations/topology.rb +0 -261
  100. data/lib/net/connector/operations.rb +0 -26
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../engine/errors"
5
+ require_relative "../storage"
6
+
7
+ module Net
8
+ module Connector
9
+ Backup = Data.define(:path, :bytes, :sha256, :collected_at, :change, :previous_sha256) do
10
+ # 保存配置备份的路径、摘要和变更状态。
11
+ def initialize(path:, bytes:, sha256:, collected_at:, change: nil, previous_sha256: nil)
12
+ super
13
+ end
14
+
15
+ # 判断本次采集是否产生新的配置内容。
16
+ def changed? = [:created, :changed].include?(change)
17
+ end
18
+
19
+ # 只携带库自身的提交回执和配置元数据,不携带正文或原始文件系统异常。
20
+ class BackupPersistenceError < Error
21
+ attr_reader :backup, :receipt, :underlying_type
22
+
23
+ def initialize(backup:, write_error:, host: nil)
24
+ unless backup.instance_of?(Backup) && Storage::PrivateFile.receipt_error?(write_error) &&
25
+ write_error.receipt.committed? && write_error.receipt.path == backup.path
26
+ raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
27
+ end
28
+
29
+ @backup, @receipt = backup, write_error.receipt
30
+ @underlying_type = write_error.underlying_type
31
+ code = if write_error.instance_of?(Storage::PrivateFile::DirectorySyncUnsupported)
32
+ :backup_durability_unsupported
33
+ elsif receipt.durable?
34
+ :backup_finalize_failed
35
+ else
36
+ :backup_persistence_unconfirmed
37
+ end
38
+ super(write_error.message, code: code, host: host, phase: :backup)
39
+ end
40
+ end
41
+
42
+ class LocalBackup
43
+ # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
44
+ module Capability
45
+ # 采集配置并以原子方式保存为私有文件。
46
+ # 采集失败时保留已有备份文件。
47
+ def backup(path:, lock_timeout: 0)
48
+ @session.assert_path_lock_order!(:backup)
49
+ LocalBackup.new(self).call(path: path, lock_timeout: lock_timeout)
50
+ end
51
+ end
52
+
53
+ # 保存执行配置采集的设备对象。
54
+ def initialize(device) = @device = device
55
+
56
+ # 采集配置、识别变更并原子写入私有文件。
57
+ def call(path:, lock_timeout: 0)
58
+ host = @device.host if @device.respond_to?(:host)
59
+ Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
60
+ end
61
+
62
+ private
63
+
64
+ def collect_and_write(destination)
65
+ contents = @device.running_config.value!
66
+ digest = Digest::SHA256.hexdigest(contents)
67
+ previous = Storage::SafeFile.fingerprint(destination, missing: true, replace_symlink: true)
68
+ previous_digest = previous&.sha256
69
+ change = if previous_digest.nil?
70
+ :created
71
+ elsif previous_digest == digest
72
+ :unchanged
73
+ else
74
+ :changed
75
+ end
76
+
77
+ backup = Backup.new(path: destination, bytes: contents.bytesize,
78
+ sha256: digest, collected_at: Time.now.utc, change: change,
79
+ previous_sha256: previous_digest)
80
+ if change != :unchanged || previous.mode != 0o600 || !Storage::SafeFile.same_entry?(destination, previous)
81
+ write_backup(backup, contents)
82
+ end
83
+ backup
84
+ end
85
+
86
+ def write_backup(backup, contents)
87
+ Storage::PrivateFile.write(backup.path, contents)
88
+ rescue Storage::PrivateFile::WriteError => error
89
+ raise unless Storage::PrivateFile.receipt_error?(error) && error.receipt.committed?
90
+
91
+ host = @device.host if @device.respond_to?(:host)
92
+ raise BackupPersistenceError.new(backup: backup, write_error: error, host: host), cause: nil
93
+ end
94
+ end
95
+ end
96
+ end
@@ -65,13 +65,18 @@ module Net
65
65
  @command_timeout = duration(command_timeout, :command_timeout)
66
66
  @terminal_size = validate_terminal_size(terminal_size)
67
67
  @running_config_strategy = strategy_class(running_config_strategy, :running_config_strategy,
68
- %i[clean result_step prompt_text check_response])
68
+ %i[clean result_step prompt_text validate_response!])
69
69
  @tftp_strategy = strategy_class(tftp_strategy, :tftp_strategy,
70
- %i[source_file default_path remote_path script complete?])
70
+ %i[resolve_source_file default_path remote_path script device_reported_complete? validate_options! receipt_metadata])
71
+ if @tftp_strategy && !@tftp_strategy.respond_to?(:filename)
72
+ raise ArgumentError, "tftp_strategy must expose filename"
73
+ end
71
74
  @topology_strategy = strategy_class(topology_strategy, :topology_strategy,
72
75
  %i[neighbor_command neighbor_template expected_neighbor_count empty_neighbor_output?
73
76
  protocol description_template interface_key configuration_interface
74
- decode_description enter_configuration change_commands finish_commands script_command])
77
+ decode_description validate_descriptions! change_error_code supports?
78
+ enter_configuration_command change_commands script_command
79
+ leave_configuration_commands verification_commands persistence_commands persistence_confirmed?])
75
80
  if @topology_strategy && !@topology_strategy.respond_to?(:supports?)
76
81
  raise ArgumentError, "topology_strategy must expose supports?"
77
82
  end
@@ -21,7 +21,7 @@ module Net
21
21
  end
22
22
 
23
23
  # 公共策略不增加逐命令响应检查。
24
- def check_response(_command, _response, _execution) end
24
+ def validate_response!(_command, _response, _execution) end
25
25
  end
26
26
 
27
27
  # 使用终端行编辑规则渲染 H3C、Huawei 和 Radware 配置。
@@ -7,6 +7,45 @@ require_relative "running_config/strategy"
7
7
  module Net
8
8
  module Connector
9
9
  class RunningConfig
10
+ # 设备只引入此能力;采集入口和策略作用域与采集流程一起维护。
11
+ module Capability
12
+ # 每次采集使用独立执行对象,设备只提供这一采集入口。
13
+ def running_config = RunningConfig.new(self).call
14
+
15
+ # 返回读取运行配置所需的设备命令;厂商必须声明。
16
+ def config_commands
17
+ commands = profile.config_commands
18
+ return commands if commands
19
+
20
+ raise NotImplementedError, "#{self.class} must define running configuration commands"
21
+ end
22
+
23
+ # 子类可覆盖并调用 super,复用本次响应检查积累的策略状态。
24
+ def clean_config(text) = config_strategy.clean(text)
25
+
26
+ protected
27
+
28
+ # 默认取最后一个完成步骤;厂商策略可选择配置所在的业务步骤。
29
+ def config_result_step(result) = config_strategy.result_step(result)
30
+
31
+ private
32
+
33
+ # 仅在持有会话锁的结果处理阶段绑定策略,退出时恢复原有作用域。
34
+ # 同一设备上其他 Fiber 的离线清理不能借用此次采集状态。
35
+ def with_config_strategy(strategy)
36
+ previous = @config_strategy_scope
37
+ @config_strategy_scope = [Fiber.current, strategy]
38
+ yield
39
+ ensure
40
+ @config_strategy_scope = previous
41
+ end
42
+
43
+ def config_strategy
44
+ scope = @config_strategy_scope
45
+ scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
46
+ end
47
+ end
48
+
10
49
  # 保存需要读取运行配置的设备对象。
11
50
  def initialize(device)
12
51
  @device = device
@@ -20,14 +59,14 @@ module Net
20
59
  # 每次采集创建独立策略;响应校验、选择与清理共享状态,均在同一脚本锁内完成。
21
60
  def call
22
61
  script = Script.new(@device.config_commands)
23
- return Result.new(error: incomplete("configuration collection has no commands")) if script.empty?
62
+ return Result.new(error: build_incomplete_error("configuration collection has no commands")) if script.empty?
24
63
 
25
64
  # 所有厂商的采集步骤都收紧输出边界,包括候选差异、模式切换及扩展查询。
26
65
  script = Script.new(script.map(&:with_output_sensitive))
27
66
  strategy = self.class.strategy(@device)
28
67
  prompt = ->(command) { prompt_for(command, strategy) }
29
68
  @device.execute_operation(script, name: :running_config, prompt: prompt,
30
- after_command: strategy.method(:check_response)) do |result|
69
+ after_command: strategy.method(:validate_response!)) do |result|
31
70
  @device.send(:with_config_strategy, strategy) do
32
71
  finish(result)
33
72
  end
@@ -39,11 +78,11 @@ module Net
39
78
  # 只从完成的配置步骤提取非空内容,并保留此前所有步骤。
40
79
  def finish(result)
41
80
  step = @device.send(:config_result_step, result)
42
- raise incomplete("configuration collection has no completed configuration step") unless step
81
+ raise build_incomplete_error("configuration collection has no completed configuration step") unless step
43
82
 
44
83
  content = @device.clean_config(step.output)
45
84
  unless content.is_a?(String) && !content.strip.empty? && content?(step)
46
- raise incomplete("configuration collection returned empty content")
85
+ raise build_incomplete_error("configuration collection returned empty content")
47
86
  end
48
87
  Result.new(steps: result.steps, config: content)
49
88
  end
@@ -68,7 +107,7 @@ module Net
68
107
  end
69
108
 
70
109
  # 将缺少配置的情况统一映射为可识别的设备错误码。
71
- def incomplete(message)
110
+ def build_incomplete_error(message)
72
111
  DeviceError.new(message, code: :incomplete_configuration, host: @device.host, phase: :collect)
73
112
  end
74
113
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 保存命令及能力入口,响应语义由通用执行器负责。
6
+ module SaveConfig
7
+ # 执行厂商保存配置命令;不支持时返回显式失败结果。
8
+ def save_config
9
+ if save_commands.empty?
10
+ return Result.new(error: @session.build_error(UnsupportedOperation, "saving configuration is not supported",
11
+ phase: :save))
12
+ end
13
+
14
+ execute_script(save_commands)
15
+ end
16
+
17
+ # 返回保存配置命令;空数组表示设备不支持保存。
18
+ def save_commands = profile.save_commands
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+
5
+ module Net
6
+ module Connector
7
+ class Tftp
8
+ # H3C 和华为共用已保存文件上传流程,源文件发现仍由厂商策略负责。
9
+ class FileUpload < Strategy
10
+ # 未指定目标名时,设备使用配置源文件的 basename。
11
+ def default_path(source_file) = File.basename(source_file)
12
+
13
+ # H3C 探测源文件之前先拒绝不支持的参数;探测本身不能放进纯校验钩子。
14
+ def validate_options!(_target, source_file:, vrf: nil)
15
+ raise ArgumentError, "TFTP file upload does not use vrf" unless vrf.nil?
16
+
17
+ TftpTarget.validate_source_file!(source_file) unless source_file.nil?
18
+ end
19
+
20
+ # 指定文件的内容与格式未知,不能仅凭 .cfg 后缀声称是当前运行配置。
21
+ def receipt_metadata(_target, source_file:, **)
22
+ { configuration_kind: :saved_file, source_file: source_file, format: :unknown }
23
+ end
24
+
25
+ # 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
26
+ def script(target, source_file:, vrf: nil)
27
+ validate_options!(target, source_file: source_file, vrf: vrf)
28
+ TftpTarget.validate_source_file!(source_file)
29
+
30
+ destination = target.explicit_path? ? " #{target.path}" : ""
31
+ Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
32
+ end
33
+
34
+ # 只接受明确的成功消息、非零传输字节数或完整进度行。
35
+ def device_reported_complete?(result)
36
+ completion_lines(result).any? { |line| completed_upload?(line) }
37
+ end
38
+
39
+ private
40
+
41
+ # 两个厂商的上传完成消息使用相同的文本及进度格式。
42
+ def completed_upload?(line)
43
+ line.match?(/\A
44
+ (?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
45
+ (?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
46
+ \z/ix) ||
47
+ line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
48
+ completed_progress?(line)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,43 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../engine/errors"
3
+ require_relative "../../engine/errors"
4
+ require_relative "target"
4
5
 
5
6
  module Net
6
7
  module Connector
7
- # 元数据采用组合,不改变 TftpBackup 的位置参数、成员或解构契约。
8
+ # 一次设备上报上传的不可变回执;path 是实际目标,未知时保持 nil。
9
+ TftpReceipt = Data.define(:server, :path, :completed_at, :configuration_kind, :source_file,
10
+ :format, :requested_path, :verification, :server_sha256)
11
+
8
12
  class TftpReceipt
9
13
  KINDS = %i[running startup saved_file native_archive unknown].freeze
10
14
  FORMATS = %i[cfg xml dat tgz unknown].freeze
11
15
  private_constant :KINDS, :FORMATS
12
16
 
13
- attr_reader :transfer, :configuration_kind, :source_file, :format, :requested_path, :actual_path,
14
- :verification, :server_sha256
17
+ def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
18
+ format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil)
19
+ raise ArgumentError, "completed_at must be a Time" unless completed_at.is_a?(Time)
15
20
 
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)
21
+ TftpTarget.new(host: server, path: path.nil? ? "unconfirmed" : path)
22
22
  TftpTarget.validate_path!(requested_path) unless requested_path.nil?
23
23
  TftpTarget.validate_source_file!(source_file) unless source_file.nil?
24
24
  unless KINDS.include?(configuration_kind) && FORMATS.include?(format)
25
25
  raise ArgumentError, "invalid TFTP configuration kind or format"
26
26
  end
27
27
  valid_verification = (verification == :device_reported && server_sha256.nil?) ||
28
- (verification == :server_verified && !actual_path.nil? && server_sha256.is_a?(String) &&
28
+ (verification == :server_verified && !path.nil? && server_sha256.is_a?(String) &&
29
29
  server_sha256.match?(/\A[0-9a-f]{64}\z/))
30
30
  raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
31
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
32
+ super(server: server.dup.freeze, path: path&.dup&.freeze, completed_at: completed_at.dup.freeze,
33
+ configuration_kind: configuration_kind, source_file: source_file&.dup&.freeze, format: format,
34
+ requested_path: requested_path&.dup&.freeze, verification: verification,
35
+ server_sha256: server_sha256&.dup&.freeze)
38
36
  end
39
37
 
40
- # 摘要不展开设备输出或调用方的源文件名。
38
+ # Ruby 3.2 的 Data#with 不调用 initialize;所有更新都重新校验并冻结字段。
39
+ def with(**attributes) = self.class.new(**to_h.merge(attributes))
40
+
41
41
  def inspect = "#<#{self.class} kind=#{configuration_kind} format=#{format} verification=#{verification}>"
42
42
  end
43
43
 
@@ -66,8 +66,6 @@ module Net
66
66
  @underlying_type = type&.match?(/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/) && type.bytesize <= 128 ? type.freeze : nil
67
67
  super(MESSAGES.fetch(code), code: code, host: host, phase: :tftp_backup)
68
68
  end
69
-
70
- def transfer = receipt.transfer
71
69
  end
72
70
  end
73
71
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../engine/command"
4
+ require_relative "target"
5
+ module Net
6
+ module Connector
7
+ class Tftp
8
+ class Strategy
9
+ # 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
10
+ def self.file_extension = "cfg"
11
+
12
+ # 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
13
+ def self.filename(host, label: nil)
14
+ TftpTarget.filename(host, extension: file_extension, label: label)
15
+ end
16
+
17
+ # 保存 TFTP 厂商策略使用的设备对象。
18
+ def initialize(device)
19
+ @device = device
20
+ @responses = []
21
+ end
22
+
23
+ # 返回调用方指定的配置源文件。
24
+ def resolve_source_file(value) = value
25
+
26
+ # 根据设备地址生成默认远端文件名。
27
+ def default_path(_source_file) = self.class.filename(@device.host)
28
+
29
+ # 返回目标中指定的远端文件名。
30
+ def remote_path(target, _result) = target.path
31
+
32
+ private
33
+
34
+ # 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
35
+ def interaction(pattern, response)
36
+ @responses << response.chomp
37
+ Interaction.new(pattern, response, capture: false)
38
+ end
39
+
40
+ # 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
41
+ def completion_lines(result)
42
+ step = result.steps.last
43
+ return [] unless step
44
+
45
+ output = TerminalRenderer.render(step.output)
46
+ prompt = TerminalRenderer.render(step.prompt.to_s)
47
+ output = output.delete_suffix(prompt) unless prompt.empty?
48
+ echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
49
+ output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
50
+ end
51
+
52
+ # 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
53
+ def completed_progress?(line)
54
+ line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
55
+ end
56
+
57
+ # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
58
+ def copied_bytes?(line)
59
+ line.match?(/\A
60
+ [1-9]\d*\s+bytes?\s+copied
61
+ (?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
62
+ \z/ix)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Net
6
+ module Connector
7
+ # 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
8
+ class TftpTarget
9
+ MAX_PATH_BYTES = 220
10
+
11
+ attr_reader :host, :path
12
+
13
+ # 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
14
+ def initialize(host:, path:, explicit_path: true)
15
+ unless host.is_a?(String) && host.bytesize <= 253 &&
16
+ host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
17
+ raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
18
+ end
19
+ self.class.validate_path!(path)
20
+ @host, @path = host.dup.freeze, path.dup.freeze
21
+ @explicit_path = explicit_path
22
+ freeze
23
+ end
24
+
25
+ # 判断调用方是否明确指定了远端文件名。
26
+ def explicit_path? = @explicit_path
27
+
28
+ # 校验设备或调用方给出的远端相对路径。
29
+ def self.validate_path!(path)
30
+ unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
31
+ path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
32
+ path.split("/").none? { |part| %w[. ..].include?(part) }
33
+ raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
34
+ end
35
+ path
36
+ end
37
+
38
+ # 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
39
+ def self.filename(host, extension:, label: nil)
40
+ if label && (!label.is_a?(String) || !label.ascii_only?)
41
+ raise ArgumentError, "TFTP label must be an ASCII String"
42
+ end
43
+
44
+ address = host.gsub(/[^A-Za-z0-9._-]/, "_")
45
+ suffix = "#{address}.#{extension}"
46
+ if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
47
+ suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
48
+ end
49
+ path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
50
+ validate_path!(path)
51
+ end
52
+
53
+ # 校验设备源文件路径是否符合命令安全约束。
54
+ def self.validate_source_file!(value)
55
+ unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
56
+ !value.split("/").include?("..")
57
+ raise ArgumentError, "source_file must be a device file path without spaces or traversal"
58
+ end
59
+ value
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tftp/strategy"
4
+ require_relative "tftp/receipt"
5
+
6
+ module Net
7
+ module Connector
8
+ class Tftp
9
+ # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
10
+ module Capability
11
+ # 要求设备直接向 TFTP 服务器导出原生配置。
12
+ # 完成仅表示设备报告传输成功,未读取服务器端文件。
13
+ def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
14
+ Tftp.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
15
+ end
16
+ end
17
+
18
+ # 保存执行 TFTP 导出的设备对象。
19
+ def initialize(device) = @device = device
20
+
21
+ # 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
22
+ def call(host:, path: nil, source_file: nil, vrf: nil)
23
+ receipt = nil
24
+ strategy_class = @device.profile.tftp_strategy
25
+ unless strategy_class
26
+ raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
27
+ host: @device.host, phase: :tftp_backup)
28
+ end
29
+ strategy = strategy_class.new(@device)
30
+ if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
31
+ raise ArgumentError, "vrf must be a single safe name"
32
+ end
33
+ source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
34
+ vrf = vrf&.dup&.freeze
35
+ target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
36
+ strategy.validate_options!(target, source_file: source_file, vrf: vrf)
37
+ @device.with_operation(:tftp_backup) do
38
+ source = strategy.resolve_source_file(source_file)
39
+ source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
40
+ target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
41
+ result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
42
+ name: :tftp_backup, privilege: false)
43
+ confirm_transfer!(strategy, target, result)
44
+ # 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
45
+ requested_path = target.explicit_path? ? target.path : nil
46
+ receipt = TftpReceipt.new(server: target.host, path: nil, completed_at: Time.now.utc,
47
+ requested_path: requested_path, source_file: source)
48
+ actual_path = remote_path(strategy, target, result, receipt)
49
+ receipt = receipt.with(path: actual_path)
50
+ receipt = build_receipt(strategy, target, source, !source_file.nil?, receipt)
51
+ if target.explicit_path? && target.path != receipt.path
52
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_mismatch, host: @device.host), cause: nil
53
+ end
54
+ # 已完成步骤仍可能伴随后处理/清理错误;上面的回执要先构造,再重新抛出。
55
+ result.value! if result.failure?
56
+ @device.log_event("tftp_backup", status: "reported_uploaded", phase: :tftp_backup,
57
+ server: target.host, path: receipt.path)
58
+ receipt
59
+ end
60
+ rescue StandardError => error
61
+ raise unless receipt
62
+ raise if error.instance_of?(TftpCompletionError) && error.receipt.equal?(receipt)
63
+
64
+ raise TftpCompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
65
+ end
66
+
67
+ private
68
+
69
+ def build_receipt(strategy, target, source_file, explicit_source, receipt)
70
+ metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
71
+ unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
72
+ raise ArgumentError, "invalid TFTP strategy receipt metadata"
73
+ end
74
+ receipt.with(**{ source_file: source_file, requested_path: target.path }.merge(metadata))
75
+ end
76
+
77
+ def remote_path(strategy, target, result, receipt)
78
+ TftpTarget.validate_path!(strategy.remote_path(target, result))
79
+ rescue StandardError => error
80
+ raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_unconfirmed,
81
+ host: @device.host, underlying: error), cause: nil
82
+ end
83
+
84
+ # 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
85
+ def confirm_transfer!(strategy, target, result)
86
+ if transfer_failed?(result)
87
+ @device.log_event("tftp_backup", level: :error, status: "transfer_failed", phase: :tftp_backup,
88
+ code: :transfer_failed, server: target.host)
89
+ raise DeviceError.new("device reported TFTP backup failure",
90
+ code: :transfer_failed, host: @device.host, phase: :tftp_backup)
91
+ end
92
+ unless strategy.device_reported_complete?(result)
93
+ result.value! if result.failure?
94
+ @device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
95
+ code: :transfer_unconfirmed, server: target.host)
96
+ raise DeviceError.new("device did not confirm TFTP backup completion",
97
+ code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
98
+ end
99
+ end
100
+
101
+ # 识别设备回显中的传输失败信息。
102
+ def transfer_failed?(result)
103
+ result.steps.any? do |step|
104
+ # 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
105
+ pattern = %r{(?<![\w./-])
106
+ (?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
107
+ unable\ to|network\ is\ unreachable|can't\ open)
108
+ (?![\w/-]|\.[\w])}ix
109
+ # 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
110
+ history = step.output.gsub(/\r(?!\n)/, "\n")
111
+ step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
112
+ TerminalRenderer.render(step.output).match?(pattern)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy"
4
+ require_relative "../../engine/terminal_renderer"
5
+
6
+ module Net
7
+ module Connector
8
+ class Topology
9
+ # 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
10
+ class ImmediateStrategy < Strategy
11
+ def verification_commands = @device.config_commands
12
+ def persistence_commands = @device.profile.save_commands
13
+
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
+ # 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
24
+ def persistence_confirmed?(result)
25
+ !result.steps.empty? && result.steps.all? do |step|
26
+ raw = step.output.delete_suffix(step.prompt.to_s)
27
+ rendered = TerminalRenderer.render(raw)
28
+ failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
29
+ next false if raw.match?(failure) || rendered.match?(failure)
30
+
31
+ rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
32
+ end
33
+ end
34
+
35
+ # 没有明确完成证据的策略不能借用一个泛化的成功关键词。
36
+ def persistence_pattern = /\A\b\B\z/
37
+ end
38
+ end
39
+ end
40
+ end