net-connector 0.4.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. metadata +517 -0
@@ -0,0 +1,77 @@
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
+ # 保存 TFTP 厂商策略使用的设备对象。
17
+ def initialize(device)
18
+ @device = device
19
+ @responses = []
20
+ end
21
+
22
+ # 返回调用方指定的配置源文件。
23
+ def source_file(value) = value
24
+
25
+ # 根据设备地址生成默认远端文件名。
26
+ def default_path(_source_file) = TftpTarget.filename(@device.host, extension: "cfg")
27
+
28
+ # 返回目标中指定的远端文件名。
29
+ def remote_path(target, _result) = target.path
30
+
31
+ private
32
+
33
+ # 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
34
+ def interaction(pattern, response)
35
+ @responses << response.chomp
36
+ Interaction.new(pattern, response, capture: false)
37
+ end
38
+
39
+ # 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
40
+ def completion_lines(result)
41
+ step = result.steps.last
42
+ return [] unless step
43
+
44
+ output = TerminalRenderer.render(step.output)
45
+ prompt = TerminalRenderer.render(step.prompt.to_s)
46
+ output = output.delete_suffix(prompt) unless prompt.empty?
47
+ echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
48
+ output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
49
+ end
50
+
51
+ # 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
52
+ def completed_progress?(line)
53
+ line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
54
+ end
55
+
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
+ # Cisco 明确报告复制完成的字节数,可附带耗时和速率。
67
+ def copied_bytes?(line)
68
+ line.match?(/\A
69
+ [1-9]\d*\s+bytes?\s+copied
70
+ (?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
71
+ \z/ix)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "tftp/strategy"
5
+
6
+ module Net
7
+ module Connector
8
+ # 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
9
+ class TftpTarget
10
+ MAX_PATH_BYTES = 220
11
+
12
+ attr_reader :host, :path
13
+
14
+ # 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
15
+ def initialize(host:, path:, explicit_path: true)
16
+ unless host.is_a?(String) && host.bytesize <= 253 &&
17
+ host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
18
+ raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
19
+ end
20
+ self.class.validate_path!(path)
21
+ @host, @path = host.dup.freeze, path.dup.freeze
22
+ @explicit_path = explicit_path
23
+ freeze
24
+ end
25
+
26
+ # 判断调用方是否明确指定了远端文件名。
27
+ def explicit_path? = @explicit_path
28
+
29
+ # 校验设备或调用方给出的远端相对路径。
30
+ def self.validate_path!(path)
31
+ unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
32
+ path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
33
+ path.split("/").none? { |part| %w[. ..].include?(part) }
34
+ raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
35
+ end
36
+ path
37
+ end
38
+
39
+ # 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
40
+ def self.filename(host, extension:, label: nil)
41
+ address = host.gsub(/[^A-Za-z0-9._-]/, "_")
42
+ suffix = "#{address}.#{extension}"
43
+ if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
44
+ suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
45
+ end
46
+ path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
47
+ validate_path!(path)
48
+ end
49
+
50
+ # 校验设备源文件路径是否符合命令安全约束。
51
+ def self.validate_source_file!(value)
52
+ unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
53
+ !value.split("/").include?("..")
54
+ raise ArgumentError, "source_file must be a device file path without spaces or traversal"
55
+ end
56
+ value
57
+ end
58
+ end
59
+
60
+ TftpBackup = Data.define(:server, :path, :completed_at)
61
+
62
+ module Operations
63
+ class TftpBackup
64
+ # 保存执行 TFTP 导出的设备对象。
65
+ def initialize(device) = @device = device
66
+
67
+ # 执行设备原生 TFTP 导出并核对完成回显。
68
+ def call(host:, path: nil, source_file: nil, vrf: nil)
69
+ strategy_class = @device.profile.tftp_strategy
70
+ unless strategy_class
71
+ raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
72
+ host: @device.host, phase: :tftp_backup)
73
+ end
74
+ strategy = strategy_class.new(@device)
75
+ if vrf && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
76
+ raise ArgumentError, "vrf must be a single safe name"
77
+ 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!
85
+ if transfer_failed?(result)
86
+ @device.record_event("tftp_backup", level: :error, status: "transfer_failed", server: target.host)
87
+ raise DeviceError.new("device reported TFTP backup failure",
88
+ code: :transfer_failed, host: @device.host, phase: :tftp_backup)
89
+ end
90
+ unless strategy.complete?(result)
91
+ @device.record_event("tftp_backup", level: :error, status: "transfer_unconfirmed", server: target.host)
92
+ raise DeviceError.new("device did not confirm TFTP backup completion",
93
+ code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
94
+ 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
+ end
100
+
101
+ private
102
+
103
+ # 识别设备回显中的传输失败信息。
104
+ def transfer_failed?(result)
105
+ result.steps.any? do |step|
106
+ # 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
107
+ pattern = %r{(?<![\w./-])
108
+ (?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
109
+ unable\ to|network\ is\ unreachable|can't\ open)
110
+ (?![\w/-]|\.[\w])}ix
111
+ # 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
112
+ history = step.output.gsub(/\r(?!\n)/, "\n")
113
+ step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
114
+ TerminalRenderer.render(step.output).match?(pattern)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../vendor/h3c/topology"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ Topology::H3c = Net::Connector::H3c::Topology
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../vendor/hillstone/topology"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ Topology::Hillstone = Net::Connector::Hillstone::Topology
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../vendor/palo_alto/topology"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ Topology::PaloAlto = Net::Connector::PaloAlto::Topology
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../vendor/radware/topology"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ Topology::Radware = Net::Connector::Radware::Topology
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../../device/interface_description"
5
+
6
+ module Net
7
+ module Connector
8
+ module Operations
9
+ class Topology
10
+ autoload :Cisco, File.expand_path("cisco", __dir__)
11
+ autoload :H3c, File.expand_path("h3c", __dir__)
12
+ autoload :Hillstone, File.expand_path("hillstone", __dir__)
13
+ autoload :PaloAlto, File.expand_path("palo_alto", __dir__)
14
+ autoload :Radware, File.expand_path("radware", __dir__)
15
+
16
+ # 厂商拓扑规则;默认不提供任何设备能力。
17
+ class Strategy
18
+ def initialize(device)
19
+ @device = device
20
+ end
21
+
22
+ def self.supports?(_capability) = false
23
+ def supports?(capability) = self.class.supports?(capability)
24
+
25
+ def neighbor_command = nil
26
+ def neighbor_template(_output) = nil
27
+ def description_template = nil
28
+ def expected_neighbor_count(_output, _template) = 0
29
+ def empty_neighbor_output?(_output) = false
30
+ def enter_configuration = nil
31
+ def finish_commands = []
32
+ def change_commands(_change) = []
33
+ def protocol = :lldp
34
+ def script_command(command) = command
35
+
36
+ def interface_key(name) = InterfaceName.key(name)
37
+
38
+ # 将发现协议常用的接口简称展开为配置视图可识别的名称。
39
+ def configuration_interface(name) = InterfaceName.configuration(name)
40
+
41
+ # 还原 PAN-OS set 配置或 Alteon dump 中的引号描述。
42
+ def decode_description(value)
43
+ value = value.strip
44
+ value.start_with?('"') ? Shellwords.split(value).join(" ") : value
45
+ rescue ArgumentError
46
+ value
47
+ end
48
+
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "topology/strategy"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ # 从 CDP/LLDP 回显生成链路邻居,并规划接口描述变更。
9
+ class Topology
10
+ Neighbor = Data.define(:local_interface, :neighbor_name, :neighbor_interface, :chassis_id, :protocol)
11
+ Change = Data.define(:interface, :old_description, :new_description, :neighbor)
12
+ Plan = Data.define(:host, :vendor, :evidence, :changes, :commands)
13
+
14
+ # 保存设备及其独立的 TextFSM 解析入口。
15
+ def initialize(device, template_dir: nil)
16
+ require_relative "parse_output"
17
+
18
+ @device = device
19
+ @parser = ParseOutput.new(template_dir: template_dir)
20
+ @strategy = (device.profile.topology_strategy || Strategy).new(device)
21
+ end
22
+
23
+ # 执行厂商 CDP/LLDP 命令,返回统一的邻居记录。
24
+ def neighbors
25
+ command = @strategy.neighbor_command
26
+ unless @strategy.supports?(:neighbors) && command
27
+ raise UnsupportedOperation.new("this device cannot discover LLDP neighbors",
28
+ code: :neighbor_discovery_unsupported, host: @device.host, phase: :discover)
29
+ end
30
+
31
+ output = @device.execute(command).value!
32
+ template = @strategy.neighbor_template(output)
33
+ rows = @parser.call(output, template: template, vendor: @device.vendor, command: command, host: @device.host)
34
+ if rows.size != @strategy.expected_neighbor_count(output, template) || (rows.empty? && !@strategy.empty_neighbor_output?(output))
35
+ raise ParsingError.new("neighbor output was not recognized", code: :unrecognized_output,
36
+ host: @device.host, phase: :discover)
37
+ end
38
+
39
+ rows.map do |row|
40
+ Neighbor.new(local_interface: row.fetch("LOCAL_INTERFACE").strip.freeze,
41
+ neighbor_name: row.fetch("NEIGHBOR_NAME", "").strip.freeze,
42
+ neighbor_interface: row.fetch("NEIGHBOR_INTERFACE").strip.freeze,
43
+ chassis_id: row.fetch("CHASSIS_ID", "").strip.freeze,
44
+ protocol: @strategy.protocol).freeze
45
+ end
46
+ end
47
+
48
+ # 从运行配置读取已设置的接口描述或端口名称。
49
+ def descriptions
50
+ template = @strategy.description_template
51
+ unless @strategy.supports?(:interface_descriptions) && template
52
+ raise UnsupportedOperation.new("interface descriptions are unsupported for this vendor",
53
+ code: :description_unsupported, host: @device.host, phase: :discover)
54
+ end
55
+ config = @device.running_config.value!
56
+ @parser.call(config, template: template, host: @device.host).each_with_object({}) do |row, found|
57
+ interface = @strategy.interface_key(row.fetch("INTERFACE"))
58
+ description = @strategy.decode_description(row.fetch("DESCRIPTION", ""))
59
+ previous = found[interface]
60
+ if previous && !previous.empty? && !description.empty? && previous != description
61
+ raise ParsingError.new("interface has conflicting descriptions", code: :ambiguous_description,
62
+ host: @device.host, phase: :parse)
63
+ end
64
+ found[interface] = description unless description.empty? && previous
65
+ end
66
+ end
67
+
68
+ # 比对邻居和现有描述,生成包含确切命令的只读变更计划。
69
+ def plan_descriptions(abbreviate: true, lowercase: false, &formatter)
70
+ check_change_support!(:plan) if @strategy.supports?(:neighbors)
71
+ discovered = neighbors
72
+ current = descriptions
73
+ current.each_value(&:freeze)
74
+ evidence = evidence_for(discovered, current)
75
+ changes = discovered.filter_map do |neighbor|
76
+ old = current.fetch(@strategy.interface_key(neighbor.local_interface))
77
+ proposed = if formatter
78
+ formatter.call(neighbor)
79
+ else
80
+ InterfaceDescription.format(neighbor, abbreviate: abbreviate, lowercase: lowercase)
81
+ end
82
+ InterfaceDescription.validate!(proposed)
83
+ next if old == proposed
84
+
85
+ Change.new(interface: @strategy.configuration_interface(neighbor.local_interface).freeze, old_description: old.freeze,
86
+ new_description: proposed.freeze, neighbor: neighbor).freeze
87
+ end.freeze
88
+ commands = if changes.empty?
89
+ []
90
+ else
91
+ [@strategy.enter_configuration] + changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
92
+ end
93
+ commands.each(&:freeze)
94
+ Plan.new(host: @device.host.dup.freeze, vendor: @device.vendor, evidence: evidence,
95
+ changes: changes, commands: commands.freeze).freeze
96
+ end
97
+
98
+ # 只有明确确认且邻居和旧描述未变化时,才下发计划中的命令。
99
+ def apply(plan, confirmed: false)
100
+ unless confirmed == true
101
+ raise UnsupportedOperation.new("description plan requires confirmation", code: :confirmation_required,
102
+ host: @device.host, phase: :apply)
103
+ end
104
+ raise ArgumentError, "plan must be a topology plan for this device" unless plan.is_a?(Plan) &&
105
+ plan.host == @device.host &&
106
+ plan.vendor == @device.vendor
107
+ return Result.new if plan.changes.empty?
108
+
109
+ check_change_support!(:apply) if @strategy.supports?(:neighbors)
110
+ @device.with_operation(:apply) { apply_plan(plan) }
111
+ end
112
+
113
+ private
114
+
115
+ # 现场复核、修改和回读属于同一次独占操作。
116
+ def apply_plan(plan)
117
+ discovered = neighbors
118
+ evidence = evidence_for(discovered, descriptions)
119
+ current_neighbors = discovered.to_h { |neighbor| [@strategy.interface_key(neighbor.local_interface), neighbor] }
120
+ same_neighbors = plan.changes.all? do |change|
121
+ current_neighbors[@strategy.interface_key(change.interface)] == change.neighbor
122
+ end
123
+ unless evidence == plan.evidence && same_neighbors
124
+ raise DeviceError.new("neighbors or interface descriptions changed after planning",
125
+ code: :stale_plan, host: @device.host, phase: :apply)
126
+ end
127
+
128
+ plan.changes.each do |change|
129
+ InterfaceDescription.validate!(change.new_description)
130
+ unless evidence.fetch(@strategy.interface_key(change.interface)) ==
131
+ [change.neighbor.neighbor_name, change.neighbor.neighbor_interface, change.old_description]
132
+ raise ArgumentError, "plan changes do not match its discovery evidence"
133
+ end
134
+ end
135
+ commands = [@strategy.enter_configuration] + plan.changes.flat_map { |change| change_commands(change) } + @strategy.finish_commands
136
+ raise ArgumentError, "plan commands were modified" unless commands == plan.commands
137
+
138
+ script = commands.map do |command|
139
+ @strategy.script_command(command)
140
+ end
141
+ result = @device.execute_script(Script.new(script, name: "interface descriptions"))
142
+ return result if result.failure?
143
+
144
+ updated = descriptions
145
+ return result if plan.changes.all? do |change|
146
+ updated.fetch(@strategy.interface_key(change.interface), "") == change.new_description
147
+ end
148
+
149
+ Result.new(steps: result.steps,
150
+ error: DeviceError.new("interface descriptions were not confirmed by readback",
151
+ code: :description_unconfirmed, host: @device.host, phase: :verify))
152
+ rescue Error => error
153
+ raise unless result&.success?
154
+
155
+ Result.new(steps: result.steps, error: error)
156
+ end
157
+
158
+ def check_change_support!(phase)
159
+ return if @strategy.supports?(:interface_description_changes)
160
+
161
+ raise UnsupportedOperation.new("interface description changes are unsupported for this vendor",
162
+ code: :description_unsupported, host: @device.host, phase: phase)
163
+ end
164
+
165
+ # 汇总邻居身份和当前描述,并要求每个目标接口都有配置证据。
166
+ def evidence_for(discovered, current)
167
+ interfaces = discovered.group_by { |neighbor| @strategy.interface_key(neighbor.local_interface) }
168
+ if interfaces.any? { |_interface, peers| peers.size != 1 || peers.first.neighbor_name.empty? ||
169
+ peers.first.neighbor_interface.empty? }
170
+ raise ParsingError.new("neighbor identity is incomplete or ambiguous", code: :ambiguous_neighbor,
171
+ host: @device.host, phase: :plan)
172
+ end
173
+ unless interfaces.keys.all? { |interface| current.key?(interface) }
174
+ raise ParsingError.new("neighbor interface was not found in running configuration",
175
+ code: :interface_missing, host: @device.host, phase: :plan)
176
+ end
177
+
178
+ interfaces.transform_values do |peers|
179
+ peer = peers.fetch(0)
180
+ [peer.neighbor_name, peer.neighbor_interface, current.fetch(@strategy.interface_key(peer.local_interface))].freeze
181
+ end.freeze
182
+ end
183
+
184
+ # 设备标识来自解析证据,仍需在生成命令前验证其安全性。
185
+ def change_commands(change)
186
+ InterfaceDescription.validate_interface!(change.interface)
187
+ @strategy.change_commands(change)
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Topology 同时是扩展策略的命名空间。显式装配公共定义,确保旧扩展先加载
4
+ # topology/strategy 时,已有常量不会阻止工作流加载;TextFSM 仍延迟到实际解析。
5
+ require_relative "operations/topology"
6
+
7
+ module Net
8
+ module Connector
9
+ module Operations
10
+ autoload :RunningConfig, File.expand_path("operations/running_config", __dir__)
11
+ autoload :ParseOutput, File.expand_path("operations/parse_output", __dir__)
12
+ autoload :LocalBackup, File.expand_path("operations/local_backup", __dir__)
13
+ autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
14
+ autoload :Tftp, File.expand_path("operations/tftp/strategy", __dir__)
15
+ end
16
+ autoload :TftpTarget, File.expand_path("operations/tftp_backup", __dir__)
17
+ autoload :TftpBackup, File.expand_path("operations/tftp_backup", __dir__)
18
+ autoload :Backup, File.expand_path("operations/local_backup", __dir__)
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ Value Required LOCAL_INTERFACE (\S+)
2
+ Value Required NEIGHBOR_NAME (\S+)
3
+ Value Required NEIGHBOR_INTERFACE (\S+)
4
+
5
+ Start
6
+ ^\s*Device ID:\s*${NEIGHBOR_NAME}\s*$$
7
+ ^\s*Interface:\s*${LOCAL_INTERFACE},\s*Port ID \(outgoing port\):\s*${NEIGHBOR_INTERFACE}\s*$$ -> Record
@@ -0,0 +1,12 @@
1
+ Value Required INTERFACE (\S+)
2
+ Value DESCRIPTION (.*)
3
+
4
+ Start
5
+ ^interface ${INTERFACE}$$ -> Interface
6
+
7
+ Interface
8
+ ^\s+description ${DESCRIPTION}$$
9
+ ^!$$ -> Record Start
10
+ ^interface .+ -> Continue.Record
11
+ ^interface ${INTERFACE}$$
12
+ ^end$$ -> Record End
@@ -0,0 +1,7 @@
1
+ Value Required INTERFACE (\S+)
2
+ Value IP_ADDRESS (\S+)
3
+ Value STATUS (administratively down|up|down)
4
+ Value PROTOCOL (up|down)
5
+
6
+ Start
7
+ ^${INTERFACE}\s+${IP_ADDRESS}\s+\S+\s+\S+\s+${STATUS}\s+${PROTOCOL}$$ -> Record
@@ -0,0 +1,11 @@
1
+ Value Required INTERFACE (\S+)
2
+ Value DESCRIPTION (.*)
3
+
4
+ Start
5
+ ^interface ${INTERFACE}$$ -> Interface
6
+
7
+ Interface
8
+ ^\s+description ${DESCRIPTION}$$
9
+ ^#$$ -> Record Start
10
+ ^interface .+ -> Continue.Record
11
+ ^interface ${INTERFACE}$$
@@ -0,0 +1,8 @@
1
+ Value Required LOCAL_INTERFACE (\S+)
2
+ Value NEIGHBOR_NAME (\S+)
3
+ Value Required CHASSIS_ID (\S+)
4
+ Value Required NEIGHBOR_INTERFACE (Port\s+\d+|\S+)
5
+
6
+ Start
7
+ ^\s*display lldp neighbor-information list\s*$$
8
+ ^\s*${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_INTERFACE}\s+${NEIGHBOR_NAME}\s*$$ -> Record
@@ -0,0 +1,8 @@
1
+ Value Required LOCAL_INTERFACE (\S+)
2
+ Value NEIGHBOR_NAME (\S+)
3
+ Value Required CHASSIS_ID (\S+)
4
+ Value Required NEIGHBOR_INTERFACE (Port\s+\d+|\S+)
5
+
6
+ Start
7
+ ^\s*display lldp neighbor-information list\s*$$
8
+ ^\s*${NEIGHBOR_NAME}\s+${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_INTERFACE}\s*$$ -> Record
@@ -0,0 +1,11 @@
1
+ Value Required INTERFACE (\S+)
2
+ Value DESCRIPTION (.*)
3
+
4
+ Start
5
+ ^interface ${INTERFACE}$$ -> Interface
6
+
7
+ Interface
8
+ ^\s+description ${DESCRIPTION}$$
9
+ ^\s*exit\s*$$ -> Record Start
10
+ ^interface .+ -> Continue.Record
11
+ ^interface ${INTERFACE}$$
@@ -0,0 +1,7 @@
1
+ Value Required LOCAL_INTERFACE (\S+)
2
+ Value NEIGHBOR_NAME (\S+)
3
+ Value Required CHASSIS_ID (\S+)
4
+ Value Required NEIGHBOR_INTERFACE (\S+)
5
+
6
+ Start
7
+ ^\s*${NEIGHBOR_NAME}\s+${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_INTERFACE}\s*$$ -> Record
@@ -0,0 +1,8 @@
1
+ Template, Vendor, Command
2
+ cisco_ios_show_ip_interface_brief.textfsm, cisco_ios, show ip interface brief$
3
+ cisco_cdp_neighbors_detail.textfsm, cisco_ios, show cdp neighbors detail$
4
+ cisco_cdp_neighbors_detail.textfsm, cisco_nxos, show cdp neighbors detail$
5
+ h3c_lldp_name_first.textfsm, h3c, display lldp neighbor-information list$
6
+ h3c_lldp_name_first.textfsm, h3c_wireless, display lldp neighbor-information list$
7
+ hillstone_lldp_neighbors.textfsm, hillstone, show lldp neighbor-information$
8
+ palo_alto_lldp_neighbors.textfsm, palo_alto, show lldp neighbors all$
@@ -0,0 +1,6 @@
1
+ Value Required INTERFACE (\S+)
2
+ Value DESCRIPTION (.*)
3
+
4
+ Start
5
+ ^set network interface ethernet ${INTERFACE} comment ${DESCRIPTION}$$ -> Record
6
+ ^set network interface ethernet ${INTERFACE}\s+.*$$ -> Record
@@ -0,0 +1,11 @@
1
+ Value Required LOCAL_INTERFACE (\S+)
2
+ Value NEIGHBOR_NAME (\S+)
3
+ Value CHASSIS_ID (\S+)
4
+ Value Required NEIGHBOR_INTERFACE (\S+)
5
+
6
+ Start
7
+ ^\s*Local information:\s*$$ -> Record
8
+ ^\s*Local interface:\s*${LOCAL_INTERFACE}\s*$$
9
+ ^\s*Chassis ID:\s*${CHASSIS_ID}\s*$$
10
+ ^\s*Port ID:\s*${NEIGHBOR_INTERFACE}\s*$$
11
+ ^\s*System name:\s*${NEIGHBOR_NAME}\s*$$
@@ -0,0 +1,13 @@
1
+ Value Required INTERFACE (\d+)
2
+ Value DESCRIPTION (.*)
3
+
4
+ Start
5
+ ^/(?:c|cfg)/port ${INTERFACE}/name "${DESCRIPTION}"\s*$$ -> Record
6
+ ^/(?:c|cfg)/port ${INTERFACE}\s*$$ -> Port
7
+
8
+ Port
9
+ ^\s+name "${DESCRIPTION}"\s*$$
10
+ ^\s+name ${DESCRIPTION}$$
11
+ ^/.* -> Continue.Record
12
+ ^/(?:c|cfg)/port ${INTERFACE}/name "${DESCRIPTION}"\s*$$ -> Record Start
13
+ ^/(?:c|cfg)/port ${INTERFACE}\s*$$