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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/docs/architecture.md +6 -0
  4. data/docs/usage.md +4 -0
  5. data/docs/usage.zh-CN.md +4 -0
  6. data/lib/net/connector/collect/progress.rb +21 -21
  7. data/lib/net/connector/device/base.rb +12 -8
  8. data/lib/net/connector/device/save_config.rb +2 -2
  9. data/lib/net/connector/engine/execution.rb +37 -10
  10. data/lib/net/connector/engine/known_hosts.rb +1 -1
  11. data/lib/net/connector/engine/log/messages.rb +16 -16
  12. data/lib/net/connector/engine/session.rb +5 -4
  13. data/lib/net/connector/engine/transports.rb +60 -41
  14. data/lib/net/connector/inventory/filters.rb +2 -2
  15. data/lib/net/connector/netdisco/cli/backup.rb +6 -6
  16. data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
  17. data/lib/net/connector/netdisco/cli/help.rb +16 -16
  18. data/lib/net/connector/netdisco/cli/options.rb +44 -44
  19. data/lib/net/connector/netdisco/cli/setup.rb +18 -18
  20. data/lib/net/connector/netdisco/cli/topology.rb +21 -21
  21. data/lib/net/connector/netdisco/cli.rb +24 -24
  22. data/lib/net/connector/netdisco/config/defaults.rb +1 -1
  23. data/lib/net/connector/netdisco/config/initializer.rb +15 -15
  24. data/lib/net/connector/netdisco/progress.rb +17 -17
  25. data/lib/net/connector/netdisco/report/store.rb +3 -1
  26. data/lib/net/connector/netdisco/report/text.rb +15 -15
  27. data/lib/net/connector/netdisco/ssh/archive/client.rb +8 -6
  28. data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
  29. data/lib/net/connector/netdisco/tftp/archive.rb +8 -4
  30. data/lib/net/connector/netdisco/topology/files.rb +8 -8
  31. data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
  32. data/lib/net/connector/progress.rb +36 -36
  33. data/lib/net/connector/resources.rb +1 -1
  34. data/lib/net/connector/storage/batch_directory.rb +3 -2
  35. data/lib/net/connector/storage/directory.rb +28 -0
  36. data/lib/net/connector/storage.rb +1 -0
  37. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  38. data/lib/net/connector/vendor/hillstone/tftp.rb +1 -1
  39. data/lib/net/connector/version.rb +1 -1
  40. metadata +7 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33503c92ccbe8f7000652c0b47b967a8f2f40e6c9edd7081ba305c3246efe820
4
- data.tar.gz: f2d4c434caab1b888d1b4cd9bf25a0def4e7e26eb63b2e6e5e93d8b56cc34c0d
3
+ metadata.gz: 0c8e0db79f9b795cc321cb29571f62e0abd559273b7d09c7598be25eb4141c27
4
+ data.tar.gz: b96f4f50c83bbc8f94a16b43afcd62eae341380342ce975640e03ddabf42132c
5
5
  SHA512:
6
- metadata.gz: e8c126666632d1f4a708cbcc42c1df5ad434d37978a55fe2db363e3628f2c7e2779192d05ad7d8989933d389d92a6de520669a93a47c46209816a85d423abcca
7
- data.tar.gz: ad704438b1ed05942371fe3f57a7ebf66f563261406682013b18f57c8cfbc8b01a190a41a685a2991f4d53ff3e2912d2cf8c8e272b593bc83e1a439c821ef349
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 :complete then "采集完成;报告已保存"
27
- when :partial then "部分成功(#{code})"
28
- else "未完成(#{code})"
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(:device).fetch("host"), success: status == :complete,
31
- duration_ms: result.fetch(:duration_ms), message: message, error_code: code)
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(:devices)
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) == :complete }
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(: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("未采集 #{pending} 台;详见批次报告") if pending.positive?
44
- notice("证据完整性不代表网络健康,审查结果见报告。")
45
- location(File.join(summary.fetch(:directory), "summary.json"))
46
- location(File.join(summary.fetch(:directory), "summary.txt"), label: "文本报告")
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 :failed unless result[:report_saved] == true
53
+ return "failed" unless result["report_saved"] == true
54
54
 
55
- %i[complete partial].include?(result[:status]) ? result[:status] : :failed
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 :report_not_saved unless result[:report_saved] == true
61
- return nil if result[:status] == :complete
62
- return result[:error] if result[:error]
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(:dimensions, {}).values.reject { |dimension| dimension[:status] == :complete }
65
- dimensions.flat_map { |dimension| dimension.fetch(:issues, []) }.first || :collection_incomplete
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
- execute_script(Script.new([Command.new(text, **)]), &)
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
- started = Expect.monotonic
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
- "开始连接 #{fields[:host]}(#{fields[:protocol].to_s.upcase},账号 #{fields[:username]})"
14
+ "Connecting to #{fields[:host]}(#{fields[:protocol].to_s.upcase}, user #{fields[:username]})"
15
15
  when "connect_failed"
16
- "连接失败:#{fields[:message]}(#{fields[:error]})"
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" ? "登录成功,设备提示符:#{fields[:prompt]}" :
23
- "登录失败:#{fields[:message]}(#{fields[:error]})"
22
+ fields[:status] == "ok" ? "Login succeeded; device prompt: #{fields[:prompt]}" :
23
+ "Login failed: #{fields[:message]}(#{fields[:error]})"
24
24
  when "command_start"
25
- "下发命令:#{fields[:text]}"
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
- "命令执行失败:#{fields[:message]}(#{fields[:error]})"
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
- "设备脚本处理失败:#{fields[:message]}(#{fields[:error]})"
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 备份:设备报告上传成功,目标 #{fields[:server]}/#{fields[:path]};服务器文件尚未核验"
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 备份失败:设备报告传输失败,服务器 #{fields[:server]}"
41
+ "TFTP backup failed: device reported a transfer failure; server #{fields[:server]}"
42
42
  else
43
- "TFTP 备份未确认:设备未报告上传成功,服务器 #{fields[:server]}"
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 ? nil : @recovery.recover(failure, transport)
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
- nil
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
- failure = nil
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, "筛选已有清单,同类条件取并集,排除优先") do |value|
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], "匹配方式 contains / prefix / glob") { |value| accept.call(key, value) }
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 "备份参数或清单错误:#{exception.message}"
64
+ @error.puts "Backup argument or inventory error: #{exception.message}"
65
65
  2
66
66
  rescue StandardError => exception
67
- @error.puts "备份未完成(#{exception.class})"
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 "清单刷新失败,使用历史缓存(#{fleet.inventory_status[:refresh_error_code]})"
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 "清单缓存写入失败(#{fleet.inventory_status[:write_error_type]})"
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: "文本报告") if File.file?(summary_path)
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, "凭据 JSON 无效", cause: nil
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, "需要四个非空凭据字符串" unless values.is_a?(Hash) && keys.all? { |key| values[key].is_a?(String) && !values[key].strip.empty? }
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")