net-connector 0.7.3 → 0.9.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 +4 -4
- data/CHANGELOG.md +21 -1
- data/CONTRIBUTING.md +12 -0
- data/README.md +27 -424
- data/README.zh-CN.md +64 -0
- data/{exe/net-connector-backup → bin/net-backup} +1 -0
- data/docs/VERIFICATION.md +2 -2
- data/docs/architecture.md +27 -5
- data/docs/netdisco.md +176 -0
- data/docs/netdisco.zh-CN.md +247 -0
- data/docs/usage.md +124 -0
- data/docs/usage.zh-CN.md +190 -0
- data/examples/backup.yml +5 -4
- data/examples/inventory_sql.yml +5 -1
- data/lib/net/connector/device/base.rb +12 -5
- data/lib/net/connector/device/local_backup.rb +3 -0
- data/lib/net/connector/device/running_config/strategy.rb +2 -1
- data/lib/net/connector/device/running_config.rb +2 -0
- data/lib/net/connector/device/tftp/receipt.rb +1 -0
- data/lib/net/connector/device/tftp/strategy.rb +28 -9
- data/lib/net/connector/device/tftp.rb +23 -16
- data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
- data/lib/net/connector/engine/authentication.rb +1 -0
- data/lib/net/connector/engine/core.rb +1 -0
- data/lib/net/connector/engine/dialogue.rb +5 -134
- data/lib/net/connector/engine/errors.rb +0 -92
- data/lib/net/connector/engine/known_hosts.rb +20 -4
- data/lib/net/connector/engine/log.rb +27 -6
- data/lib/net/connector/engine/recovery.rb +1 -1
- data/lib/net/connector/engine/redactor.rb +98 -0
- data/lib/net/connector/engine/response_reader.rb +139 -0
- data/lib/net/connector/engine/session.rb +20 -7
- data/lib/net/connector/engine/transport.rb +21 -10
- data/lib/net/connector/netdisco/backup_run.rb +35 -8
- data/lib/net/connector/netdisco/cli/options.rb +20 -8
- data/lib/net/connector/netdisco/cli.rb +14 -11
- data/lib/net/connector/netdisco/config_file.rb +19 -14
- data/lib/net/connector/netdisco/connection.rb +0 -1
- data/lib/net/connector/netdisco/database_client.rb +44 -26
- data/lib/net/connector/netdisco/device.rb +5 -0
- data/lib/net/connector/netdisco/diagnostic.rb +8 -0
- data/lib/net/connector/netdisco/fleet.rb +12 -3
- data/lib/net/connector/netdisco/plan.rb +16 -3
- data/lib/net/connector/netdisco/planner.rb +2 -1
- data/lib/net/connector/netdisco/progress.rb +31 -7
- data/lib/net/connector/netdisco/report/files.rb +4 -2
- data/lib/net/connector/netdisco/result_store.rb +6 -7
- data/lib/net/connector/netdisco/rules.rb +8 -5
- data/lib/net/connector/netdisco/settings.rb +83 -19
- data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
- data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
- data/lib/net/connector/netdisco/worker.rb +3 -1
- data/lib/net/connector/netdisco.rb +1 -21
- data/lib/net/connector/storage/private_file.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +2 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
- data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
- data/lib/net/connector/vendor/radware/running_config.rb +1 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
- data/lib/net/connector/vendor/radware/topology.rb +1 -0
- data/lib/net/connector/version.rb +2 -1
- data/lib/net/connector.rb +1 -0
- data/lib/net-connector.rb +1 -0
- metadata +12 -20
- data/lib/net/connector/netdisco/tftp_history.rb +0 -16
|
@@ -8,21 +8,20 @@ require_relative "../storage/private_file"
|
|
|
8
8
|
module Net
|
|
9
9
|
module Connector
|
|
10
10
|
module Netdisco
|
|
11
|
+
# 调用方选择结果持久化方式;所有写入器接收同一份 Report,不再次执行设备任务。
|
|
11
12
|
module ResultStore
|
|
12
13
|
# 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
|
|
13
14
|
class Json
|
|
14
15
|
# 以临时文件和重命名原子写入私有 JSON 报告。
|
|
15
|
-
def write(
|
|
16
|
+
def write(report, directory:)
|
|
16
17
|
destination = File.expand_path(directory)
|
|
17
18
|
FileUtils.mkdir_p(destination, mode: 0o700)
|
|
18
|
-
filename = "netdisco-#{
|
|
19
|
+
filename = "netdisco-#{report.mode}-#{report.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
|
|
19
20
|
path = File.join(destination, filename)
|
|
20
|
-
Storage::PrivateFile.write(path, JSON.pretty_generate(
|
|
21
|
+
Storage::PrivateFile.write(path, JSON.pretty_generate(report.summary)).path
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
Text = Json # Compatibility with the former name of the JSON writer.
|
|
25
|
-
|
|
26
25
|
# 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
|
|
27
26
|
# 仓储须实现 create!,本项目不负责数据库表结构。
|
|
28
27
|
class Database
|
|
@@ -34,8 +33,8 @@ module Net
|
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
# 把批量摘要写入调用方仓储并返回记录位置。
|
|
37
|
-
def write(
|
|
38
|
-
record = @repository.create!(
|
|
36
|
+
def write(report, **_options)
|
|
37
|
+
record = @repository.create!(report.summary)
|
|
39
38
|
record.respond_to?(:id) ? "database:#{record.id}" : "database"
|
|
40
39
|
end
|
|
41
40
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ipaddr"
|
|
4
|
+
require_relative "../../connector"
|
|
4
5
|
|
|
5
6
|
module Net
|
|
6
7
|
module Connector
|
|
@@ -93,11 +94,13 @@ module Net
|
|
|
93
94
|
|
|
94
95
|
# 根据地址、显式规则和厂商信息识别连接器。
|
|
95
96
|
def resolve(row)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
unless @host_overrides.empty?
|
|
98
|
+
begin
|
|
99
|
+
address = IPAddr.new(row["ip"]).to_s
|
|
100
|
+
return @host_overrides.fetch(address) if @host_overrides.key?(address)
|
|
101
|
+
rescue IPAddr::Error, TypeError
|
|
102
|
+
# 无效的管理地址交由 Device.from_row 标记。
|
|
103
|
+
end
|
|
101
104
|
end
|
|
102
105
|
label = normalize(row["vendor"])
|
|
103
106
|
os = normalize(row["os"])
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../../connector"
|
|
5
|
+
require_relative "client"
|
|
6
|
+
require_relative "database_client"
|
|
7
|
+
require_relative "config_file"
|
|
8
|
+
require_relative "rules"
|
|
9
|
+
require_relative "planner"
|
|
4
10
|
|
|
5
11
|
module Net
|
|
6
12
|
module Connector
|
|
@@ -10,21 +16,24 @@ module Net
|
|
|
10
16
|
DEFAULT_BACKUP_DIRECTORY = "backups"
|
|
11
17
|
DEFAULT_CONCURRENCY = 4
|
|
12
18
|
MAX_CONCURRENCY = 50
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
# 这里只列可从环境读取的公开策略;凭据单独动态读取,其他内部键只供 YAML/CLI 使用。
|
|
20
|
+
ENVIRONMENT_KEYS = %w[ND_URL].freeze
|
|
15
21
|
DEFAULT_PAGE_SIZE = Client::DEFAULTS.fetch(:page_size)
|
|
16
22
|
UNSET = Object.new.freeze
|
|
17
23
|
private_constant :UNSET
|
|
18
24
|
|
|
25
|
+
# 加载无凭据 YAML 作为默认策略,保留传入环境作为动态凭据来源。
|
|
19
26
|
def self.from_file(path, env: ENV)
|
|
20
27
|
new(env: env, defaults: ConfigFile.load(path))
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
# 只有 NC_CONFIG 显式指定时才读取文件,不隐式加载 .env。
|
|
23
31
|
def self.from_env(env: ENV)
|
|
24
32
|
path = env["NC_CONFIG"]
|
|
25
33
|
path && !path.empty? ? from_file(path, env: env) : new(env: env)
|
|
26
34
|
end
|
|
27
35
|
|
|
36
|
+
# 分开保存默认策略、显式覆盖与凭据来源;fixed 实例只承载已复制的公开快照。
|
|
28
37
|
def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
|
|
29
38
|
@env, @defaults, @overrides = env, defaults, overrides
|
|
30
39
|
@policy, @fixed = policy, fixed
|
|
@@ -32,7 +41,14 @@ module Net
|
|
|
32
41
|
|
|
33
42
|
# 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
|
|
34
43
|
def with_overrides(values)
|
|
35
|
-
|
|
44
|
+
if @fixed
|
|
45
|
+
public_values = @env.merge(@overrides).merge(values).slice(*non_secret_keys)
|
|
46
|
+
.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
47
|
+
return self.class.new(env: public_values, fixed: true).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
policy = @policy.with_overrides(values) if @policy
|
|
51
|
+
self.class.new(env: @env, defaults: @defaults, overrides: @overrides.merge(values), policy: policy)
|
|
36
52
|
end
|
|
37
53
|
|
|
38
54
|
# 快照自身不保留 ENV、凭据 resolver 或原 Settings 的引用,不能携带秘密进入摘要。
|
|
@@ -55,6 +71,7 @@ module Net
|
|
|
55
71
|
self.class.new(env: @env, defaults: @defaults, overrides: @overrides, policy: snapshot(mode: mode))
|
|
56
72
|
end
|
|
57
73
|
|
|
74
|
+
# 按业务模式预检所有策略;离线导出仅验证目录,不读取清单或设备凭据。
|
|
58
75
|
def validate!(mode:)
|
|
59
76
|
unless %i[backup tftp inventory show_config export].include?(mode)
|
|
60
77
|
raise ArgumentError, "unknown settings validation mode"
|
|
@@ -67,7 +84,9 @@ module Net
|
|
|
67
84
|
concurrency
|
|
68
85
|
limit_per_vendor
|
|
69
86
|
log_directory
|
|
87
|
+
tftp_root
|
|
70
88
|
connection_options
|
|
89
|
+
database_options
|
|
71
90
|
Net::Connector.vendors.each { |vendor| connection_options(vendor) }
|
|
72
91
|
tftp_source_files.each_value { |source| TftpTarget.validate_source_file!(source) }
|
|
73
92
|
tftp_vrfs
|
|
@@ -75,6 +94,7 @@ module Net
|
|
|
75
94
|
self
|
|
76
95
|
end
|
|
77
96
|
|
|
97
|
+
# 用固定策略创建清单客户端,但每次读取最新身份凭据;显式认证覆盖默认认证来源。
|
|
78
98
|
def client(policy: snapshot(mode: :inventory), credentials: nil)
|
|
79
99
|
if credentials && (!credentials.is_a?(Hash) || credentials.keys.sort != %i[password username] ||
|
|
80
100
|
!credentials.values.all? { |value| present?(value) })
|
|
@@ -82,28 +102,29 @@ module Net
|
|
|
82
102
|
end
|
|
83
103
|
options = policy.client_options
|
|
84
104
|
if policy.inventory_source == :postgres
|
|
85
|
-
connection = DatabaseClient::CONNECTION_ENV.each_with_object(
|
|
105
|
+
connection = DatabaseClient::CONNECTION_ENV.each_with_object(policy.database_options.dup) do |(key, name), values|
|
|
86
106
|
item = secret_value(name)
|
|
87
107
|
values[key] = item if item
|
|
88
108
|
end
|
|
89
109
|
connection.merge!(user: credentials[:username], password: credentials[:password]) if credentials
|
|
90
110
|
return DatabaseClient.new(connection_options: connection, **options)
|
|
91
111
|
end
|
|
92
|
-
raise ArgumentError, "
|
|
112
|
+
raise ArgumentError, "ND_URL is required" unless options.fetch(:url)
|
|
93
113
|
|
|
94
114
|
authentication = if credentials
|
|
95
115
|
credentials
|
|
96
|
-
elsif (token = secret_value("
|
|
116
|
+
elsif (token = secret_value("ND_API_KEY"))
|
|
97
117
|
{ api_key: token }
|
|
98
118
|
else
|
|
99
|
-
{ username: required_secret("
|
|
119
|
+
{ username: required_secret("ND_USERNAME"), password: required_secret("ND_PASSWORD") }
|
|
100
120
|
end
|
|
101
121
|
Client.new(**options, **authentication)
|
|
102
122
|
end
|
|
103
123
|
|
|
124
|
+
# 清单来源来自 YAML/CLI,HTTP 与 PostgreSQL 共用后续计划和执行接口。
|
|
104
125
|
def inventory_source
|
|
105
|
-
source = value("
|
|
106
|
-
raise ArgumentError, "
|
|
126
|
+
source = value("ND_SOURCE") || "http"
|
|
127
|
+
raise ArgumentError, "ND_SOURCE must be http or postgres" unless %w[http postgres].include?(source)
|
|
107
128
|
|
|
108
129
|
source.to_sym
|
|
109
130
|
end
|
|
@@ -115,6 +136,7 @@ module Net
|
|
|
115
136
|
inventory_source == :postgres ? postgres_options(options) : http_options(options)
|
|
116
137
|
end
|
|
117
138
|
|
|
139
|
+
# 将筛选和映射策略交给 Rules 校验,未识别设备由清单结果单独标记。
|
|
118
140
|
def rules
|
|
119
141
|
Rules.new(include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
120
142
|
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
@@ -124,6 +146,7 @@ module Net
|
|
|
124
146
|
mappings: json("NC_DEVICE_RULES", []))
|
|
125
147
|
end
|
|
126
148
|
|
|
149
|
+
# 将备份目录解析为绝对路径;这里只校验配置,不创建目录。
|
|
127
150
|
def backup_directory
|
|
128
151
|
path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
|
|
129
152
|
raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
|
|
@@ -131,6 +154,7 @@ module Net
|
|
|
131
154
|
File.expand_path(path)
|
|
132
155
|
end
|
|
133
156
|
|
|
157
|
+
# 与 Worker 共用并发上限,非法值在任务与目录创建前失败。
|
|
134
158
|
def concurrency
|
|
135
159
|
count = integer("NC_CONCURRENCY", DEFAULT_CONCURRENCY)
|
|
136
160
|
raise ArgumentError, "NC_CONCURRENCY must be at most #{MAX_CONCURRENCY}" if count > MAX_CONCURRENCY
|
|
@@ -138,6 +162,7 @@ module Net
|
|
|
138
162
|
count
|
|
139
163
|
end
|
|
140
164
|
|
|
165
|
+
# 显式 nil 表示全量;tftp: true 且未设置时采用五台上限,示例入口另外选择全量。
|
|
141
166
|
def limit_per_vendor(tftp: false)
|
|
142
167
|
count = if configured?("NC_SAMPLE_PER_VENDOR")
|
|
143
168
|
integer("NC_SAMPLE_PER_VENDOR", 5) unless raw("NC_SAMPLE_PER_VENDOR").nil?
|
|
@@ -148,6 +173,7 @@ module Net
|
|
|
148
173
|
count
|
|
149
174
|
end
|
|
150
175
|
|
|
176
|
+
# 华为批次默认上传 startup.cfg;H3C 省略源文件时由设备策略探测。
|
|
151
177
|
def tftp_source_files
|
|
152
178
|
files = { huawei: raw("NC_HUAWEI_TFTP_SOURCE_FILE", "flash:/startup.cfg") }
|
|
153
179
|
%i[h3c h3c_wireless].each do |vendor|
|
|
@@ -157,6 +183,7 @@ module Net
|
|
|
157
183
|
files
|
|
158
184
|
end
|
|
159
185
|
|
|
186
|
+
# 按厂商读取 VRF 映射,不能把不支持 VRF 的厂商静默忽略。
|
|
160
187
|
def tftp_vrfs
|
|
161
188
|
mapping = json("NC_TFTP_VRFS", {})
|
|
162
189
|
raise ArgumentError, "NC_TFTP_VRFS must be a JSON object" unless mapping.is_a?(Hash)
|
|
@@ -164,6 +191,7 @@ module Net
|
|
|
164
191
|
self.class.validate_vrfs!(mapping.transform_keys(&:to_sym))
|
|
165
192
|
end
|
|
166
193
|
|
|
194
|
+
# 只允许 NX-OS、山石的安全单一 VRF 名称,禁止命令拼接字符。
|
|
167
195
|
def self.validate_vrfs!(vrfs)
|
|
168
196
|
unless vrfs.is_a?(Hash) && vrfs.all? { |vendor, name|
|
|
169
197
|
%i[cisco_nxos hillstone].include?(vendor) && name.is_a?(String) &&
|
|
@@ -174,8 +202,32 @@ module Net
|
|
|
174
202
|
vrfs
|
|
175
203
|
end
|
|
176
204
|
|
|
177
|
-
|
|
205
|
+
# 服务器地址只来自 YAML/CLI 的公开策略,不从 NC_TFTP_HOST 环境变量读取。
|
|
206
|
+
def tftp_server = value("NC_TFTP_HOST")
|
|
207
|
+
|
|
208
|
+
# 本机核验根目录属于策略快照,解析路径不证明文件已经上传。
|
|
209
|
+
def tftp_root
|
|
210
|
+
path = value("NC_TFTP_ROOT")
|
|
211
|
+
File.expand_path(path) if path
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# 端口、TLS 与连接超时进入固定策略;主机、数据库名和登录凭据仍从环境读取。
|
|
215
|
+
def database_options
|
|
216
|
+
options = {}
|
|
217
|
+
%i[port connect_timeout].each do |name|
|
|
218
|
+
key = "ND_DB_#{name.to_s.upcase}"
|
|
219
|
+
next unless configured?(key)
|
|
178
220
|
|
|
221
|
+
options[name] = integer(key, nil)
|
|
222
|
+
end
|
|
223
|
+
%i[sslmode sslrootcert].each do |name|
|
|
224
|
+
item = value("ND_DB_#{name.to_s.upcase}")
|
|
225
|
+
options[name] = item if item
|
|
226
|
+
end
|
|
227
|
+
DatabaseClient.connection_policy(**options)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# 经 Configuration 复用连接校验;厂商协议覆盖通用协议,不包含设备凭据。
|
|
179
231
|
def connection_options(vendor = nil)
|
|
180
232
|
configuration = Configuration.new(
|
|
181
233
|
protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
|
|
@@ -188,6 +240,7 @@ module Net
|
|
|
188
240
|
options.freeze
|
|
189
241
|
end
|
|
190
242
|
|
|
243
|
+
# 厂商协议必须使用已登记的厂商键和 ssh/telnet,不接受未知映射。
|
|
191
244
|
def vendor_protocols
|
|
192
245
|
values = json("NC_VENDOR_PROTOCOLS", {})
|
|
193
246
|
unless values.is_a?(Hash) && values.all? { |vendor, protocol| Net::Connector.vendors.map(&:to_s).include?(vendor) && %w[ssh telnet].include?(protocol) }
|
|
@@ -196,6 +249,7 @@ module Net
|
|
|
196
249
|
values
|
|
197
250
|
end
|
|
198
251
|
|
|
252
|
+
# 每台任务读取最新厂商凭据,缺失字段回退到通用凭据;无用户名时跳过设备。
|
|
199
253
|
def device_credentials_for(device)
|
|
200
254
|
prefix = "NC_#{device.vendor.to_s.upcase}_"
|
|
201
255
|
username = secret_value(prefix + "USERNAME") || secret_value("NC_DEVICE_USERNAME")
|
|
@@ -206,11 +260,13 @@ module Net
|
|
|
206
260
|
enable_password: secret_value(prefix + "ENABLE_PASSWORD") || secret_value("NC_ENABLE_PASSWORD") }
|
|
207
261
|
end
|
|
208
262
|
|
|
263
|
+
# 会话日志目录属于公开策略;调用方和批次入口负责实际创建。
|
|
209
264
|
def log_directory
|
|
210
265
|
path = value("NC_LOG_DIRECTORY")
|
|
211
266
|
File.expand_path(path) if path
|
|
212
267
|
end
|
|
213
268
|
|
|
269
|
+
# 生成已验证的无凭据配置摘要,不展开原环境或 resolver。
|
|
214
270
|
def public_config
|
|
215
271
|
snapshot(mode: :show_config).config_hash
|
|
216
272
|
end
|
|
@@ -219,9 +275,10 @@ module Net
|
|
|
219
275
|
|
|
220
276
|
protected
|
|
221
277
|
|
|
278
|
+
# 只输出公开策略;数据库身份和设备凭据不得通过配置摘要进入报告。
|
|
222
279
|
def config_hash
|
|
223
280
|
{
|
|
224
|
-
netdisco: client_options.merge(source: inventory_source),
|
|
281
|
+
netdisco: client_options.merge(source: inventory_source, database: database_options),
|
|
225
282
|
backup: { directory: backup_directory, concurrency: concurrency, limit_per_vendor: limit_per_vendor },
|
|
226
283
|
inventory: { include_hosts: list("NC_INCLUDE_HOSTS"),
|
|
227
284
|
exclude_hosts: list("NC_EXCLUDE_HOSTS"),
|
|
@@ -233,10 +290,11 @@ module Net
|
|
|
233
290
|
log_directory: log_directory,
|
|
234
291
|
vendor_protocols: vendor_protocols
|
|
235
292
|
),
|
|
236
|
-
tftp: { server: tftp_server, vrfs: tftp_vrfs.transform_keys(&:to_s), source_files: tftp_source_files }
|
|
293
|
+
tftp: { server: tftp_server, verification_root: tftp_root, vrfs: tftp_vrfs.transform_keys(&:to_s), source_files: tftp_source_files }
|
|
237
294
|
}
|
|
238
295
|
end
|
|
239
296
|
|
|
297
|
+
# 优先读取固定策略或显式覆盖;内部键的名称不表示该键可由进程环境配置。
|
|
240
298
|
def raw(key, default = nil)
|
|
241
299
|
return @policy.raw(key, default) if @policy
|
|
242
300
|
return @overrides.fetch(key) if @overrides.key?(key)
|
|
@@ -254,7 +312,7 @@ module Net
|
|
|
254
312
|
|
|
255
313
|
def client_budget_options(defaults)
|
|
256
314
|
defaults.to_h do |name, default|
|
|
257
|
-
key = "
|
|
315
|
+
key = "ND_#{name.to_s.upcase}"
|
|
258
316
|
parsed = case name
|
|
259
317
|
when :inventory_timeout then number(key, default)
|
|
260
318
|
when :allow_insecure_http then boolean(key, default)
|
|
@@ -265,16 +323,16 @@ module Net
|
|
|
265
323
|
end
|
|
266
324
|
|
|
267
325
|
def postgres_options(options)
|
|
268
|
-
query = value("
|
|
269
|
-
DatabaseClient.options(query: query, query_params: json("
|
|
326
|
+
query = value("ND_QUERY") || (raise ArgumentError, "ND_QUERY is required for postgres")
|
|
327
|
+
DatabaseClient.options(query: query, query_params: json("ND_QUERY_PARAMS", []), **options)
|
|
270
328
|
end
|
|
271
329
|
|
|
272
330
|
def http_options(options)
|
|
273
|
-
if value("
|
|
274
|
-
raise ArgumentError, "SQL query settings require
|
|
331
|
+
if value("ND_QUERY") || value("ND_QUERY_PARAMS")
|
|
332
|
+
raise ArgumentError, "SQL query settings require ND_SOURCE=postgres"
|
|
275
333
|
end
|
|
276
334
|
options = Client.options(**options)
|
|
277
|
-
url = value("
|
|
335
|
+
url = value("ND_URL")
|
|
278
336
|
Client.validate_url!(url, allow_insecure_http: options.fetch(:allow_insecure_http)) if url
|
|
279
337
|
options.merge(url: url).freeze
|
|
280
338
|
end
|
|
@@ -283,11 +341,16 @@ module Net
|
|
|
283
341
|
ConfigFile::FIELDS.values.flat_map { |section| section.values.map(&:first) }
|
|
284
342
|
end
|
|
285
343
|
|
|
344
|
+
# 旧前缀和已移除策略明确报错;错误只包含变量名,不泄露变量值。
|
|
286
345
|
def validate_environment!
|
|
287
346
|
legacy = @env.keys.find { |key| key.start_with?("NET_CONNECTOR_") }
|
|
288
|
-
raise ArgumentError, "#{legacy}
|
|
347
|
+
raise ArgumentError, "#{legacy} is unsupported; use NC_ credentials or YAML via NC_CONFIG" if legacy
|
|
348
|
+
|
|
349
|
+
legacy = @env.keys.find { |key| key.start_with?("NETDISCO_", "TFTP_") }
|
|
350
|
+
raise ArgumentError, "#{legacy} is unsupported; use ND_ credentials or YAML via NC_CONFIG" if legacy
|
|
289
351
|
|
|
290
352
|
removed = non_secret_keys - ENVIRONMENT_KEYS
|
|
353
|
+
removed << "NC_PROGRESS"
|
|
291
354
|
removed += Net::Connector.vendors.map { |vendor| "NC_#{vendor.to_s.upcase}_PROTOCOL" }
|
|
292
355
|
key = removed.find { |name| @env.key?(name) }
|
|
293
356
|
raise ArgumentError, "#{key} is no longer an environment setting; use YAML via NC_CONFIG or CLI options" if key
|
|
@@ -343,6 +406,7 @@ module Net
|
|
|
343
406
|
result if present?(result)
|
|
344
407
|
end
|
|
345
408
|
|
|
409
|
+
# 凭据只从活的环境映射读取并复制,不允许 defaults/overrides 充当秘密来源。
|
|
346
410
|
def secret_value(key)
|
|
347
411
|
result = @env[key]
|
|
348
412
|
raise ArgumentError, "#{key} must be a String" unless result.nil? || result.is_a?(String)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "tempfile"
|
|
5
|
+
require "digest"
|
|
5
6
|
require_relative "../storage/backup_lock"
|
|
6
7
|
require_relative "../storage/private_file"
|
|
7
8
|
require_relative "../storage/safe_file"
|
|
8
9
|
require_relative "tftp_verification"
|
|
10
|
+
require_relative "diagnostic"
|
|
9
11
|
|
|
10
12
|
module Net
|
|
11
13
|
module Connector
|
|
@@ -15,6 +17,7 @@ module Net
|
|
|
15
17
|
BATCH_DIRECTORY_NAME = /\A\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d{2})?\z/
|
|
16
18
|
private_constant :BATCH_DIRECTORY_NAME
|
|
17
19
|
|
|
20
|
+
# 固定名上传无法保留历史时,在设备上传之前拒绝执行。
|
|
18
21
|
class Unavailable < Net::Connector::Error
|
|
19
22
|
def initialize(host: nil)
|
|
20
23
|
super("a local TFTP root is required to retain a fixed-name upload", code: :tftp_history_unavailable,
|
|
@@ -22,6 +25,7 @@ module Net
|
|
|
22
25
|
end
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
# 设备可能已经上传,归档失败不能撤销或重新执行设备命令。
|
|
25
29
|
class ArchiveFailed < Net::Connector::Error
|
|
26
30
|
def initialize(host: nil)
|
|
27
31
|
super("the uploaded file could not be retained in the batch", code: :tftp_archive_failed,
|
|
@@ -38,6 +42,7 @@ module Net
|
|
|
38
42
|
@verifier = TftpVerification.new(root: @server_root)
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
# 有本机根目录时保留原名并归档;远端服务器使用批次后缀避免重复覆盖。
|
|
41
46
|
def upload_filename(device)
|
|
42
47
|
name = device.tftp_filename
|
|
43
48
|
return name if @server_root || device.vendor == :palo_alto
|
|
@@ -48,6 +53,7 @@ module Net
|
|
|
48
53
|
TftpTarget.validate_path!("#{stem.byteslice(0, TftpTarget::MAX_PATH_BYTES - suffix.bytesize)}#{suffix}")
|
|
49
54
|
end
|
|
50
55
|
|
|
56
|
+
# 上传与归档在同一目标锁内完成,后续失败仍保留可确认的上传回执和提交路径。
|
|
51
57
|
def upload_and_archive(device, started_at:)
|
|
52
58
|
raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
|
|
53
59
|
|
|
@@ -69,9 +75,6 @@ module Net
|
|
|
69
75
|
end
|
|
70
76
|
end
|
|
71
77
|
|
|
72
|
-
alias path_for upload_filename
|
|
73
|
-
alias capture upload_and_archive
|
|
74
|
-
|
|
75
78
|
private
|
|
76
79
|
|
|
77
80
|
# 文件系统的 mtime 可能落后于 Time.now;用同一服务器目录的时间建立上传边界。
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "settings"
|
|
4
|
+
|
|
3
5
|
module Net
|
|
4
6
|
module Connector
|
|
5
7
|
module Netdisco
|
|
@@ -58,7 +60,7 @@ module Net
|
|
|
58
60
|
threads.each do |thread|
|
|
59
61
|
begin
|
|
60
62
|
thread.join
|
|
61
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
63
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 等待全部工作线程停止,不让收尾异常覆盖原中断。
|
|
62
64
|
nil
|
|
63
65
|
end
|
|
64
66
|
end
|
|
@@ -1,25 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "netdisco/client"
|
|
5
|
-
require_relative "netdisco/database_client"
|
|
6
|
-
require_relative "netdisco/rules"
|
|
7
|
-
require_relative "netdisco/device"
|
|
8
|
-
require_relative "netdisco/config_file"
|
|
9
|
-
require_relative "netdisco/settings"
|
|
10
|
-
require_relative "netdisco/worker"
|
|
11
|
-
require_relative "netdisco/result_store"
|
|
12
|
-
require_relative "netdisco/batch"
|
|
13
|
-
require_relative "netdisco/planner"
|
|
14
|
-
require_relative "netdisco/fleet"
|
|
15
|
-
require_relative "netdisco/progress"
|
|
3
|
+
# 公共入口只组合应用流程;每个模块自行声明依赖,不要求调用方维护加载顺序。
|
|
16
4
|
require_relative "netdisco/cli"
|
|
17
|
-
require_relative "storage/batch_directory"
|
|
18
|
-
require_relative "netdisco/report/text"
|
|
19
|
-
require_relative "netdisco/connection"
|
|
20
|
-
require_relative "netdisco/cli/options"
|
|
21
|
-
require_relative "netdisco/report/files"
|
|
22
|
-
require_relative "netdisco/tftp_verification"
|
|
23
|
-
require_relative "netdisco/tftp_archive"
|
|
24
|
-
require_relative "netdisco/tftp_history"
|
|
25
5
|
require_relative "netdisco/backup_run"
|
|
@@ -18,8 +18,12 @@ module Net
|
|
|
18
18
|
|
|
19
19
|
def committed? = state != :not_committed
|
|
20
20
|
def durable? = state == :durable
|
|
21
|
+
|
|
22
|
+
# 阶段更新仍校验状态并冻结路径,Ruby 3.2 也不能绕过初始化约束。
|
|
23
|
+
def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# 写入失败仍携带阶段与提交回执,调用方据此区分未替换和已替换文件。
|
|
23
27
|
class WriteError < IOError
|
|
24
28
|
attr_reader :receipt, :code, :underlying_type
|
|
25
29
|
|
|
@@ -41,8 +45,10 @@ module Net
|
|
|
41
45
|
def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
# 替换已经提交后的持久化或收尾失败,不能按尚未写入处理。
|
|
44
49
|
class PersistenceError < WriteError; end
|
|
45
50
|
|
|
51
|
+
# 目录同步能力缺失只表示持久性未确认,已替换的目标文件仍然保留。
|
|
46
52
|
class DirectorySyncUnsupported < WriteError
|
|
47
53
|
def initialize(**options)
|
|
48
54
|
super
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/running_config/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 清理 IOS 响应头,保留 banner 和配置正文内的命令、时间等原始内容。
|
|
8
9
|
class RunningConfig < Net::Connector::RunningConfig::Rendered
|
|
9
10
|
# 每条命令已有独立响应;正文中的命令示例属于配置,不能按会话回显删除。
|
|
10
11
|
# 只清理首个配置语句之前的时间注释,避免改写 banner 等多行文本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 通过交互复制运行配置,以设备的复制完成行确认上传。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 在连接前拒绝当前厂商不支持的参数组合。
|
|
10
11
|
def validate_options!(_target, source_file:, vrf: nil)
|
|
@@ -12,6 +13,7 @@ module Net
|
|
|
12
13
|
raise ArgumentError, "Cisco IOS TFTP backup does not use vrf" unless vrf.nil?
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
15
17
|
def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
|
|
16
18
|
|
|
17
19
|
# 构造当前厂商的 TFTP 导出交互脚本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
|
+
# 基于 CDP 证据规划接口描述,变更后读回确认,再执行独立保存阶段。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
# IOS 提供 CDP 邻居、配置描述读取和描述变更。
|
|
10
11
|
def self.supports?(capability)
|
|
@@ -26,6 +27,7 @@ module Net
|
|
|
26
27
|
# 先回到执行视图读回,随后才执行档案中的保存命令。
|
|
27
28
|
def leave_configuration_commands = ["end"]
|
|
28
29
|
|
|
30
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
29
31
|
def persistence_pattern = /\A(?:\[OK\]|Copy complete\.)\z/i
|
|
30
32
|
|
|
31
33
|
# 每个 Device ID 表示一条应由模板解析的邻居记录。
|
|
@@ -5,12 +5,14 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoNxos
|
|
8
|
+
# 在指定 VRF 上传 NX-OS 运行配置,默认使用 management。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 在连接前拒绝当前厂商不支持的参数组合。
|
|
10
11
|
def validate_options!(_target, source_file:, **)
|
|
11
12
|
raise ArgumentError, "Cisco NX-OS does not use source_file" unless source_file.nil?
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
14
16
|
def receipt_metadata(_target, **) = { configuration_kind: :running, format: :cfg }
|
|
15
17
|
|
|
16
18
|
# 构造当前厂商的 TFTP 导出交互脚本。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module H3c
|
|
8
|
+
# 适配 H3C LLDP 表头与接口视图,数量核对失败时禁止继续规划变更。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
TABLE_HEADER = /^\s*(?:System Name\s+Local Interface\b|(?:Local Interface|LocalIf)\s+)/i
|
|
10
11
|
# H3C 提供 LLDP 邻居、配置描述读取和描述变更。
|
|
@@ -24,6 +25,7 @@ module Net
|
|
|
24
25
|
# 返回用户视图读回;保存交给后续独立阶段。
|
|
25
26
|
def leave_configuration_commands = ["return"]
|
|
26
27
|
|
|
28
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
27
29
|
def persistence_pattern = /\ASaved the current configuration to mainboard device successfully\.\z/i
|
|
28
30
|
|
|
29
31
|
# H3C 不同版本的列顺序不同,按表头选对应模板。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/running_config/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 山石配置保持原始文本,仅删除已识别的退格分页痕迹。
|
|
8
9
|
class RunningConfig < Net::Connector::RunningConfig::Strategy
|
|
9
10
|
# 去掉山石 CLI 用退格控制符绘制的分页痕迹。
|
|
10
11
|
def clean(text) = text.gsub(/\x00?\x08+[ \t]+\x08+/, "")
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 导出山石启动配置,并从完成行确认设备实际使用的 dat 文件名。
|
|
8
9
|
class TftpBackup < Tftp::Strategy
|
|
9
10
|
# 山石原生导出的配置文件使用 dat 扩展名。
|
|
10
11
|
def self.file_extension = "dat"
|
|
@@ -17,6 +18,7 @@ module Net
|
|
|
17
18
|
raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# 声明本厂商实际导出的配置种类与格式,不推断服务器文件已核验。
|
|
20
22
|
def receipt_metadata(target, **)
|
|
21
23
|
{ configuration_kind: :startup, format: :dat, requested_path: target.explicit_path? ? target.path : nil }
|
|
22
24
|
end
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/topology/immediate_strategy"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Hillstone
|
|
8
|
+
# 核对山石 LLDP 数据与总数,接口描述读回一致后才保存配置。
|
|
8
9
|
class Topology < Topology::ImmediateStrategy
|
|
9
10
|
# 山石提供 LLDP 邻居、配置描述读取和描述变更。
|
|
10
11
|
def self.supports?(capability)
|
|
@@ -23,6 +24,7 @@ module Net
|
|
|
23
24
|
# 退出配置视图后先读回,保存动作随后单独执行。
|
|
24
25
|
def leave_configuration_commands = ["exit"]
|
|
25
26
|
|
|
27
|
+
# 只认可本厂商明确的保存完成行,设备提示符返回本身不能证明持久化。
|
|
26
28
|
def persistence_pattern = /\ASaving configuration is finished\.?\z/i
|
|
27
29
|
|
|
28
30
|
# 用数据行和设备报告的总数交叉核对,避免遗漏邻居。
|
|
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/file_upload"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module Huawei
|
|
8
|
+
# 上传调用方明确指定的华为保存文件,连接前拒绝缺少源文件的请求。
|
|
8
9
|
class TftpBackup < Tftp::FileUpload
|
|
9
10
|
# 缺少源文件也必须在首次设备 I/O 之前拒绝。
|
|
10
11
|
def validate_options!(target, source_file:, vrf: nil)
|
|
@@ -12,7 +13,7 @@ module Net
|
|
|
12
13
|
raise ArgumentError, "Huawei TFTP backup requires source_file" if source_file.nil?
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
#
|
|
16
|
+
# 读取调用方明确指定的合法保存文件路径,不根据文件名推断为启动配置。
|
|
16
17
|
def resolve_source_file(value)
|
|
17
18
|
raise ArgumentError, "Huawei TFTP backup requires source_file" unless value
|
|
18
19
|
|