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
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "textfsm"
4
- require_relative "../engine/errors"
5
- require_relative "../engine/terminal_renderer"
6
-
7
- module Net
8
- module Connector
9
- module Operations
10
- # 将设备命令回显或配置文本按 TextFSM 模板转换为结构化记录。
11
- class ParseOutput
12
- DEFAULT_TEMPLATE_DIR = File.expand_path("../templates", __dir__).freeze
13
-
14
- # 配置模板目录和 Netmiko 风格的命令索引文件。
15
- def initialize(template_dir: nil, index: "index")
16
- template_dir ||= DEFAULT_TEMPLATE_DIR
17
- raise ArgumentError, "template_dir must be a nonempty String" unless template_dir.is_a?(String) && !template_dir.empty?
18
- raise ArgumentError, "index must be a nonempty String" unless index.is_a?(String) && !index.empty?
19
-
20
- @template_dir = File.expand_path(template_dir)
21
- @index = index
22
- end
23
-
24
- # 解析按 UTF-8 字节处理副本,先验证原文,避免回车或转义序列隐藏非法字节。
25
- # 拓扑选模板和计数也使用这份副本,但仍保留未经终端渲染的完整证据。
26
- def utf8(text, host: nil)
27
- raise ArgumentError, "text must be a String" unless text.is_a?(String)
28
-
29
- text = text.b.force_encoding(Encoding::UTF_8)
30
- invalid_encoding!(host) unless text.valid_encoding?
31
- text
32
- end
33
-
34
- # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
35
- def call(text, template: nil, vendor: nil, command: nil, host: nil)
36
- text = TerminalRenderer.render(utf8(text, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
37
- if template
38
- raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
39
-
40
- TextFSM::Parser.from_file(File.expand_path(template, @template_dir)).parse_hashes(text)
41
- else
42
- raise ArgumentError, "vendor and command are required for template lookup" unless vendor && command.is_a?(String) && !command.empty?
43
-
44
- attributes = { "Vendor" => vendor.to_s, "Command" => command }
45
- table = TextFSM::CliTable.new(index: @index, template_dir: @template_dir)
46
- unless table.index.match(attributes)
47
- raise ParsingError.new("no TextFSM template matches this vendor and command",
48
- code: :template_missing, host: host, phase: :parse)
49
- end
50
- table.parse(text, attributes: attributes).to_hashes
51
- end
52
- rescue Encoding::InvalidByteSequenceError
53
- invalid_encoding!(host)
54
- rescue TextFSM::IndexError
55
- raise ParsingError.new("TextFSM template index is invalid",
56
- code: :template_invalid, host: host, phase: :parse), cause: nil
57
- rescue TextFSM::TemplateError
58
- raise ParsingError.new("TextFSM template is invalid",
59
- code: :template_invalid, host: host, phase: :parse), cause: nil
60
- rescue TextFSM::ParseError
61
- raise ParsingError.new("TextFSM could not parse the device output",
62
- code: :parse_failed, host: host, phase: :parse), cause: nil
63
- rescue IOError, SystemCallError
64
- raise ParsingError.new("unable to read TextFSM template",
65
- code: :template_unreadable, host: host, phase: :parse), cause: nil
66
- end
67
-
68
- private
69
-
70
- def invalid_encoding!(host)
71
- raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
72
- host: host, phase: :parse), cause: nil
73
- end
74
- end
75
- end
76
- end
77
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../running_config"
4
- require_relative "../../vendor/cisco_ios/running_config"
5
- require_relative "../../vendor/cisco_nxos/running_config"
6
-
7
- module Net
8
- module Connector
9
- module Operations
10
- RunningConfig::Cisco = Net::Connector::CiscoIos::RunningConfig
11
- RunningConfig::CiscoNxos = Net::Connector::CiscoNxos::RunningConfig
12
- end
13
- end
14
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../running_config"
4
- require_relative "../../vendor/hillstone/running_config"
5
-
6
- module Net
7
- module Connector
8
- module Operations
9
- RunningConfig::Hillstone = Net::Connector::Hillstone::RunningConfig
10
- end
11
- end
12
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../running_config"
4
- require_relative "../../vendor/palo_alto/running_config"
5
-
6
- module Net
7
- module Connector
8
- module Operations
9
- RunningConfig::PaloAlto = Net::Connector::PaloAlto::RunningConfig
10
- end
11
- end
12
- end
@@ -1,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../running_config"
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../device/running_config"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- # 旧常量与新能力共享同一实现,不维护第二套采集流程。
9
- RunningConfig = Net::Connector::RunningConfig
10
- RunningConfig.autoload :Cisco, File.expand_path("running_config/cisco", __dir__)
11
- RunningConfig.autoload :CiscoNxos, File.expand_path("running_config/cisco", __dir__)
12
- RunningConfig.autoload :Hillstone, File.expand_path("running_config/hillstone", __dir__)
13
- RunningConfig.autoload :PaloAlto, File.expand_path("running_config/palo_alto", __dir__)
14
- end
15
- end
16
- end
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../engine/errors"
4
- require_relative "../safe_file"
5
-
6
- module Net
7
- module Connector
8
- class SavedConfigChanged < Error; end
9
-
10
- module Operations
11
- class SavedConfig
12
- # 每批只发布一份只读目录快照。记录文件身份而不读取正文;不跨批次缓存。
13
- class LegacyIndex
14
- Entry = Data.define(:name, :identity, :regular)
15
- EMPTY = [].freeze
16
-
17
- def initialize(directory)
18
- @directory = directory
19
- @mutex = Mutex.new
20
- @entries = nil
21
- @failed = false
22
- end
23
-
24
- def candidates(filename)
25
- @mutex.synchronize do
26
- raise IOError, "unable to index saved configurations", cause: nil if @failed
27
-
28
- @entries ||= snapshot
29
- @entries.fetch(filename, EMPTY)
30
- rescue IOError, SystemCallError
31
- @failed = true
32
- raise IOError, "unable to index saved configurations", cause: nil
33
- end
34
- end
35
-
36
- # 目录快照不是锁:同一 FD 读取前后及最终目录项都必须仍匹配快照。
37
- # 非合作写入者仍可在检查之后修改文件,调用方必须保护整个备份目录。
38
- def open(entry)
39
- path = File.join(@directory, entry.name)
40
- verify!(entry, File.lstat(path))
41
- SafeFile.open(path) do |file, stat|
42
- verify!(entry, stat)
43
- value = yield path, file, stat
44
- verify!(entry, file.stat)
45
- verify!(entry, File.lstat(path))
46
- value
47
- end
48
- rescue IOError, SystemCallError
49
- changed!
50
- rescue ArgumentError
51
- raise unless entry.regular
52
-
53
- changed!
54
- end
55
-
56
- private
57
-
58
- def snapshot
59
- entries = {}
60
- Dir.children(@directory).each do |name|
61
- filename = canonical_suffix(name)
62
- next unless filename
63
-
64
- (entries[filename] ||= []) << snapshot_entry(name)
65
- end
66
- entries.each_value(&:freeze)
67
- entries.freeze
68
- end
69
-
70
- # 下划线仅还原地址部分,IPv6 zone 中的下划线保持原义;合法性仍由 IPAddr 判断。
71
- def canonical_suffix(name)
72
- suffix = name.b[/-([0-9a-f_.]+(?:%[a-z0-9_.-]+)?)\.txt\z/in, 1]
73
- return unless suffix
74
-
75
- address, zone = suffix.split("%", 2)
76
- host = address.tr("_", ":")
77
- host += "%#{zone}" if zone
78
- SavedConfig.filename(host).freeze
79
- rescue ArgumentError
80
- nil
81
- end
82
-
83
- def snapshot_entry(name)
84
- stat = File.lstat(File.join(@directory, name))
85
- Entry.new(name: name.freeze, identity: identity(stat), regular: stat.file?)
86
- rescue SystemCallError
87
- # 列表与 lstat 之间消失的候选仍占据该身份,不能把后来出现的文件当作旧快照。
88
- Entry.new(name: name.freeze, identity: nil, regular: false)
89
- end
90
-
91
- def identity(stat)
92
- [stat.dev, stat.ino, stat.mode, stat.size, stat.mtime.to_r, stat.ctime.to_r].freeze
93
- end
94
-
95
- def verify!(entry, stat)
96
- changed! unless entry.identity == identity(stat)
97
- end
98
-
99
- def changed!
100
- raise SavedConfigChanged.new("legacy saved configuration changed or became unavailable after indexing",
101
- code: :saved_config_changed, phase: :backup), cause: nil
102
- end
103
- end
104
-
105
- private_constant :LegacyIndex
106
- end
107
- end
108
- end
109
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/cisco_ios/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::CiscoIos = Net::Connector::CiscoIos::TftpBackup
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/cisco_nxos/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::CiscoNxos = Net::Connector::CiscoNxos::TftpBackup
9
- end
10
- end
11
- end
@@ -1,55 +0,0 @@
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
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/h3c/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::H3c = Net::Connector::H3c::TftpBackup
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/hillstone/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::Hillstone = Net::Connector::Hillstone::TftpBackup
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/huawei/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::Huawei = Net::Connector::Huawei::TftpBackup
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/palo_alto/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::PaloAlto = Net::Connector::PaloAlto::TftpBackup
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/radware/tftp_backup"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Tftp::Radware = Net::Connector::Radware::TftpBackup
9
- end
10
- end
11
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Net
4
- module Connector
5
- module Operations
6
- module Tftp
7
- autoload :H3c, File.expand_path("h3c", __dir__)
8
- autoload :CiscoIos, File.expand_path("cisco_ios", __dir__)
9
- autoload :CiscoNxos, File.expand_path("cisco_nxos", __dir__)
10
- autoload :Huawei, File.expand_path("huawei", __dir__)
11
- autoload :Hillstone, File.expand_path("hillstone", __dir__)
12
- autoload :Radware, File.expand_path("radware", __dir__)
13
- autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
14
-
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
-
24
- # 保存 TFTP 厂商策略使用的设备对象。
25
- def initialize(device)
26
- @device = device
27
- @responses = []
28
- end
29
-
30
- # 返回调用方指定的配置源文件。
31
- def source_file(value) = value
32
-
33
- # 根据设备地址生成默认远端文件名。
34
- def default_path(_source_file) = self.class.filename(@device.host)
35
-
36
- # 返回目标中指定的远端文件名。
37
- def remote_path(target, _result) = target.path
38
-
39
- private
40
-
41
- # 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
42
- def interaction(pattern, response)
43
- @responses << response.chomp
44
- Interaction.new(pattern, response, capture: false)
45
- end
46
-
47
- # 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
48
- def completion_lines(result)
49
- step = result.steps.last
50
- return [] unless step
51
-
52
- output = TerminalRenderer.render(step.output)
53
- prompt = TerminalRenderer.render(step.prompt.to_s)
54
- output = output.delete_suffix(prompt) unless prompt.empty?
55
- echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
56
- output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
57
- end
58
-
59
- # 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
60
- def completed_progress?(line)
61
- line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
62
- end
63
-
64
- # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
65
- def copied_bytes?(line)
66
- line.match?(/\A
67
- [1-9]\d*\s+bytes?\s+copied
68
- (?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
69
- \z/ix)
70
- end
71
- end
72
- end
73
- end
74
- end
75
- end
@@ -1,183 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require_relative "tftp/strategy"
5
- require_relative "tftp_receipt"
6
-
7
- module Net
8
- module Connector
9
- # 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
10
- class TftpTarget
11
- MAX_PATH_BYTES = 220
12
-
13
- attr_reader :host, :path
14
-
15
- # 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
16
- def initialize(host:, path:, explicit_path: true)
17
- unless host.is_a?(String) && host.bytesize <= 253 &&
18
- host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
19
- raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
20
- end
21
- self.class.validate_path!(path)
22
- @host, @path = host.dup.freeze, path.dup.freeze
23
- @explicit_path = explicit_path
24
- freeze
25
- end
26
-
27
- # 判断调用方是否明确指定了远端文件名。
28
- def explicit_path? = @explicit_path
29
-
30
- # 校验设备或调用方给出的远端相对路径。
31
- def self.validate_path!(path)
32
- unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
33
- path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
34
- path.split("/").none? { |part| %w[. ..].include?(part) }
35
- raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
36
- end
37
- path
38
- end
39
-
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
-
46
- address = host.gsub(/[^A-Za-z0-9._-]/, "_")
47
- suffix = "#{address}.#{extension}"
48
- if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
49
- suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
50
- end
51
- path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
52
- validate_path!(path)
53
- end
54
-
55
- # 校验设备源文件路径是否符合命令安全约束。
56
- def self.validate_source_file!(value)
57
- unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
58
- !value.split("/").include?("..")
59
- raise ArgumentError, "source_file must be a device file path without spaces or traversal"
60
- end
61
- value
62
- end
63
- end
64
-
65
- TftpBackup = Data.define(:server, :path, :completed_at)
66
-
67
- module Operations
68
- class TftpBackup
69
- # 保存执行 TFTP 导出的设备对象。
70
- def initialize(device) = @device = device
71
-
72
- # 保留原有返回结构;扩展元数据由 call_receipt 单独提供。
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
80
- strategy_class = @device.profile.tftp_strategy
81
- unless strategy_class
82
- raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
83
- host: @device.host, phase: :tftp_backup)
84
- end
85
- strategy = strategy_class.new(@device)
86
- if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
87
- raise ArgumentError, "vrf must be a single safe name"
88
- end
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)
153
- if transfer_failed?(result)
154
- @device.record_event("tftp_backup", level: :error, status: "transfer_failed", server: target.host)
155
- raise DeviceError.new("device reported TFTP backup failure",
156
- code: :transfer_failed, host: @device.host, phase: :tftp_backup)
157
- end
158
- unless strategy.complete?(result)
159
- result.value! if result.failure?
160
- @device.record_event("tftp_backup", level: :error, status: "transfer_unconfirmed", server: target.host)
161
- raise DeviceError.new("device did not confirm TFTP backup completion",
162
- code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
163
- end
164
- end
165
-
166
- # 识别设备回显中的传输失败信息。
167
- def transfer_failed?(result)
168
- result.steps.any? do |step|
169
- # 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
170
- pattern = %r{(?<![\w./-])
171
- (?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
172
- unable\ to|network\ is\ unreachable|can't\ open)
173
- (?![\w/-]|\.[\w])}ix
174
- # 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
175
- history = step.output.gsub(/\r(?!\n)/, "\n")
176
- step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
177
- TerminalRenderer.render(step.output).match?(pattern)
178
- end
179
- end
180
- end
181
- end
182
- end
183
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../vendor/cisco_ios/topology"
4
-
5
- module Net
6
- module Connector
7
- module Operations
8
- Topology::Cisco = Net::Connector::CiscoIos::Topology
9
- end
10
- end
11
- end