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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- 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,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
|