net-connector 0.17.1 → 0.18.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 +16 -0
- data/docs/architecture.md +6 -0
- data/docs/usage.md +4 -0
- data/docs/usage.zh-CN.md +4 -0
- data/lib/net/connector/collect/progress.rb +21 -21
- data/lib/net/connector/device/base.rb +12 -8
- data/lib/net/connector/device/save_config.rb +2 -2
- data/lib/net/connector/engine/execution.rb +37 -10
- data/lib/net/connector/engine/known_hosts.rb +1 -1
- data/lib/net/connector/engine/log/messages.rb +16 -16
- data/lib/net/connector/engine/session.rb +5 -4
- data/lib/net/connector/engine/transports.rb +60 -41
- data/lib/net/connector/inventory/filters.rb +2 -2
- data/lib/net/connector/netdisco/cli/backup.rb +6 -6
- data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
- data/lib/net/connector/netdisco/cli/help.rb +16 -16
- data/lib/net/connector/netdisco/cli/options.rb +44 -44
- data/lib/net/connector/netdisco/cli/setup.rb +18 -18
- data/lib/net/connector/netdisco/cli/topology.rb +21 -21
- data/lib/net/connector/netdisco/cli.rb +24 -24
- data/lib/net/connector/netdisco/config/defaults.rb +1 -1
- data/lib/net/connector/netdisco/config/initializer.rb +15 -15
- data/lib/net/connector/netdisco/progress.rb +17 -17
- data/lib/net/connector/netdisco/report/store.rb +3 -1
- data/lib/net/connector/netdisco/report/text.rb +15 -15
- data/lib/net/connector/netdisco/ssh/archive/client.rb +8 -6
- data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
- data/lib/net/connector/netdisco/tftp/archive.rb +8 -4
- data/lib/net/connector/netdisco/topology/files.rb +8 -8
- data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
- data/lib/net/connector/progress.rb +36 -36
- data/lib/net/connector/resources.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +3 -2
- data/lib/net/connector/storage/directory.rb +28 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +1 -1
- data/lib/net/connector/vendor/hillstone/tftp.rb +1 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c8e0db79f9b795cc321cb29571f62e0abd559273b7d09c7598be25eb4141c27
|
|
4
|
+
data.tar.gz: b96f4f50c83bbc8f94a16b43afcd62eae341380342ce975640e03ddabf42132c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb6aff24a1892a671e6f6263c6fe1d49894bfdc4748ddd9726db5442bc891cf6fd1bbced58f7fd4d468259e3dd2db88b11e9457f514fb02429061fa6b74bea75
|
|
7
|
+
data.tar.gz: 468c0dad7b32ee3e1d7f5f7969afecd37a4199d92b117bced55674c748e21fa3550d390427800cbbb8f9c3b0d8ed9008250118c254be54db9d8d24500e572e48
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.18.0 - 2026-10-23
|
|
6
|
+
|
|
7
|
+
- Add on_receipt completion callbacks to command, script, save and custom operations, retaining completed responses after later logging, processing, budget or cancellation failures.
|
|
8
|
+
- Retain PTY channels after incomplete cleanup and retry cleanup before reopening or recovering a connection.
|
|
9
|
+
- Use owned Expect session scopes during SSH authentication so initialization cancellation releases terminal handles and cleanup failures preserve the authentication error.
|
|
10
|
+
- Use English diagnostics, CLI help and reports, and consume string-keyed progress documents from Runner 0.2.
|
|
11
|
+
|
|
12
|
+
## 0.17.2 - 2026-10-23
|
|
13
|
+
|
|
14
|
+
- Migrate shared CLI entrypoints to ctx_cli 0.2.0 names without legacy aliases.
|
|
15
|
+
- Synchronize new directory ancestry across batch reports and local/SSH archives; synchronize uploaded-file cleanup.
|
|
16
|
+
- Keep host-key comparison and vendor parsing independent of host field separators.
|
|
17
|
+
- Reuse ctx_cli configuration, initialization, terminal and option boundaries without changing backup/TFTP/topology policies.
|
|
18
|
+
|
|
3
19
|
## 0.17.1 - 2026-10-22
|
|
4
20
|
|
|
5
21
|
- Delegate shared configuration, private initialization, option groups and terminal interaction to ctx_cli 0.1.0.
|
data/docs/architecture.md
CHANGED
|
@@ -206,8 +206,14 @@ Fleet 可把回执与本地普通文件的时间、稳定性、摘要及目录
|
|
|
206
206
|
|
|
207
207
|
提示符和交互标记是 Ruby 正则表达式,应短到能放入未匹配尾部;流式适配器不支持需要无限长历史的模式。收集输出的上限与匹配窗口上限不同。终端渲染器只处理常见行编辑控制符,不是完整屏幕终端模拟器。`Profile#terminal_size` 使用 `[宽, 高]`,PTY 适配器转换为 Ruby 的 `[行, 列]`。
|
|
208
208
|
|
|
209
|
+
传输关闭先撤下活跃信道,使连接不可用;尚未回收的信道由内部 `@closing_channel` 保留。`hard_close` 正常返回且当前进程拥有的子进程已退出后才释放引用;异常、非局部退出及有界等待后仍存活的子进程都保留清理责任,后者明确报告 IOError。后续 `close` 重试原信道,完成后不重复清理。fork 副本仅关闭继承句柄,不要求回收父进程拥有的子进程。
|
|
210
|
+
|
|
211
|
+
重新打开会先完成旧资源回收,失败时不创建新进程或 SSH 信任快照。私有 `close_resources` 供关闭与打开共用;公开 `close` 停止日志交付,而打开时保留新连接已配置的日志目标。SSH 临时信任快照仍只处置一次。连接恢复只有在旧传输及日志清理成功后才能替换传输对象;失败仍报告原连接错误,保留旧所有者供后续关闭,不重放设备命令。注入信道工厂须遵守现有 Expect 的 `hard_close` / `alive?` 生命周期协议。
|
|
212
|
+
|
|
209
213
|
PTY 输出上限直接读取信道公开的 `buffer_bytesize` 标量,避免正常分片反复取得累计正文副本;只有实际超限时才获取错误正文,关闭日志也不会关闭上限检查。传输与 SSH 归档认证统一使用当前 `Session`、`transcript`、`spawn(raw: true)` 和 `expect` 契约,不探测或回退到旧接口。运行要求为 expect-pty 0.9.4+、Ruby 3.4+。
|
|
210
214
|
|
|
215
|
+
SSH 归档认证借用 `Expect.spawn` 块中交付的私有会话;初始化与交接期间取消的回收责任由 Expect 工厂持有,不在连接器重复构造、登记和 ensure 清理。普通清理错误不能替换认证主异常;没有主异常时仍传播清理错误,新发生的致命清理异常沿用 Expect 契约。密码仍只发送一次,15 秒对话预算、输出隐私及调用方的主连接核对保持原有职责。
|
|
216
|
+
|
|
211
217
|
`prepare_command` 钩子返回 `Command` 表示继续执行,返回 nil 表示跳过。其他返回值在替代命令发送前以 `ScriptError` 拒绝,保留原脚本位置和此前完成步骤,诊断不展开非法值。钩子内部主动执行的查询仍遵循原有执行与输出预算边界。
|
|
212
218
|
|
|
213
219
|
`max_script_output_bytes` 为正 Integer 或 nil。计数属于每个 Execution,主命令以及准备、提示符和后处理钩子的 `Execution#execute_command` 共用计数,包含原始分页标记和最终提示符,不能用 `capture: false` 绕过。登录和提权认证保留各自原有单响应上限。每次脚本新建计数器;一次租约中的多个脚本、多个设备和多个批次不共用它。
|
data/docs/usage.md
CHANGED
|
@@ -143,6 +143,10 @@ warn "#{result.error.code}: #{result.steps.size} completed commands" if result.f
|
|
|
143
143
|
|
|
144
144
|
`execute_command` runs one command; `execute_script` accepts a `Script` or command array. `Script.load(path)` reads a file. Commands are validated before I/O; completed steps survive later failures. `save_config` explicitly sends save commands; ordinary scripts do not save automatically.
|
|
145
145
|
|
|
146
|
+
Use `on_receipt: ->(step) { ... }` with these methods or `execute_operation` to retain completed responses even when later logging, postprocessing, output-budget checks or cancellation prevent a successful return. It receives each completed `Command::Result` once, before the optional success-progress block. Timeouts and device rejection without a completed step produce no receipt. A receipt confirms the response, not persistence or the intended configuration effect.
|
|
147
|
+
|
|
148
|
+
The receipt callback runs synchronously within the operation and must not reenter the device. Its exception stops subsequent commands; an earlier failure or cancellation keeps precedence. Step output is unredacted business data, like `Result#steps`; persist protected output or safe summaries. Interruptions still propagate, and a killed process cannot deliver callbacks.
|
|
149
|
+
|
|
146
150
|
`result.output` concatenates completed steps as raw bytes without transcoding. Each call returns a mutable binary string; changing it does not change the frozen step outputs. Use `result.config` for the cleaned configuration, or `result.value!` to raise on failure before reading the successful value.
|
|
147
151
|
|
|
148
152
|
Set `max_script_output_bytes:` to bound cumulative raw responses, including additional queries. Default is `nil`. Exceeding it returns `ScriptOutputLimitExceeded` (`:script_output_limit_exceeded`) and stops subsequent commands; the current command may already have executed. `max_output_bytes` separately limits each response. Neither setting is a process-memory limit.
|
data/docs/usage.zh-CN.md
CHANGED
|
@@ -186,6 +186,10 @@ end
|
|
|
186
186
|
|
|
187
187
|
`execute_command` 执行一条命令;`execute_script` 接收 `Script` 或命令数组;`Script.load(path)` 读取脚本文件。所有命令在设备 I/O 前校验,后续步骤失败时仍保留已完成结果,库不会自动重放命令。`save_config` 显式执行厂商保存命令,普通脚本不会自动保存。
|
|
188
188
|
|
|
189
|
+
上述方法及 `execute_operation` 接受 `on_receipt: ->(step) { ... }`,用于固定已收到的完整响应:即使之后的日志、后处理、累计输出检查或取消阻止方法正常返回,已完成的 `Command::Result` 仍交付一次,早于可选的成功进度块。没有完成步骤的超时或设备拒绝不产生回执;回执本身不证明设备保存或预期配置生效。
|
|
190
|
+
|
|
191
|
+
回执回调在操作中同步执行,不得重入设备。回调异常停止后续命令,已有主故障或取消保持优先。步骤正文与 `Result#steps` 一样未脱敏,宿主应保护正文或仅保存安全摘要。取消异常仍继续传播,进程被强制终止时无法保证回调交付。
|
|
192
|
+
|
|
189
193
|
`result.output` 按顺序拼接已完成步骤的原始字节,不执行字符编码转换。每次调用返回可修改的二进制字符串,修改它不会改变已冻结的步骤输出。清理后的配置使用 `result.config`;需要失败时先抛出错误,再读取成功值时使用 `result.value!`。
|
|
190
194
|
|
|
191
195
|
创建连接器时可设置 `max_script_output_bytes: 8 * 1024 * 1024`,限制每个脚本及其追加查询累计收到的原始响应字节;默认 `nil` 保持原有完整输出行为。达到上限后不再发送下一命令,当前响应超过上限时保留刚完成的步骤并返回 `ScriptOutputLimitExceeded`(`code: :script_output_limit_exceeded`)。命令可能已经执行,不会自动重试。原有 `max_output_bytes` 继续限制单次读取响应;累计预算不是进程内存上限,详细计数规则见架构文档。
|
|
@@ -23,46 +23,46 @@ module Net
|
|
|
23
23
|
status = result_status(result)
|
|
24
24
|
code = diagnostic_code(result)
|
|
25
25
|
message = case status
|
|
26
|
-
when
|
|
27
|
-
when
|
|
28
|
-
else "
|
|
26
|
+
when "complete" then "Collection complete; reports saved"
|
|
27
|
+
when "partial" then "Partially complete (#{code})"
|
|
28
|
+
else "Incomplete (#{code})"
|
|
29
29
|
end
|
|
30
|
-
super(host: result.fetch(
|
|
31
|
-
duration_ms: result.fetch(
|
|
30
|
+
super(host: result.fetch("device").fetch("host"), success: status == "complete",
|
|
31
|
+
duration_ms: result.fetch("duration_ms"), message: message, error_code: code&.to_sym)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# 使用持久化摘要作最终判定,不把已处理数量或完整性当作健康结论。
|
|
35
35
|
def finish(summary)
|
|
36
|
-
results = summary.fetch(
|
|
36
|
+
results = summary.fetch("devices")
|
|
37
37
|
counts = results.group_by { |result| result_status(result) }.transform_values(&:size)
|
|
38
|
-
failures = results.reject { |result| result_status(result) ==
|
|
38
|
+
failures = results.reject { |result| result_status(result) == "complete" }
|
|
39
39
|
.group_by { |result| diagnostic_code(result) }.transform_values(&:size)
|
|
40
|
-
super(success: summary.fetch(
|
|
41
|
-
partial: counts.fetch(
|
|
42
|
-
pending = summary.fetch(
|
|
43
|
-
notice("
|
|
44
|
-
notice("
|
|
45
|
-
location(File.join(summary.fetch(
|
|
46
|
-
location(File.join(summary.fetch(
|
|
40
|
+
super(success: summary.fetch("status") == "complete", succeeded: counts.fetch("complete", 0),
|
|
41
|
+
partial: counts.fetch("partial", 0), failed: counts.fetch("failed", 0), skipped: @skipped || 0, failures: failures)
|
|
42
|
+
pending = summary.fetch("pending").size
|
|
43
|
+
notice("Not collected: #{pending} devices; see the batch report") if pending.positive?
|
|
44
|
+
notice("Complete evidence does not establish network health; see the review results in the report.")
|
|
45
|
+
location(File.join(summary.fetch("directory"), "summary.json"))
|
|
46
|
+
location(File.join(summary.fetch("directory"), "summary.txt"), label: "Text report")
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
51
|
# 写入失败的完整快照仍是未交付结果,不能在终端宣称成功。
|
|
52
52
|
def result_status(result)
|
|
53
|
-
return
|
|
53
|
+
return "failed" unless result["report_saved"] == true
|
|
54
54
|
|
|
55
|
-
%
|
|
55
|
+
%w[complete partial].include?(result["status"]) ? result["status"] : "failed"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# 完整维度的提示不属于失败;优先保留会话错误,再选择未完成维度的诊断码。
|
|
59
59
|
def diagnostic_code(result)
|
|
60
|
-
return
|
|
61
|
-
return nil if result[
|
|
62
|
-
return result[
|
|
60
|
+
return "report_not_saved" unless result["report_saved"] == true
|
|
61
|
+
return nil if result["status"] == "complete"
|
|
62
|
+
return result["error"] if result["error"]
|
|
63
63
|
|
|
64
|
-
dimensions = result.fetch(
|
|
65
|
-
dimensions.flat_map { |dimension| dimension.fetch(
|
|
64
|
+
dimensions = result.fetch("dimensions", {}).values.reject { |dimension| dimension["status"] == "complete" }
|
|
65
|
+
dimensions.flat_map { |dimension| dimension.fetch("issues", []) }.first || "collection_incomplete"
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -130,23 +130,26 @@ module Net
|
|
|
130
130
|
def close = @session.close
|
|
131
131
|
|
|
132
132
|
# 将一条文本命令包装成脚本并执行。
|
|
133
|
-
def execute_command(text, **, &)
|
|
134
|
-
|
|
133
|
+
def execute_command(text, on_receipt: nil, **, &)
|
|
134
|
+
script = Script.new([Command.new(text, **)])
|
|
135
|
+
return execute_script(script, &) if on_receipt.nil?
|
|
136
|
+
|
|
137
|
+
execute_script(script, on_receipt: on_receipt, &)
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
# 接收脚本或命令数组,统一交给单次会话执行。
|
|
138
|
-
def execute_script(script, &)
|
|
141
|
+
def execute_script(script, on_receipt: nil, &)
|
|
139
142
|
script = Script.new(script) unless script.is_a?(Script)
|
|
140
|
-
perform_script(script, &)
|
|
143
|
+
perform_script(script, on_receipt: on_receipt, &)
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
# 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
|
|
144
147
|
def with_operation(name, &) = @session.with_operation(name, &)
|
|
145
148
|
|
|
146
149
|
# 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
|
|
147
|
-
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
|
|
150
|
+
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, on_receipt: nil, &finalize)
|
|
148
151
|
perform_script(script, operation: name, prompt: prompt, after_command: after_command,
|
|
149
|
-
privilege: privilege, finalize: finalize)
|
|
152
|
+
privilege: privilege, finalize: finalize, on_receipt: on_receipt)
|
|
150
153
|
end
|
|
151
154
|
|
|
152
155
|
# 返回已确认的提示,供业务层为只读采集绑定准确的结束条件。
|
|
@@ -260,7 +263,8 @@ module Net
|
|
|
260
263
|
|
|
261
264
|
# 在会话锁内运行脚本及业务回调,保留已完成步骤和统一错误边界。
|
|
262
265
|
def perform_script(script, operation: nil, prompt: nil, after_command: nil, privilege: true,
|
|
263
|
-
finalize: nil, &on_step)
|
|
266
|
+
finalize: nil, on_receipt: nil, &on_step)
|
|
267
|
+
raise ArgumentError, "on_receipt must respond to call" unless on_receipt.nil? || on_receipt.respond_to?(:call)
|
|
264
268
|
return Result.new if script.empty?
|
|
265
269
|
|
|
266
270
|
execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
|
|
@@ -269,7 +273,7 @@ module Net
|
|
|
269
273
|
@session.perform(:script) do
|
|
270
274
|
@session.log_script(operation: operation, steps: execution.steps) do
|
|
271
275
|
@session.with_sensitive_output(output_sensitive) { before_batch(execution) }
|
|
272
|
-
result = execution.execute_script(script, &on_step)
|
|
276
|
+
result = execution.execute_script(script, on_receipt: on_receipt, &on_step)
|
|
273
277
|
sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
|
|
274
278
|
@session.with_sensitive_output(sensitive_result) do
|
|
275
279
|
result = finalize_script_result(result, finalize)
|
|
@@ -5,13 +5,13 @@ module Net
|
|
|
5
5
|
# 保存命令及能力入口,响应语义由通用执行器负责。
|
|
6
6
|
module SaveConfig
|
|
7
7
|
# 执行厂商保存配置命令;不支持时返回显式失败结果。
|
|
8
|
-
def save_config
|
|
8
|
+
def save_config(on_receipt: nil)
|
|
9
9
|
if save_commands.empty?
|
|
10
10
|
return Result.new(error: @session.build_error(UnsupportedOperation, "saving configuration is not supported",
|
|
11
11
|
phase: :save))
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
execute_script(save_commands)
|
|
14
|
+
on_receipt.nil? ? execute_script(save_commands) : execute_script(save_commands, on_receipt: on_receipt)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# 返回保存配置命令;空数组表示设备不支持保存。
|
|
@@ -28,7 +28,7 @@ module Net
|
|
|
28
28
|
def sensitive? = @sensitive
|
|
29
29
|
|
|
30
30
|
# 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
|
|
31
|
-
def execute_script(script)
|
|
31
|
+
def execute_script(script, on_receipt: nil)
|
|
32
32
|
script.each do |original_command|
|
|
33
33
|
@session.with_command_redaction(original_command) do
|
|
34
34
|
@current_command = original_command
|
|
@@ -39,15 +39,7 @@ module Net
|
|
|
39
39
|
raise build_error("prepare_command must return a Command or nil") unless command.is_a?(Command)
|
|
40
40
|
|
|
41
41
|
@current_command = command
|
|
42
|
-
|
|
43
|
-
step = nil
|
|
44
|
-
response = execute_command(command) do |received|
|
|
45
|
-
step = Command::Result.new(command: command, output: received.output, prompt: received.prompt,
|
|
46
|
-
duration: Expect.monotonic - started)
|
|
47
|
-
steps << step
|
|
48
|
-
end
|
|
49
|
-
# 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
|
|
50
|
-
@after_command.call(command, response, self)
|
|
42
|
+
step = execute_step(command, on_receipt: on_receipt)
|
|
51
43
|
yield step if block_given?
|
|
52
44
|
validate_response_budget!(@last_executed_command)
|
|
53
45
|
rescue => error
|
|
@@ -97,6 +89,41 @@ module Net
|
|
|
97
89
|
|
|
98
90
|
private
|
|
99
91
|
|
|
92
|
+
# 完成回执独立于成功进度;日志、后处理、预算或取消不撤销已收到的响应。
|
|
93
|
+
def execute_step(command, on_receipt:)
|
|
94
|
+
started = Expect.monotonic
|
|
95
|
+
step, failure = nil, nil
|
|
96
|
+
finished = false
|
|
97
|
+
begin
|
|
98
|
+
response = execute_command(command) do |received|
|
|
99
|
+
step = Command::Result.new(command: command, output: received.output, prompt: received.prompt,
|
|
100
|
+
duration: Expect.monotonic - started)
|
|
101
|
+
steps << step
|
|
102
|
+
end
|
|
103
|
+
@after_command.call(command, response, self)
|
|
104
|
+
finished = true
|
|
105
|
+
step
|
|
106
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 完成事实先交付,原始取消随后继续传播。
|
|
107
|
+
failure = error
|
|
108
|
+
raise
|
|
109
|
+
ensure
|
|
110
|
+
deliver_receipt(step, on_receipt, failure, unwinding: !finished) if step && on_receipt
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 收据处理不能用自身异常替换此前的设备失败或取消,也不自动重复回调。
|
|
115
|
+
def deliver_receipt(step, callback, failure, unwinding:)
|
|
116
|
+
callback.call(step)
|
|
117
|
+
ensure
|
|
118
|
+
# ensure 覆盖回调内的 throw/kill;不能让二次退出替换外层正在传播的取消。
|
|
119
|
+
if unwinding
|
|
120
|
+
raise failure, cause: failure.cause if failure
|
|
121
|
+
|
|
122
|
+
# 仅结束这个回调子方法,让 execute_step 原有的非局部退出继续。
|
|
123
|
+
return # rubocop:disable Lint/EnsureReturn -- 保留调用方正在进行的 throw、return 或线程终止。
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
100
127
|
# 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
|
|
101
128
|
def validate_send_budget!(command)
|
|
102
129
|
return unless @output_limit && @output_bytes >= @output_limit
|
|
@@ -159,7 +159,7 @@ module Net
|
|
|
159
159
|
output, status = Open3.capture2e("ssh-keygen", "-f", path, "-F", @host)
|
|
160
160
|
raise IOError, "reading device host key failed" unless [0, 1].include?(status.exitstatus)
|
|
161
161
|
|
|
162
|
-
output.lines("\n").reject { |line| line.start_with?("#") }.map { |line| line.split[1, 2] }.uniq
|
|
162
|
+
output.lines("\n").reject { |line| line.start_with?("#") }.map { |line| line.split(" ")[1, 2] }.uniq
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
# 从指定快照中移除当前主机条目,不直接修改共享信任文件。
|
|
@@ -11,36 +11,36 @@ module Net
|
|
|
11
11
|
def format(name, fields)
|
|
12
12
|
case name
|
|
13
13
|
when "connect"
|
|
14
|
-
"
|
|
14
|
+
"Connecting to #{fields[:host]}(#{fields[:protocol].to_s.upcase}, user #{fields[:username]})"
|
|
15
15
|
when "connect_failed"
|
|
16
|
-
"
|
|
16
|
+
"Connection failed: #{fields[:message]}(#{fields[:error]})"
|
|
17
17
|
when "login_start"
|
|
18
|
-
"
|
|
18
|
+
"Waiting for the device login prompt"
|
|
19
19
|
when "login_output"
|
|
20
|
-
"
|
|
20
|
+
"Login output (redacted): "
|
|
21
21
|
when "login_complete"
|
|
22
|
-
fields[:status] == "ok" ? "
|
|
23
|
-
"
|
|
22
|
+
fields[:status] == "ok" ? "Login succeeded; device prompt: #{fields[:prompt]}" :
|
|
23
|
+
"Login failed: #{fields[:message]}(#{fields[:error]})"
|
|
24
24
|
when "command_start"
|
|
25
|
-
"
|
|
25
|
+
"Sending command: #{fields[:text]}"
|
|
26
26
|
when "device_output"
|
|
27
|
-
"
|
|
27
|
+
"Device output: "
|
|
28
28
|
when "command_complete"
|
|
29
|
-
fields[:status] == "response_received" ? "
|
|
30
|
-
"
|
|
29
|
+
fields[:status] == "response_received" ? "Command output complete; device prompt received" :
|
|
30
|
+
"Command failed: #{fields[:message]}(#{fields[:error]})"
|
|
31
31
|
when "operation_start"
|
|
32
|
-
"
|
|
32
|
+
"Starting device script"
|
|
33
33
|
when "operation_complete"
|
|
34
|
-
fields[:status] == "completed" ? "
|
|
35
|
-
"
|
|
34
|
+
fields[:status] == "completed" ? "Device script complete" :
|
|
35
|
+
"Device script failed: #{fields[:message]}(#{fields[:error]})"
|
|
36
36
|
when "tftp_backup"
|
|
37
37
|
case fields[:status]
|
|
38
38
|
when "reported_uploaded"
|
|
39
|
-
"TFTP
|
|
39
|
+
"TFTP backup: device reported a successful upload to #{fields[:server]}/#{fields[:path]}; server file has not been verified"
|
|
40
40
|
when "transfer_failed"
|
|
41
|
-
"TFTP
|
|
41
|
+
"TFTP backup failed: device reported a transfer failure; server #{fields[:server]}"
|
|
42
42
|
else
|
|
43
|
-
"TFTP
|
|
43
|
+
"TFTP backup unconfirmed: device did not report a successful upload; server #{fields[:server]}"
|
|
44
44
|
end
|
|
45
45
|
else
|
|
46
46
|
name.to_s
|
|
@@ -404,8 +404,8 @@ module Net
|
|
|
404
404
|
failure = normalize_error(error, phase: phase)
|
|
405
405
|
@log.log_failure(phase == :connect ? "connect_failed" : "login_complete", failure,
|
|
406
406
|
status: "failed", phase: phase, duration_ms: elapsed_ms(started))
|
|
407
|
-
close_preserving_failure
|
|
408
|
-
replacement = attempted_recovery ?
|
|
407
|
+
cleaned = close_preserving_failure
|
|
408
|
+
replacement = cleaned && !attempted_recovery ? @recovery.recover(failure, transport) : nil
|
|
409
409
|
if replacement
|
|
410
410
|
@transport = replacement
|
|
411
411
|
attempted_recovery = true
|
|
@@ -458,12 +458,13 @@ module Net
|
|
|
458
458
|
raise normalize_error(error, phase: :close), cause: nil
|
|
459
459
|
end
|
|
460
460
|
|
|
461
|
-
#
|
|
461
|
+
# 清理结果决定能否移交传输所有权;失败仍保留原操作异常与待清理资源。
|
|
462
462
|
def close_preserving_failure
|
|
463
463
|
close_resources
|
|
464
|
+
true
|
|
464
465
|
rescue Exception # rubocop:disable Lint/RescueException -- 原操作失败时,清理故障或中断均不得覆盖它。
|
|
465
466
|
# 所有清理路径完成后,原始操作异常仍然是权威结果。
|
|
466
|
-
|
|
467
|
+
false
|
|
467
468
|
end
|
|
468
469
|
end
|
|
469
470
|
end
|
|
@@ -21,14 +21,17 @@ module Net
|
|
|
21
21
|
@configuration = configuration
|
|
22
22
|
@channel_factory = channel_factory
|
|
23
23
|
@terminal_size = terminal_size
|
|
24
|
+
@channel_owner = Process.pid
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# 打开伪终端、设置终端大小并启动设备进程。
|
|
27
28
|
def open
|
|
28
29
|
failure = nil
|
|
29
30
|
configuration.validate_endpoint!
|
|
31
|
+
close_resources if @channel || @closing_channel
|
|
30
32
|
# 构造期间的阻塞仍可取消;返回交接先登记信道,再交付异步中断。
|
|
31
33
|
Thread.handle_interrupt(Exception => :on_blocking) do
|
|
34
|
+
@channel_owner = Process.pid
|
|
32
35
|
@channel = if @channel_factory
|
|
33
36
|
@channel_factory.call
|
|
34
37
|
else
|
|
@@ -89,21 +92,10 @@ module Net
|
|
|
89
92
|
@log_target = target
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
#
|
|
95
|
+
# 停止日志交付并关闭信道;失败资源继续由内部账本持有。
|
|
93
96
|
def close
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Thread.handle_interrupt(Exception => :never) do
|
|
97
|
-
channel = @channel
|
|
98
|
-
@channel = nil
|
|
99
|
-
@log_target = nil
|
|
100
|
-
channel&.hard_close
|
|
101
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
|
|
102
|
-
failure = error
|
|
103
|
-
raise
|
|
104
|
-
end
|
|
105
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留最先观察到的故障。
|
|
106
|
-
raise failure || error, cause: nil
|
|
97
|
+
@log_target = nil
|
|
98
|
+
close_resources
|
|
107
99
|
end
|
|
108
100
|
|
|
109
101
|
# 把人工交互结果转换为设备 EOF、超时或输入状态。
|
|
@@ -119,6 +111,30 @@ module Net
|
|
|
119
111
|
|
|
120
112
|
private
|
|
121
113
|
|
|
114
|
+
# 活跃信道先退休,只有所属进程确认退出后才释放清理责任;失败可由后续关闭重试。
|
|
115
|
+
# 打开前也使用同一路径,但保留调用方为新连接设置的日志目标。
|
|
116
|
+
def close_resources
|
|
117
|
+
failure = nil
|
|
118
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
119
|
+
@closing_channel = @channel if @channel
|
|
120
|
+
@channel = nil
|
|
121
|
+
status = @closing_channel&.hard_close
|
|
122
|
+
# fork 副本只关闭继承句柄;父进程拥有的子进程不能成为本副本的回收前提。
|
|
123
|
+
if @channel_owner == Process.pid && @closing_channel&.alive?
|
|
124
|
+
raise IOError, "PTY child is still running after hard close"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@closing_channel = nil
|
|
128
|
+
@channel_owner = nil
|
|
129
|
+
status
|
|
130
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
|
|
131
|
+
failure = error
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 先保存完成边界,再交付最先观察到的故障。
|
|
135
|
+
raise failure || error, cause: nil
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
# 已有操作异常时仍尝试清理;次级故障或中断不能覆盖原始失败。
|
|
123
139
|
def close_preserving_failure
|
|
124
140
|
close
|
|
@@ -154,6 +170,7 @@ module Net
|
|
|
154
170
|
def open
|
|
155
171
|
failure = nil
|
|
156
172
|
configuration.validate_endpoint!
|
|
173
|
+
close_resources if @channel || @closing_channel || @known_hosts
|
|
157
174
|
unless configuration.host_key_policy == :strict
|
|
158
175
|
# 临时信任文件的返回交接与信道遵守同一所有权边界。
|
|
159
176
|
Thread.handle_interrupt(Exception => :on_blocking) do
|
|
@@ -175,33 +192,6 @@ module Net
|
|
|
175
192
|
@replace_host_key = false
|
|
176
193
|
end
|
|
177
194
|
|
|
178
|
-
# 同时回收 SSH 通道与私有信任快照,保留先发生的关闭故障。
|
|
179
|
-
def close
|
|
180
|
-
failure = nil
|
|
181
|
-
# 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
|
|
182
|
-
Thread.handle_interrupt(Exception => :never) do
|
|
183
|
-
known_hosts = @known_hosts
|
|
184
|
-
@known_hosts = nil
|
|
185
|
-
begin
|
|
186
|
-
super
|
|
187
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
188
|
-
failure = error
|
|
189
|
-
raise
|
|
190
|
-
ensure
|
|
191
|
-
begin
|
|
192
|
-
known_hosts&.close
|
|
193
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
194
|
-
unless failure
|
|
195
|
-
failure = error
|
|
196
|
-
raise
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
|
|
202
|
-
raise failure || error, cause: nil
|
|
203
|
-
end
|
|
204
|
-
|
|
205
195
|
# 返回 SSH 协议标识。
|
|
206
196
|
def protocol = :ssh
|
|
207
197
|
|
|
@@ -236,6 +226,35 @@ module Net
|
|
|
236
226
|
@replace_host_key = true
|
|
237
227
|
end
|
|
238
228
|
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# 同时回收 SSH 通道与私有信任快照,保留先发生的关闭故障。
|
|
232
|
+
def close_resources
|
|
233
|
+
failure = nil
|
|
234
|
+
# 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
|
|
235
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
236
|
+
known_hosts = @known_hosts
|
|
237
|
+
@known_hosts = nil
|
|
238
|
+
begin
|
|
239
|
+
super
|
|
240
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
241
|
+
failure = error
|
|
242
|
+
raise
|
|
243
|
+
ensure
|
|
244
|
+
begin
|
|
245
|
+
known_hosts&.close
|
|
246
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
247
|
+
unless failure
|
|
248
|
+
failure = error
|
|
249
|
+
raise
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
|
|
255
|
+
raise failure || error, cause: nil
|
|
256
|
+
end
|
|
257
|
+
|
|
239
258
|
end
|
|
240
259
|
|
|
241
260
|
# Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
|
|
@@ -33,7 +33,7 @@ module Net
|
|
|
33
33
|
def self.add_options(parser, single_host: true, &accept)
|
|
34
34
|
flags = single_host ? {"--host IP" => "hosts"}.merge(FLAGS) : FLAGS
|
|
35
35
|
flags.each do |flag, key|
|
|
36
|
-
parser.on(flag, "
|
|
36
|
+
parser.on(flag, "Filter inventory; combine conditions of the same type with OR; exclusions take precedence") do |value|
|
|
37
37
|
items = value.split(",", -1).map(&:strip)
|
|
38
38
|
raise OptionParser::InvalidArgument, "filter list contains empty items" if items.empty? || items.any?(&:empty?)
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ module Net
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
MODES.each do |key|
|
|
44
|
-
parser.on("--#{key.tr("_", "-")} MODE", %w[contains prefix glob], "
|
|
44
|
+
parser.on("--#{key.tr("_", "-")} MODE", %w[contains prefix glob], "Matching mode: contains / prefix / glob") { |value| accept.call(key, value) }
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -58,13 +58,13 @@ module Net
|
|
|
58
58
|
finish_interrupted(fleet, progress, options, batch_directory, plan, settings)
|
|
59
59
|
130
|
|
60
60
|
rescue EOFError
|
|
61
|
-
@error.puts "
|
|
61
|
+
@error.puts "Configuration setup is incomplete; stopped. Check the files listed above."
|
|
62
62
|
2
|
|
63
63
|
rescue ArgumentError, Inventory::Error => exception
|
|
64
|
-
@error.puts "
|
|
64
|
+
@error.puts "Backup argument or inventory error: #{exception.message}"
|
|
65
65
|
2
|
|
66
66
|
rescue StandardError => exception
|
|
67
|
-
@error.puts "
|
|
67
|
+
@error.puts "Backup incomplete (#{exception.class})"
|
|
68
68
|
2
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -111,10 +111,10 @@ module Net
|
|
|
111
111
|
# 清单离线回退和本地保存故障单独提示,不覆盖设备操作结果。
|
|
112
112
|
def inventory_warning(fleet)
|
|
113
113
|
if fleet.inventory_status && fleet.inventory_status[:source] == :stale_cache
|
|
114
|
-
@error.puts "
|
|
114
|
+
@error.puts "Inventory refresh failed; using the previous cache (#{fleet.inventory_status[:refresh_error_code]})"
|
|
115
115
|
end
|
|
116
116
|
return unless fleet.inventory_status && fleet.inventory_status[:write_error_type]
|
|
117
|
-
@error.puts "
|
|
117
|
+
@error.puts "Inventory cache write failed (#{fleet.inventory_status[:write_error_type]})"
|
|
118
118
|
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -188,7 +188,7 @@ module Net
|
|
|
188
188
|
progress.finish(report)
|
|
189
189
|
progress.location(report.report_location) if report.report_location
|
|
190
190
|
summary_path = File.join(batch_directory, "summary.txt")
|
|
191
|
-
progress.location(summary_path, label: "
|
|
191
|
+
progress.location(summary_path, label: "Text report") if File.file?(summary_path)
|
|
192
192
|
updated = record_progress_error(report, progress)
|
|
193
193
|
unless updated.equal?(report)
|
|
194
194
|
# 末尾显示也可能首次失败,只补写诊断,不重复设备任务或事件。
|
|
@@ -18,10 +18,10 @@ module Net
|
|
|
18
18
|
begin
|
|
19
19
|
values = JSON.parse(input.gets("\n") || "")
|
|
20
20
|
rescue JSON::ParserError
|
|
21
|
-
raise ArgumentError, "
|
|
21
|
+
raise ArgumentError, "Invalid credentials JSON", cause: nil
|
|
22
22
|
end
|
|
23
23
|
keys = %w[netdisco_username netdisco_password device_username device_password]
|
|
24
|
-
raise ArgumentError, "
|
|
24
|
+
raise ArgumentError, "Four nonempty credential strings are required" unless values.is_a?(Hash) && keys.all? { |key| values[key].is_a?(String) && !values[key].strip.empty? }
|
|
25
25
|
|
|
26
26
|
inventory = settings.inventory(credentials: {
|
|
27
27
|
username: values.fetch("netdisco_username"), password: values.fetch("netdisco_password")
|