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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ # 使用时从环境中读取配置,并提供明确的默认值。
10
+ class Settings
11
+ DEFAULT_BACKUP_DIRECTORY = "backups"
12
+ DEFAULT_CONCURRENCY = 4
13
+ MAX_CONCURRENCY = 50
14
+ DEFAULT_PAGE_SIZE = 500
15
+
16
+ # 加载 YAML 配置,并使环境变量覆盖文件配置。
17
+ def self.from_file(path, env: ENV)
18
+ new(env: ConfigFile.load(path).merge(env.to_h))
19
+ end
20
+
21
+ # 保存当前批次读取配置所用的环境映射。
22
+ def initialize(env: ENV)
23
+ @env = env
24
+ end
25
+
26
+ # 使用 Netdisco 地址和认证信息创建客户端。
27
+ def client
28
+ options = { url: required("NETDISCO_URL"), page_size: integer("NETDISCO_PAGE_SIZE", DEFAULT_PAGE_SIZE) }
29
+ if present?(@env["NETDISCO_API_KEY"])
30
+ options[:api_key] = @env["NETDISCO_API_KEY"]
31
+ else
32
+ options[:username] = required("NETDISCO_USERNAME")
33
+ options[:password] = required("NETDISCO_PASSWORD")
34
+ end
35
+ Client.new(**options)
36
+ end
37
+
38
+ # 读取清单筛选和厂商映射规则。
39
+ def rules
40
+ Rules.new(include_hosts: list("NET_CONNECTOR_INCLUDE_HOSTS"),
41
+ exclude_hosts: list("NET_CONNECTOR_EXCLUDE_HOSTS"),
42
+ include_vendors: list("NET_CONNECTOR_INCLUDE_VENDORS"),
43
+ vendor_overrides: json("NET_CONNECTOR_VENDOR_OVERRIDES", {}),
44
+ host_overrides: json("NET_CONNECTOR_HOST_OVERRIDES", {}),
45
+ mappings: json("NET_CONNECTOR_DEVICE_RULES", []))
46
+ end
47
+
48
+ # 读取并展开本地备份目录。
49
+ def backup_directory
50
+ path = @env.fetch("NET_CONNECTOR_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
51
+ raise ArgumentError, "NET_CONNECTOR_BACKUP_DIRECTORY must be nonempty" unless present?(path)
52
+
53
+ File.expand_path(path)
54
+ end
55
+
56
+ # 读取并限制设备任务并发数。
57
+ def concurrency
58
+ count = integer("NET_CONNECTOR_CONCURRENCY", DEFAULT_CONCURRENCY)
59
+ raise ArgumentError, "NET_CONNECTOR_CONCURRENCY must be at most #{MAX_CONCURRENCY}" if count > MAX_CONCURRENCY
60
+
61
+ count
62
+ end
63
+
64
+ # 确定厂商采样数量:本地默认全量,TFTP 默认每厂商五台。
65
+ def limit_per_vendor(tftp: false)
66
+ return integer("NET_CONNECTOR_SAMPLE_PER_VENDOR", 5) if @env.key?("NET_CONNECTOR_SAMPLE_PER_VENDOR")
67
+
68
+ 5 if tftp
69
+ end
70
+
71
+ # 读取厂商的 TFTP 源文件配置,并提供华为启动配置默认路径。
72
+ def tftp_source_files
73
+ files = { huawei: @env.fetch("NET_CONNECTOR_HUAWEI_TFTP_SOURCE_FILE", "flash:/startup.cfg") }
74
+ %i[h3c h3c_wireless].each do |vendor|
75
+ key = "NET_CONNECTOR_#{vendor.to_s.upcase}_TFTP_SOURCE_FILE"
76
+ files[vendor] = @env.fetch(key) if @env.key?(key)
77
+ end
78
+ files
79
+ end
80
+
81
+ # 读取各厂商 TFTP 出口 VRF 的名称映射。
82
+ def tftp_vrfs
83
+ raw = json("NET_CONNECTOR_TFTP_VRFS", {})
84
+ raise ArgumentError, "NET_CONNECTOR_TFTP_VRFS must be a JSON object" unless raw.is_a?(Hash)
85
+
86
+ raw.transform_keys(&:to_sym)
87
+ end
88
+
89
+ # 每台设备执行时读取凭据,使新批次可使用轮换后的环境变量。
90
+ # 按厂商优先级读取设备账号与密码。
91
+ def credentials_for(device)
92
+ prefix = "NET_CONNECTOR_#{device.vendor.to_s.upcase}_"
93
+ username = value(prefix + "USERNAME") || value("NET_CONNECTOR_DEVICE_USERNAME")
94
+ return nil unless username
95
+
96
+ {
97
+ username: username,
98
+ password: value(prefix + "PASSWORD") || value("NET_CONNECTOR_DEVICE_PASSWORD"),
99
+ enable_password: value(prefix + "ENABLE_PASSWORD") || value("NET_CONNECTOR_ENABLE_PASSWORD"),
100
+ protocol: (value(prefix + "PROTOCOL") || value("NET_CONNECTOR_PROTOCOL") || "ssh").to_sym,
101
+ log_level: (value("NET_CONNECTOR_LOG_LEVEL") || "info").to_sym,
102
+ known_hosts: value("NET_CONNECTOR_KNOWN_HOSTS"),
103
+ host_key_policy: (value("NET_CONNECTOR_HOST_KEY_POLICY") || "strict").to_sym
104
+ }
105
+ end
106
+
107
+ # 读取并展开设备日志目录。
108
+ def log_directory
109
+ path = value("NET_CONNECTOR_LOG_DIRECTORY")
110
+ File.expand_path(path) if path
111
+ end
112
+
113
+ # 仅展示非敏感配置,不输出设备凭据或 API 密钥。
114
+ # 生成不包含凭据的可展示配置。
115
+ def public_config
116
+ rules
117
+ url = value("NETDISCO_URL")
118
+ if url
119
+ uri = URI.parse(url)
120
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
121
+ raise ArgumentError, "NETDISCO_URL must be a base URL without credentials or query"
122
+ end
123
+ end
124
+ {
125
+ netdisco: { url: url, page_size: integer("NETDISCO_PAGE_SIZE", DEFAULT_PAGE_SIZE) },
126
+ backup: { directory: backup_directory, concurrency: concurrency,
127
+ limit_per_vendor: limit_per_vendor },
128
+ inventory: { include_hosts: list("NET_CONNECTOR_INCLUDE_HOSTS"),
129
+ exclude_hosts: list("NET_CONNECTOR_EXCLUDE_HOSTS"),
130
+ include_vendors: list("NET_CONNECTOR_INCLUDE_VENDORS"),
131
+ vendor_overrides: json("NET_CONNECTOR_VENDOR_OVERRIDES", {}),
132
+ host_overrides: json("NET_CONNECTOR_HOST_OVERRIDES", {}),
133
+ device_rules: json("NET_CONNECTOR_DEVICE_RULES", []) },
134
+ ssh: { protocol: value("NET_CONNECTOR_PROTOCOL") || "ssh",
135
+ known_hosts: value("NET_CONNECTOR_KNOWN_HOSTS"),
136
+ host_key_policy: value("NET_CONNECTOR_HOST_KEY_POLICY") || "strict",
137
+ log_directory: log_directory,
138
+ log_level: value("NET_CONNECTOR_LOG_LEVEL") || "info" },
139
+ tftp: { server: value("TFTP_HOST"), vrfs: tftp_vrfs.transform_keys(&:to_s) }
140
+ }
141
+ rescue URI::InvalidURIError
142
+ raise ArgumentError, "NETDISCO_URL is invalid"
143
+ end
144
+
145
+ private
146
+
147
+ # 解析指定环境变量中的 JSON 配置。
148
+ def json(key, default)
149
+ raw = value(key)
150
+ raw ? JSON.parse(raw) : default
151
+ rescue JSON::ParserError
152
+ raise ArgumentError, "#{key} must be valid JSON"
153
+ end
154
+
155
+ # 解析逗号分隔的环境变量列表。
156
+ def list(key) = @env.fetch(key, "").split(",").map(&:strip).reject(&:empty?)
157
+
158
+ # 读取正整数配置并统一错误信息。
159
+ def integer(key, default)
160
+ raw = @env.fetch(key, default)
161
+ value = Integer(raw.to_s, 10)
162
+ raise ArgumentError, "#{key} must be a positive integer" unless value.positive?
163
+
164
+ value
165
+ rescue ArgumentError, TypeError
166
+ raise ArgumentError, "#{key} must be a positive integer"
167
+ end
168
+
169
+ # 读取非空字符串配置。
170
+ def value(key)
171
+ result = @env[key]
172
+ result if present?(result)
173
+ end
174
+
175
+ # 读取必填环境变量。
176
+ def required(key) = value(key) || (raise ArgumentError, "#{key} is required")
177
+
178
+ # 判断环境变量是否为非空字符串。
179
+ def present?(value) = value.is_a?(String) && !value.strip.empty?
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Netdisco
6
+ # 以固定并发上限执行相互隔离的设备任务。
7
+ class Worker
8
+ # 校验并保存最大设备并发数。
9
+ def initialize(concurrency:)
10
+ unless concurrency.is_a?(Integer) && (1..Settings::MAX_CONCURRENCY).cover?(concurrency)
11
+ raise ArgumentError, "concurrency must be an Integer in 1..#{Settings::MAX_CONCURRENCY}"
12
+ end
13
+
14
+ @concurrency = concurrency
15
+ end
16
+
17
+ # 按清单顺序收集结果,并隔离设备及回调异常。
18
+ def run(tasks, outcomes:, on_error:, on_start: nil, on_result: nil)
19
+ queue = Queue.new
20
+ tasks.each { |task| queue << task }
21
+ worker_count = [tasks.size, @concurrency].min
22
+ worker_count.times { queue << nil }
23
+ callback_errors = Queue.new
24
+ threads = Array.new(worker_count) do
25
+ Thread.new do
26
+ Thread.current.report_on_exception = false
27
+ while (task = queue.pop)
28
+ index, device = task
29
+ started_at = Time.now.utc
30
+ begin
31
+ on_start&.call(device)
32
+ rescue StandardError => error
33
+ callback_errors << { host: device.host, error_type: error.class.name }.freeze
34
+ end
35
+ result = begin
36
+ yield device
37
+ rescue StandardError => error
38
+ on_error.call(device, error)
39
+ end
40
+ result = result.with(started_at: started_at, finished_at: Time.now.utc)
41
+ outcomes[index] = result
42
+ Array(on_result).each do |callback|
43
+ begin
44
+ callback.call(result)
45
+ rescue StandardError => error
46
+ callback_errors << { host: device.host, error_type: error.class.name }.freeze
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ completed = false
53
+ begin
54
+ threads.each(&:value)
55
+ completed = true
56
+ ensure
57
+ # 中断或某个 worker 抛出非 StandardError 时,不让其他设备任务
58
+ # 在调用方已经退出后继续运行;Thread#kill 会执行任务自身的 ensure。
59
+ unless completed
60
+ threads.each(&:kill)
61
+ threads.each do |thread|
62
+ begin
63
+ thread.join
64
+ rescue Exception # rubocop:disable Lint/RescueException -- Preserve the original interruption after all workers stop.
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+ Array.new(callback_errors.size) { callback_errors.pop }.freeze
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../connector"
4
+ require_relative "netdisco/client"
5
+ require_relative "netdisco/rules"
6
+ require_relative "netdisco/device"
7
+ require_relative "netdisco/config_file"
8
+ require_relative "netdisco/settings"
9
+ require_relative "netdisco/worker"
10
+ require_relative "netdisco/result_store"
11
+ require_relative "netdisco/batch"
12
+ require_relative "netdisco/planner"
13
+ require_relative "netdisco/fleet"
14
+ require_relative "netdisco/cli"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "private_file"
5
+
6
+ module Net
7
+ module Connector
8
+ Backup = Data.define(:path, :bytes, :sha256, :collected_at, :change, :previous_sha256) do
9
+ # 保存配置备份的路径、摘要和变更状态。
10
+ def initialize(path:, bytes:, sha256:, collected_at:, change: nil, previous_sha256: nil)
11
+ super
12
+ end
13
+
14
+ # 判断本次采集是否产生新的配置内容。
15
+ def changed? = [:created, :changed].include?(change)
16
+ end
17
+
18
+ module Operations
19
+ class LocalBackup
20
+ # 保存执行配置采集的设备对象。
21
+ def initialize(device) = @device = device
22
+
23
+ # 采集配置、识别变更并原子写入私有文件。
24
+ def call(path:)
25
+ raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
26
+
27
+ contents = @device.running_config.value!
28
+ destination = File.expand_path(path)
29
+ digest = Digest::SHA256.hexdigest(contents)
30
+ previous_digest = Digest::SHA256.file(destination).hexdigest if File.file?(destination) && !File.symlink?(destination)
31
+ change = if previous_digest.nil?
32
+ :created
33
+ elsif previous_digest == digest
34
+ :unchanged
35
+ else
36
+ :changed
37
+ end
38
+
39
+ if change != :unchanged || (File.stat(destination).mode & 0o777) != 0o600
40
+ PrivateFile.write(destination, contents)
41
+ end
42
+ Backup.new(path: destination, bytes: contents.bytesize,
43
+ sha256: digest, collected_at: Time.now.utc, change: change,
44
+ previous_sha256: previous_digest)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
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
+ # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
25
+ def call(text, template: nil, vendor: nil, command: nil, host: nil)
26
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
27
+
28
+ text = TerminalRenderer.render(text).force_encoding(Encoding::UTF_8)
29
+ if template
30
+ raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
31
+
32
+ TextFSM::Parser.from_file(File.expand_path(template, @template_dir)).parse_hashes(text)
33
+ else
34
+ raise ArgumentError, "vendor and command are required for template lookup" unless vendor && command.is_a?(String) && !command.empty?
35
+
36
+ attributes = { "Vendor" => vendor.to_s, "Command" => command }
37
+ table = TextFSM::CliTable.new(index: @index, template_dir: @template_dir)
38
+ unless table.index.match(attributes)
39
+ raise ParsingError.new("no TextFSM template matches this vendor and command",
40
+ code: :template_missing, host: host, phase: :parse)
41
+ end
42
+ table.parse(text, attributes: attributes).to_hashes
43
+ end
44
+ rescue TextFSM::IndexError
45
+ raise ParsingError.new("TextFSM template index is invalid",
46
+ code: :template_invalid, host: host, phase: :parse), cause: nil
47
+ rescue TextFSM::TemplateError
48
+ raise ParsingError.new("TextFSM template is invalid",
49
+ code: :template_invalid, host: host, phase: :parse), cause: nil
50
+ rescue TextFSM::ParseError
51
+ raise ParsingError.new("TextFSM could not parse the device output",
52
+ code: :parse_failed, host: host, phase: :parse), cause: nil
53
+ rescue IOError, SystemCallError
54
+ raise ParsingError.new("unable to read TextFSM template",
55
+ code: :template_unreadable, host: host, phase: :parse), cause: nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ # 将配置写入同目录的私有临时文件,再原子替换目标文件。
9
+ module PrivateFile
10
+ # 写入完整内容并同步到磁盘,避免覆盖失败留下半份配置。
11
+ def self.write(path, contents)
12
+ Tempfile.create([".net-connector-", ".tmp"], File.dirname(path)) do |file|
13
+ file.binmode
14
+ file.chmod(0o600)
15
+ file.write(contents)
16
+ file.flush
17
+ file.fsync
18
+ File.rename(file.path, path)
19
+ end
20
+ path
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../running_config"
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ipaddr"
5
+ require_relative "private_file"
6
+ require_relative "parse_output"
7
+
8
+ module Net
9
+ module Connector
10
+ module Operations
11
+ # 直接导出已有本地配置,无需访问 Netdisco。
12
+ class SavedConfig
13
+ # 只用规范化的管理地址标识配置,避免设备改名改变备份身份。
14
+ def self.filename(host)
15
+ raise ArgumentError, "host must be an IP address" unless host.is_a?(String) && !host.include?("/")
16
+
17
+ "#{IPAddr.new(host).to_s.tr(":", "_")}.txt"
18
+ rescue IPAddr::Error
19
+ raise ArgumentError, "host must be an IP address"
20
+ end
21
+
22
+ # 保存已有配置文件所在目录。
23
+ def initialize(directory:)
24
+ raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
25
+
26
+ @directory = File.expand_path(directory)
27
+ end
28
+
29
+ # 优先查找固定文件名;仅在不存在时兼容唯一的旧命名文件。
30
+ def find(host, required: true)
31
+ filename = self.class.filename(host)
32
+ path = File.join(@directory, filename)
33
+ unless File.exist?(path) || File.symlink?(path)
34
+ entries = Dir.children(@directory).select { |name| name.end_with?("-#{filename}") }
35
+ return nil if entries.empty? && !required
36
+
37
+ raise ArgumentError, "no saved configuration for #{host}" if entries.empty?
38
+ raise ArgumentError, "multiple saved configurations for #{host}" if entries.size > 1
39
+
40
+ path = File.join(@directory, entries.first)
41
+ end
42
+ raise ArgumentError, "saved configuration is not a regular file" unless File.file?(path) && !File.symlink?(path)
43
+
44
+ path
45
+ end
46
+
47
+ # 将已保存配置写入目标文件或标准输出。
48
+ def export(host:, output: nil, io: $stdout)
49
+ contents = File.binread(find(host))
50
+ if output
51
+ raise ArgumentError, "output must be a nonempty String" unless output.is_a?(String) && !output.empty?
52
+
53
+ destination = File.expand_path(output)
54
+ directory = File.dirname(destination)
55
+ FileUtils.mkdir_p(directory, mode: 0o700)
56
+ PrivateFile.write(destination, contents)
57
+ else
58
+ io.binmode if io.respond_to?(:binmode)
59
+ io.write(contents)
60
+ nil
61
+ end
62
+ end
63
+
64
+ # 使用指定模板解析已有配置,无需访问设备或 Netdisco。
65
+ def parse(host:, template:, template_dir: nil)
66
+ parser = ParseOutput.new(template_dir: template_dir)
67
+ parser.call(File.binread(find(host)), template: template, host: host)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,11 @@
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