net-connector 0.6.0 → 0.7.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 +52 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +112 -50
- data/docs/VERIFICATION.md +42 -1
- data/docs/architecture.md +15 -2
- data/examples/backup.yml +40 -0
- data/lib/net/connector/device/base.rb +30 -38
- data/lib/net/connector/device/profile/builder.rb +8 -8
- data/lib/net/connector/device/profile.rb +2 -2
- data/lib/net/connector/device/running_config.rb +2 -2
- data/lib/net/connector/device/tftp/receipt.rb +12 -3
- data/lib/net/connector/device/tftp.rb +7 -7
- data/lib/net/connector/device/topology.rb +4 -4
- data/lib/net/connector/engine/command.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +5 -2
- data/lib/net/connector/engine/execution.rb +25 -14
- data/lib/net/connector/engine/known_hosts.rb +112 -0
- data/lib/net/connector/engine/log.rb +10 -7
- data/lib/net/connector/engine/session.rb +117 -128
- data/lib/net/connector/engine/transport.rb +32 -7
- data/lib/net/connector/netdisco/backup_run.rb +132 -0
- data/lib/net/connector/netdisco/batch.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +139 -0
- data/lib/net/connector/netdisco/cli.rb +13 -26
- data/lib/net/connector/netdisco/client.rb +6 -6
- data/lib/net/connector/netdisco/config_file.rb +20 -19
- data/lib/net/connector/netdisco/connection.rb +38 -0
- data/lib/net/connector/netdisco/database_client.rb +2 -2
- data/lib/net/connector/netdisco/device.rb +11 -1
- data/lib/net/connector/netdisco/fleet.rb +68 -47
- data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
- data/lib/net/connector/netdisco/plan.rb +14 -9
- data/lib/net/connector/netdisco/planner.rb +18 -14
- data/lib/net/connector/netdisco/progress.rb +253 -0
- data/lib/net/connector/netdisco/report/files.rb +41 -0
- data/lib/net/connector/netdisco/report/text.rb +45 -0
- data/lib/net/connector/netdisco/report.rb +53 -31
- data/lib/net/connector/netdisco/result_store.rb +3 -1
- data/lib/net/connector/netdisco/rules.rb +10 -2
- data/lib/net/connector/netdisco/settings.rb +118 -68
- data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
- data/lib/net/connector/netdisco/tftp_history.rb +16 -0
- data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
- data/lib/net/connector/netdisco/worker.rb +8 -3
- data/lib/net/connector/netdisco.rb +10 -0
- data/lib/net/connector/storage/backup_lock.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +29 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c.rb +2 -1
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/running_config.rb +19 -0
- data/lib/net/connector/vendor/radware.rb +4 -3
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +2 -2
- metadata +42 -111
- /data/examples/{netdisco_database.yml → inventory_sql.yml} +0 -0
|
@@ -30,13 +30,10 @@ module Net
|
|
|
30
30
|
@lock = Mutex.new
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# 判断会话状态和传输层是否都表明连接仍然有效。
|
|
34
33
|
def connected? = state != :closed && !transport.closed?
|
|
35
34
|
|
|
36
|
-
# 返回当前是否已经进入特权模式。
|
|
37
35
|
def privileged? = @privileged
|
|
38
36
|
|
|
39
|
-
# 标记当前会话已进入特权模式。
|
|
40
37
|
def mark_privileged! = @privileged = true
|
|
41
38
|
|
|
42
39
|
# 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
|
|
@@ -63,16 +60,16 @@ module Net
|
|
|
63
60
|
end
|
|
64
61
|
|
|
65
62
|
# 锁覆盖完整操作而不是单次写入,保证批处理命令不会交错。
|
|
66
|
-
def perform(phase)
|
|
63
|
+
def perform(phase, &)
|
|
67
64
|
if @operation_owner == [Thread.current, Fiber.current] && !@performing
|
|
68
|
-
return perform_locked(phase)
|
|
65
|
+
return perform_locked(phase, &)
|
|
69
66
|
end
|
|
70
67
|
unless @lock.try_lock
|
|
71
68
|
raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
|
|
72
69
|
end
|
|
73
70
|
|
|
74
71
|
begin
|
|
75
|
-
perform_locked(phase)
|
|
72
|
+
perform_locked(phase, &)
|
|
76
73
|
ensure
|
|
77
74
|
@lock.unlock
|
|
78
75
|
end
|
|
@@ -106,37 +103,6 @@ module Net
|
|
|
106
103
|
raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
|
|
107
104
|
end
|
|
108
105
|
|
|
109
|
-
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
110
|
-
def perform_locked(phase)
|
|
111
|
-
@performing = true
|
|
112
|
-
completed = false
|
|
113
|
-
begin
|
|
114
|
-
unless connected?
|
|
115
|
-
close_resources unless state == :closed
|
|
116
|
-
connect_session
|
|
117
|
-
end
|
|
118
|
-
@state = (phase == :connect) ? :ready : :executing
|
|
119
|
-
result = yield
|
|
120
|
-
completed = true
|
|
121
|
-
result
|
|
122
|
-
rescue => error
|
|
123
|
-
failure = normalize_error(error, phase: phase)
|
|
124
|
-
close_preserving_failure
|
|
125
|
-
raise failure, cause: nil
|
|
126
|
-
rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
|
|
127
|
-
close_preserving_failure
|
|
128
|
-
raise
|
|
129
|
-
ensure
|
|
130
|
-
# throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
|
|
131
|
-
# 半途的设备提示符误当成下一条命令的响应。
|
|
132
|
-
close_preserving_failure unless completed || !connected?
|
|
133
|
-
@state = :ready if connected?
|
|
134
|
-
@performing = false
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private :perform_locked
|
|
139
|
-
|
|
140
106
|
# 独占关闭会话;已有操作占用时返回会话繁忙错误。
|
|
141
107
|
def close
|
|
142
108
|
unless @lock.try_lock
|
|
@@ -195,6 +161,99 @@ module Net
|
|
|
195
161
|
end
|
|
196
162
|
end
|
|
197
163
|
|
|
164
|
+
# 按截止时间限制传输写入,并统一包装底层错误。
|
|
165
|
+
def write(bytes, deadline:, phase:, command: nil)
|
|
166
|
+
remaining = [deadline - Expect.monotonic, 0].max
|
|
167
|
+
transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
|
|
168
|
+
rescue => error
|
|
169
|
+
raise normalize_error(error, phase: phase, command: command), cause: nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 人工交互会结束当前连接,未完成的输入行不能在之后重放。
|
|
173
|
+
def interact(**options)
|
|
174
|
+
perform(:interact) do
|
|
175
|
+
@state = :interacting
|
|
176
|
+
begin
|
|
177
|
+
transport.interact(**options)
|
|
178
|
+
ensure
|
|
179
|
+
$ERROR_INFO ? close_preserving_failure : close_resources
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
185
|
+
def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
186
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
187
|
+
message = safe_error_text(message, sensitive: sensitive)
|
|
188
|
+
output = safe_error_text(output, sensitive: sensitive)
|
|
189
|
+
klass.new(message, host: configuration.host, phase: phase,
|
|
190
|
+
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
191
|
+
source: command&.source, line: command&.line,
|
|
192
|
+
output: truncate_output(output),
|
|
193
|
+
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
197
|
+
def normalize_error(exception, phase:, command: nil)
|
|
198
|
+
if exception.is_a?(Error)
|
|
199
|
+
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
200
|
+
failed_command = exception.command || command&.text
|
|
201
|
+
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
202
|
+
failed_command = "[REDACTED]"
|
|
203
|
+
end
|
|
204
|
+
message = safe_error_text(exception.message, sensitive: sensitive)
|
|
205
|
+
output = safe_error_text(exception.output, sensitive: sensitive)
|
|
206
|
+
return exception.with_diagnostics(
|
|
207
|
+
message: message,
|
|
208
|
+
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
209
|
+
command: failed_command && redactor.call(failed_command),
|
|
210
|
+
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
211
|
+
output: truncate_output(output),
|
|
212
|
+
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
klass = case exception
|
|
217
|
+
when Expect::WriteTimeout then WriteTimeout
|
|
218
|
+
when IOError, SystemCallError, Expect::SpawnError then TransportError
|
|
219
|
+
else InternalError
|
|
220
|
+
end
|
|
221
|
+
build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
229
|
+
def perform_locked(phase)
|
|
230
|
+
@performing = true
|
|
231
|
+
completed = false
|
|
232
|
+
begin
|
|
233
|
+
unless connected?
|
|
234
|
+
close_resources unless state == :closed
|
|
235
|
+
connect_session
|
|
236
|
+
end
|
|
237
|
+
@state = (phase == :connect) ? :ready : :executing
|
|
238
|
+
result = yield
|
|
239
|
+
completed = true
|
|
240
|
+
result
|
|
241
|
+
rescue => error
|
|
242
|
+
failure = normalize_error(error, phase: phase)
|
|
243
|
+
close_preserving_failure
|
|
244
|
+
raise failure, cause: nil
|
|
245
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
|
|
246
|
+
close_preserving_failure
|
|
247
|
+
raise
|
|
248
|
+
ensure
|
|
249
|
+
# throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
|
|
250
|
+
# 半途的设备提示符误当成下一条命令的响应。
|
|
251
|
+
close_preserving_failure unless completed || !connected?
|
|
252
|
+
@state = :ready if connected?
|
|
253
|
+
@performing = false
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
198
257
|
def execute_command_with_logging(command, timeout:, prompt:)
|
|
199
258
|
started = Expect.monotonic
|
|
200
259
|
@log.log_event("command_start")
|
|
@@ -224,8 +283,6 @@ module Net
|
|
|
224
283
|
end
|
|
225
284
|
end
|
|
226
285
|
|
|
227
|
-
private :execute_command_with_logging, :elapsed_ms, :log_operation_completion
|
|
228
|
-
|
|
229
286
|
# 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
|
|
230
287
|
def protect_command(command)
|
|
231
288
|
redactor.output_sensitive! if command.output_sensitive?
|
|
@@ -235,8 +292,6 @@ module Net
|
|
|
235
292
|
redactor.remember(command.text) if command.sensitive?
|
|
236
293
|
end
|
|
237
294
|
|
|
238
|
-
private :protect_command
|
|
239
|
-
|
|
240
295
|
# 写入命令、读取对话、识别设备诊断,并返回响应对象。
|
|
241
296
|
def exchange_command(command, timeout:, prompt: nil)
|
|
242
297
|
deadline = Expect.monotonic + (command.timeout || timeout)
|
|
@@ -260,109 +315,23 @@ module Net
|
|
|
260
315
|
end
|
|
261
316
|
end
|
|
262
317
|
|
|
263
|
-
private :exchange_command
|
|
264
|
-
|
|
265
|
-
# 按截止时间限制传输写入,并统一包装底层错误。
|
|
266
|
-
def write(bytes, deadline:, phase:, command: nil)
|
|
267
|
-
remaining = [deadline - Expect.monotonic, 0].max
|
|
268
|
-
transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
|
|
269
|
-
rescue => error
|
|
270
|
-
raise normalize_error(error, phase: phase, command: command), cause: nil
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# 人工交互会结束当前连接,未完成的输入行不能在之后重放。
|
|
274
|
-
def interact(**options)
|
|
275
|
-
perform(:interact) do
|
|
276
|
-
@state = :interacting
|
|
277
|
-
begin
|
|
278
|
-
transport.interact(**options)
|
|
279
|
-
ensure
|
|
280
|
-
$ERROR_INFO ? close_preserving_failure : close_resources
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
# 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
|
|
286
|
-
def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
|
|
287
|
-
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
288
|
-
message = safe_error_text(message, sensitive: sensitive)
|
|
289
|
-
output = safe_error_text(output, sensitive: sensitive)
|
|
290
|
-
klass.new(message, host: configuration.host, phase: phase,
|
|
291
|
-
command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
|
|
292
|
-
source: command&.source, line: command&.line,
|
|
293
|
-
output: truncate_output(output),
|
|
294
|
-
underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
|
|
298
|
-
def normalize_error(exception, phase:, command: nil)
|
|
299
|
-
if exception.is_a?(Error)
|
|
300
|
-
sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
|
|
301
|
-
failed_command = exception.command || command&.text
|
|
302
|
-
if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
|
|
303
|
-
failed_command = "[REDACTED]"
|
|
304
|
-
end
|
|
305
|
-
message = safe_error_text(exception.message, sensitive: sensitive)
|
|
306
|
-
output = safe_error_text(exception.output, sensitive: sensitive)
|
|
307
|
-
return exception.with_diagnostics(
|
|
308
|
-
message: message,
|
|
309
|
-
host: exception.host || configuration.host, phase: exception.phase || phase,
|
|
310
|
-
command: failed_command && redactor.call(failed_command),
|
|
311
|
-
source: exception.source || command&.source, line: exception.line || command&.line,
|
|
312
|
-
output: truncate_output(output),
|
|
313
|
-
underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
|
|
314
|
-
)
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
klass = case exception
|
|
318
|
-
when Expect::WriteTimeout then WriteTimeout
|
|
319
|
-
when IOError, SystemCallError, Expect::SpawnError then TransportError
|
|
320
|
-
else InternalError
|
|
321
|
-
end
|
|
322
|
-
build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
318
|
# 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
|
|
326
319
|
def safe_error_text(text, sensitive:)
|
|
327
320
|
sensitive && !text.to_s.empty? ? "[REDACTED]".b : redactor.call(text)
|
|
328
321
|
end
|
|
329
322
|
|
|
330
|
-
private :safe_error_text
|
|
331
|
-
|
|
332
323
|
# 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
|
|
333
324
|
def truncate_output(text)
|
|
334
325
|
text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
|
|
335
326
|
end
|
|
336
327
|
|
|
337
|
-
private :truncate_output
|
|
338
|
-
|
|
339
|
-
# 返回不包含凭据的会话状态摘要。
|
|
340
|
-
def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
|
|
341
|
-
|
|
342
|
-
private
|
|
343
|
-
|
|
344
328
|
# 建立传输并登录;失败时最多执行一次连接级恢复,然后重试登录。
|
|
345
329
|
def connect_session
|
|
346
330
|
attempted_recovery = false
|
|
347
331
|
begin
|
|
348
332
|
@state = :connecting
|
|
349
333
|
started = Expect.monotonic
|
|
350
|
-
|
|
351
|
-
@log.open(transport)
|
|
352
|
-
@log.log_event("connect", host: configuration.host, username: configuration.username,
|
|
353
|
-
phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
354
|
-
transport.open
|
|
355
|
-
@state = :authenticating
|
|
356
|
-
@log.log_event("login_start", level: :debug, phase: :login)
|
|
357
|
-
response = @authentication.login
|
|
358
|
-
safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
|
|
359
|
-
prompt: response.prompt)
|
|
360
|
-
@prompt = response.prompt
|
|
361
|
-
@after_login.call(self, safe_response)
|
|
362
|
-
@log.log_authentication_output(response.raw)
|
|
363
|
-
@log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
|
|
364
|
-
@log.attach
|
|
365
|
-
@state = :ready
|
|
334
|
+
login_once(started)
|
|
366
335
|
rescue => error
|
|
367
336
|
phase = (@state == :connecting) ? :connect : :login
|
|
368
337
|
failure = normalize_error(error, phase: phase)
|
|
@@ -379,6 +348,26 @@ module Net
|
|
|
379
348
|
end
|
|
380
349
|
end
|
|
381
350
|
|
|
351
|
+
def login_once(started)
|
|
352
|
+
redactor.reset
|
|
353
|
+
@log.open(transport)
|
|
354
|
+
@log.log_event("connect", host: configuration.host, username: configuration.username,
|
|
355
|
+
phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
|
|
356
|
+
transport.open
|
|
357
|
+
@state = :authenticating
|
|
358
|
+
@log.log_event("login_start", level: :debug, phase: :login)
|
|
359
|
+
response = @authentication.login
|
|
360
|
+
transport.authenticated if transport.respond_to?(:authenticated)
|
|
361
|
+
safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
|
|
362
|
+
prompt: response.prompt)
|
|
363
|
+
@prompt = response.prompt
|
|
364
|
+
@after_login.call(self, safe_response)
|
|
365
|
+
@log.log_authentication_output(response.raw)
|
|
366
|
+
@log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
|
|
367
|
+
@log.attach
|
|
368
|
+
@state = :ready
|
|
369
|
+
end
|
|
370
|
+
|
|
382
371
|
# 清除会话状态并关闭传输和日志资源。
|
|
383
372
|
def close_resources
|
|
384
373
|
@state = :closed
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "expect/pty"
|
|
5
|
+
require_relative "known_hosts"
|
|
5
6
|
|
|
6
7
|
module Net
|
|
7
8
|
module Connector
|
|
@@ -125,6 +126,29 @@ module Net
|
|
|
125
126
|
@legacy_arguments = legacy_arguments.dup.freeze
|
|
126
127
|
end
|
|
127
128
|
|
|
129
|
+
def open
|
|
130
|
+
configuration.validate_endpoint!
|
|
131
|
+
unless configuration.host_key_policy == :strict
|
|
132
|
+
@known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
|
|
133
|
+
end
|
|
134
|
+
super
|
|
135
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Release a staged trust file on failed startup.
|
|
136
|
+
close
|
|
137
|
+
raise
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def authenticated
|
|
141
|
+
@known_hosts&.commit
|
|
142
|
+
@replace_host_key = false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def close
|
|
146
|
+
super
|
|
147
|
+
ensure
|
|
148
|
+
@known_hosts&.close
|
|
149
|
+
@known_hosts = nil
|
|
150
|
+
end
|
|
151
|
+
|
|
128
152
|
# 返回 SSH 协议标识。
|
|
129
153
|
def protocol = :ssh
|
|
130
154
|
|
|
@@ -133,9 +157,10 @@ module Net
|
|
|
133
157
|
configuration.validate_endpoint!
|
|
134
158
|
checking = (configuration.host_key_policy == :strict) ? "yes" : "accept-new"
|
|
135
159
|
arguments = ["ssh", *legacy_arguments, "-tt", "-o", "StrictHostKeyChecking=#{checking}",
|
|
136
|
-
"-o", "NumberOfPasswordPrompts=1",
|
|
160
|
+
"-o", "NumberOfPasswordPrompts=1", "-o", "UpdateHostKeys=no",
|
|
137
161
|
"-o", "ConnectTimeout=#{[configuration.login_timeout.ceil, 1].max}"]
|
|
138
|
-
|
|
162
|
+
known_hosts = @known_hosts&.path || configuration.known_hosts
|
|
163
|
+
arguments += ["-o", "UserKnownHostsFile=#{known_hosts}"] if known_hosts
|
|
139
164
|
arguments += ["-p", configuration.port.to_s] if configuration.port
|
|
140
165
|
arguments + ["-l", configuration.username, configuration.host]
|
|
141
166
|
end
|
|
@@ -151,13 +176,13 @@ module Net
|
|
|
151
176
|
Telnet.new(configuration, channel_factory: @channel_factory, terminal_size: @terminal_size)
|
|
152
177
|
end
|
|
153
178
|
|
|
154
|
-
#
|
|
179
|
+
# 恢复只标记下一次会话副本;共享信任文件在认证成功后才更新。
|
|
155
180
|
def replace_host_key
|
|
156
|
-
host
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
raise IOError, "removing device host key failed" unless status.success?
|
|
181
|
+
raise IOError, "host key replacement is not enabled" unless configuration.host_key_policy == :replace
|
|
182
|
+
|
|
183
|
+
@replace_host_key = true
|
|
160
184
|
end
|
|
185
|
+
|
|
161
186
|
end
|
|
162
187
|
|
|
163
188
|
# Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
# 人类批次入口:固定配置、预览、执行、保存报告,最后决定输出与退出码。
|
|
10
|
+
class BackupRun
|
|
11
|
+
def initialize(mode: :backup, argv: ARGV, env: ENV, input: $stdin, output: $stdout, error: $stderr)
|
|
12
|
+
raise ArgumentError, "mode must be backup or tftp" unless %i[backup tftp].include?(mode)
|
|
13
|
+
|
|
14
|
+
@mode, @argv, @env, @input, @output, @error = mode, argv, env, input, output, error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
options = CLI::Options.parse(argv: @argv, input: @input, output: @output, error: @error)
|
|
19
|
+
return 0 if options[:help]
|
|
20
|
+
|
|
21
|
+
settings, success_policy, server_root, batch_directory = prepare(options)
|
|
22
|
+
client, credentials = Connection.build(settings, stdin_credentials: options[:stdin_credentials], input: @input)
|
|
23
|
+
progress = Progress.new(io: @error, enabled: @env.fetch("NC_PROGRESS", "1") != "0", verbose: options[:verbose])
|
|
24
|
+
resolver = ->(device) { credentials.call(device)&.merge(on_event: progress.method(:event)) }
|
|
25
|
+
fleet = Fleet.new(client: client, settings: settings, credentials: resolver, result_store: nil)
|
|
26
|
+
progress.reading_inventory
|
|
27
|
+
limit = settings.limit_per_vendor
|
|
28
|
+
plan = if @mode == :backup
|
|
29
|
+
fleet.plan_backup(limit_per_vendor: limit)
|
|
30
|
+
else
|
|
31
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit, allow_fixed_name_reuse: !server_root.nil?)
|
|
32
|
+
end
|
|
33
|
+
progress.plan(plan, concurrency: settings.concurrency, limit_per_vendor: limit)
|
|
34
|
+
preview = plan_summary(plan)
|
|
35
|
+
@output.puts JSON.generate(plan: preview) if options[:json]
|
|
36
|
+
@output.flush
|
|
37
|
+
return 1 if options[:stdin_credentials] && @input.gets&.strip != "RUN"
|
|
38
|
+
|
|
39
|
+
Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
|
|
40
|
+
report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
41
|
+
report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
|
|
42
|
+
finish(report, progress, options, batch_directory)
|
|
43
|
+
rescue ArgumentError, Client::Error => exception
|
|
44
|
+
@error.puts "备份参数或清单错误:#{exception.message}"
|
|
45
|
+
2
|
|
46
|
+
rescue StandardError => exception
|
|
47
|
+
@error.puts "备份未完成(#{exception.class})"
|
|
48
|
+
2
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def prepare(options)
|
|
54
|
+
settings = CLI::Options.settings(options, env: @env)
|
|
55
|
+
success_policy = options.fetch(:success_policy, :selected)
|
|
56
|
+
raise ArgumentError, "verified policy requires TFTP mode" if success_policy == :verified && @mode != :tftp
|
|
57
|
+
raise ArgumentError, "--tftp-root requires TFTP mode" if options[:tftp_root] && @mode != :tftp
|
|
58
|
+
server_root = tftp_root(settings, options) if @mode == :tftp
|
|
59
|
+
raise ArgumentError, "verified policy requires a local TFTP directory" if success_policy == :verified && !server_root
|
|
60
|
+
TftpVerification.new(root: server_root) if server_root
|
|
61
|
+
batch_directory = Storage::BatchDirectory.create(settings.backup_directory)
|
|
62
|
+
if @mode == :tftp && !settings.log_directory
|
|
63
|
+
settings = settings.with_overrides("NC_LOG_DIRECTORY" => File.join(batch_directory, "logs"))
|
|
64
|
+
end
|
|
65
|
+
settings = settings.for_run(mode: @mode)
|
|
66
|
+
[settings, success_policy, server_root, batch_directory]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
|
|
70
|
+
File.open(File.join(batch_directory, "events.jsonl"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |events|
|
|
71
|
+
options = { plan: plan, concurrency: settings.concurrency, success_policy: success_policy,
|
|
72
|
+
on_start: progress.method(:start), on_result: result_callback(progress, events) }
|
|
73
|
+
progress.with_updates do
|
|
74
|
+
if @mode == :backup
|
|
75
|
+
fleet.backup_all(directory: batch_directory, filename_style: :hostname_ip, **options)
|
|
76
|
+
else
|
|
77
|
+
fleet.tftp_backup_all(server: settings.tftp_server, source_files: settings.tftp_source_files,
|
|
78
|
+
vrfs: settings.tftp_vrfs, report_directory: batch_directory,
|
|
79
|
+
verification_root: server_root, preserve_history: true, **options)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def result_callback(progress, events)
|
|
86
|
+
lock = Mutex.new
|
|
87
|
+
lambda do |outcome|
|
|
88
|
+
progress.result(outcome)
|
|
89
|
+
lock.synchronize do
|
|
90
|
+
events.puts JSON.generate(host: outcome.device.host, vendor: outcome.device.vendor,
|
|
91
|
+
status: outcome.status, error_code: outcome.diagnostic&.error_code,
|
|
92
|
+
error_type: outcome.diagnostic&.error_type, duration_ms: outcome.duration_ms)
|
|
93
|
+
events.flush
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def finish(report, progress, options, batch_directory)
|
|
99
|
+
progress.finish(report)
|
|
100
|
+
progress.location(report.report_location) if report.report_location
|
|
101
|
+
@error.puts "文本报告:#{File.join(batch_directory, "summary.txt")}" if @env.fetch("NC_PROGRESS", "1") != "0" && File.file?(File.join(batch_directory, "summary.txt"))
|
|
102
|
+
if options[:json]
|
|
103
|
+
@output.puts JSON.generate(directory: batch_directory, counts: report.counts,
|
|
104
|
+
tasks_succeeded: report.policy_success?, success: report.policy_success?,
|
|
105
|
+
policy: report.policy, policy_success: report.policy_success?,
|
|
106
|
+
report_location: report.report_location, report_error: report.report_error)
|
|
107
|
+
end
|
|
108
|
+
report.policy_success? ? 0 : 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tftp_root(settings, options)
|
|
112
|
+
server = settings.tftp_server || (raise ArgumentError, "TFTP_HOST or YAML tftp.server is required")
|
|
113
|
+
return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["TFTP_ROOT"]
|
|
114
|
+
return unless Socket.ip_address_list.any? { |address| address.ip_address == server }
|
|
115
|
+
|
|
116
|
+
candidate = File.join(Dir.home, "Documents", "TFTP")
|
|
117
|
+
candidate if File.directory?(candidate)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def plan_summary(plan)
|
|
121
|
+
{ total: plan.inventory.size, ready: plan.inventory.count(&:ready?),
|
|
122
|
+
vendors: plan.inventory.group_by { |device| device.vendor || :unmapped }.transform_values(&:size),
|
|
123
|
+
issues: plan.inventory.reject(&:ready?).group_by(&:issue).transform_values(&:size),
|
|
124
|
+
selected: plan.selected.map do |device|
|
|
125
|
+
filename = @mode == :backup ? device.backup_filename(style: :hostname_ip) : device.tftp_filename
|
|
126
|
+
{ host: device.host, name: device.name, vendor: device.vendor, backup_filename: filename }
|
|
127
|
+
end }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -45,7 +45,7 @@ module Net
|
|
|
45
45
|
Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
|
|
46
46
|
:report_location, :report_error) do
|
|
47
47
|
# 按设备最终状态统计数量。
|
|
48
|
-
def counts = outcomes.
|
|
48
|
+
def counts = outcomes.map(&:status).tally
|
|
49
49
|
|
|
50
50
|
# 仅在所有设备成功且回调、报告均正常时判定整批成功。
|
|
51
51
|
def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Netdisco
|
|
8
|
+
class CLI
|
|
9
|
+
# 可注入参数与输入输出的备份选项,不退出宿主进程。
|
|
10
|
+
class Options
|
|
11
|
+
def self.parse(argv: ARGV, input: $stdin, output: $stdout, error: $stderr, program: $PROGRAM_NAME)
|
|
12
|
+
values = { environment: {}, json: false, verbose: false, stdin_credentials: false }
|
|
13
|
+
parser = parser_for(values, output: output, program: program)
|
|
14
|
+
remaining = parser.parse!(argv.dup)
|
|
15
|
+
return values if values[:help]
|
|
16
|
+
raise OptionParser::InvalidArgument unless remaining.empty?
|
|
17
|
+
credentials = values[:environment].keys.any? { |key| key.match?(/USERNAME|PASSWORD/) }
|
|
18
|
+
if values[:stdin_credentials] && (credentials || values[:ask_password] || values[:ask_netdisco_password])
|
|
19
|
+
raise ArgumentError, "--stdin-credentials 不能与命令行凭据或密码提示同时使用"
|
|
20
|
+
end
|
|
21
|
+
read_password(values, :ask_password, "NC_DEVICE_PASSWORD", "设备密码", input: input, output: error)
|
|
22
|
+
read_password(values, :ask_netdisco_password, "NETDISCO_PASSWORD", "Netdisco 密码", input: input, output: error)
|
|
23
|
+
|
|
24
|
+
values
|
|
25
|
+
rescue OptionParser::ParseError
|
|
26
|
+
raise ArgumentError, "参数无效:并发数须为 1 至 50,抽样须为 1 至 5;使用 --help 查看参数", cause: nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.parser_for(values, output:, program:)
|
|
30
|
+
OptionParser.new do |opts|
|
|
31
|
+
opts.banner = "用法:ruby #{File.basename(program)} [选项](默认全量)"
|
|
32
|
+
add_settings(opts, values)
|
|
33
|
+
add_execution(opts, values)
|
|
34
|
+
opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
|
|
35
|
+
opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
|
|
36
|
+
opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
|
|
37
|
+
opts.on("--json", "在 STDOUT 输出计划和结果 JSON") { values[:json] = true }
|
|
38
|
+
opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
|
|
39
|
+
opts.on("-h", "--help", "显示帮助") { output.puts opts; values[:help] = true }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.add_execution(opts, values)
|
|
44
|
+
opts.on("-c", "--concurrency N", Integer, "并发设备数,1 至 50") do |count|
|
|
45
|
+
raise OptionParser::InvalidArgument unless (1..Settings::MAX_CONCURRENCY).cover?(count)
|
|
46
|
+
|
|
47
|
+
values[:environment]["NC_CONCURRENCY"] = count.to_s
|
|
48
|
+
end
|
|
49
|
+
opts.on("--sample N", "--limit-per-vendor N", Integer, "每厂商抽样 1 至 5 台") do |count|
|
|
50
|
+
Planner.validate_limit!(count)
|
|
51
|
+
values[:limit] = count
|
|
52
|
+
end
|
|
53
|
+
opts.on("--tftp-root DIR", "本机 TFTP 服务器文件目录") { |path| values[:tftp_root] = path }
|
|
54
|
+
opts.on("--all", "选择全部就绪设备") { values[:all] = true }
|
|
55
|
+
opts.on("--success-policy POLICY", %w[strict selected verified], "成功判定:strict / selected / verified(TFTP 文件核验)") do |policy|
|
|
56
|
+
values[:success_policy] = policy.to_sym
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.settings(options, env: ENV)
|
|
61
|
+
raise ArgumentError, "--all 不能与抽样同时使用" if options[:all] && options[:limit]
|
|
62
|
+
|
|
63
|
+
environment = environment(options, env: env)
|
|
64
|
+
path = environment["NC_CONFIG"]
|
|
65
|
+
defaults = path && !path.empty? ? ConfigFile.load(path) : {}
|
|
66
|
+
overrides = {}
|
|
67
|
+
{ source: "NETDISCO_SOURCE", query: "NETDISCO_QUERY", query_params: "NETDISCO_QUERY_PARAMS",
|
|
68
|
+
max_script_output_bytes: "NC_MAX_SCRIPT_OUTPUT_BYTES", host: "NC_INCLUDE_HOSTS",
|
|
69
|
+
limit: "NC_SAMPLE_PER_VENDOR" }.each do |option, key|
|
|
70
|
+
overrides[key] = options[option].to_s if options.key?(option)
|
|
71
|
+
end
|
|
72
|
+
overrides["NC_SAMPLE_PER_VENDOR"] = nil if options[:all]
|
|
73
|
+
Settings.new(env: environment, defaults: defaults, overrides: overrides)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.add_settings(opts, values)
|
|
77
|
+
{
|
|
78
|
+
"--username USER" => ["NC_DEVICE_USERNAME", "设备登录用户名"],
|
|
79
|
+
"--password PASSWORD" => ["NC_DEVICE_PASSWORD", "设备登录密码(可改用 --ask-password)"],
|
|
80
|
+
"--enable-password PASSWORD" => ["NC_ENABLE_PASSWORD", "设备提权密码"],
|
|
81
|
+
"--netdisco-url URL" => ["NETDISCO_URL", "Netdisco 地址"],
|
|
82
|
+
"--netdisco-username USER" => ["NETDISCO_USERNAME", "Netdisco 用户名"],
|
|
83
|
+
"--netdisco-password PASSWORD" => ["NETDISCO_PASSWORD", "Netdisco 密码"],
|
|
84
|
+
"--directory DIR" => ["NC_BACKUP_DIRECTORY", "备份目录,相对项目根目录"],
|
|
85
|
+
"--config FILE" => ["NC_CONFIG", "YAML 配置,相对项目根目录"]
|
|
86
|
+
}.each do |flag, (key, description)|
|
|
87
|
+
opts.on(flag, description) do |value|
|
|
88
|
+
raise OptionParser::InvalidArgument if value.empty?
|
|
89
|
+
|
|
90
|
+
values[:environment][key] = value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
opts.on("--host-key-policy POLICY", %w[strict accept_new replace], "主机密钥策略:strict / accept_new / replace(变化时自动替换)") do |policy|
|
|
94
|
+
values[:environment]["NC_HOST_KEY_POLICY"] = policy
|
|
95
|
+
end
|
|
96
|
+
opts.on("--known-hosts FILE", "持久保存主机密钥的文件") do |path|
|
|
97
|
+
values[:environment]["NC_KNOWN_HOSTS"] = path
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.environment(options, env: ENV)
|
|
102
|
+
result = env.to_h.dup
|
|
103
|
+
overrides = options.fetch(:environment)
|
|
104
|
+
# 显式设备参数覆盖对应厂商默认值;未指定的字段继续按原凭据规则解析。
|
|
105
|
+
{ "NC_DEVICE_USERNAME" => "USERNAME", "NC_DEVICE_PASSWORD" => "PASSWORD",
|
|
106
|
+
"NC_ENABLE_PASSWORD" => "ENABLE_PASSWORD" }.each do |key, suffix|
|
|
107
|
+
next unless overrides.key?(key)
|
|
108
|
+
|
|
109
|
+
result.delete_if do |name, _|
|
|
110
|
+
name.start_with?("NC_") && name.end_with?("_#{suffix}") &&
|
|
111
|
+
(suffix != "PASSWORD" || !name.end_with?("_ENABLE_PASSWORD"))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
if overrides.key?("NETDISCO_USERNAME") || overrides.key?("NETDISCO_PASSWORD")
|
|
115
|
+
result.delete("NETDISCO_API_KEY")
|
|
116
|
+
end
|
|
117
|
+
result.merge(overrides)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.read_password(values, flag, key, label, input:, output:)
|
|
121
|
+
return unless values[flag]
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
|
|
124
|
+
raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless input.tty?
|
|
125
|
+
require "io/console"
|
|
126
|
+
output.print "#{label}:"
|
|
127
|
+
output.flush
|
|
128
|
+
password = input.noecho(&:gets)&.chomp
|
|
129
|
+
output.puts
|
|
130
|
+
raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
|
|
131
|
+
|
|
132
|
+
values[:environment][key] = password
|
|
133
|
+
end
|
|
134
|
+
private_class_method :read_password, :parser_for
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|