net-connector 0.17.2 → 0.18.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/architecture.md +14 -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/error/metadata.rb +2 -1
  10. data/lib/net/connector/engine/execution.rb +37 -10
  11. data/lib/net/connector/engine/known_hosts.rb +19 -6
  12. data/lib/net/connector/engine/log/messages.rb +16 -16
  13. data/lib/net/connector/engine/session.rb +5 -4
  14. data/lib/net/connector/engine/transports.rb +60 -41
  15. data/lib/net/connector/inventory/filters.rb +2 -2
  16. data/lib/net/connector/netdisco/cli/backup.rb +6 -6
  17. data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
  18. data/lib/net/connector/netdisco/cli/help.rb +16 -16
  19. data/lib/net/connector/netdisco/cli/options.rb +44 -44
  20. data/lib/net/connector/netdisco/cli/setup.rb +18 -18
  21. data/lib/net/connector/netdisco/cli/topology.rb +21 -21
  22. data/lib/net/connector/netdisco/cli.rb +24 -24
  23. data/lib/net/connector/netdisco/config/defaults.rb +1 -1
  24. data/lib/net/connector/netdisco/config/initializer.rb +13 -13
  25. data/lib/net/connector/netdisco/progress.rb +17 -17
  26. data/lib/net/connector/netdisco/report/text.rb +15 -15
  27. data/lib/net/connector/netdisco/ssh/archive/client.rb +109 -79
  28. data/lib/net/connector/netdisco/ssh/archive/protocol.rb +107 -0
  29. data/lib/net/connector/netdisco/ssh/archive/worker.py +35 -19
  30. data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
  31. data/lib/net/connector/netdisco/topology/files.rb +8 -8
  32. data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
  33. data/lib/net/connector/progress.rb +36 -36
  34. data/lib/net/connector/resources.rb +1 -1
  35. data/lib/net/connector/storage/atomic_file.rb +60 -3
  36. data/lib/net/connector/version.rb +1 -1
  37. metadata +11 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aadffbf2db023105e9b507fb732a89bd710207adf28456b2edb5184eb245fedf
4
- data.tar.gz: 72cc71886fd9c6dce77171af3c8300f5f84af7c7e8192acd335e132c942b2b3c
3
+ metadata.gz: 87b108e7525f64a9df67db08537eafa2e3c2845797fccea6fe09fdb5701696fd
4
+ data.tar.gz: f6c6bea9be822222feb3056e2866b39d35209b4fe06b108613c059261b987467
5
5
  SHA512:
6
- metadata.gz: 4d59d23ae05999d469f96837baf6b5f2eca3518104b8769947c36c860788627f3f9cb35c4e05151e364e51f159f0b171905f64fbfd30b963d096bf176cbf35d5
7
- data.tar.gz: 0b0137d959ecc3be69fd72fbed2bcc562e95c615ab13a786c4485dcf107ff9de30376e2c731051edbac6f297b496be1cdc97c2ec321066c955c9a3a88ef369f6
6
+ metadata.gz: de0022df96e76aeeec4bd916c7abd27f9fb4646269d552846d07d1c575a05593fbf3605a25f271574a48202e81100986fb5508e44c162c6b1668c158bcad9ae9
7
+ data.tar.gz: 749ba87f2702d94098e02b3cf7f9fae6e00557f219c796e453028b7d67db07f1c7469985ad8275818de71b50a1bfc7b2319936e502817f7057f5b3a59c670cd7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.18.1 - 2026-10-24
6
+
7
+ - Stream SSH archive bodies through bounded binary frames and incremental hashes instead of whole-file base64 copies; preserve deadlines, verified server receipts and committed local paths.
8
+ - Retain cleanup ownership across cancellation, failed closes and forked KnownHosts snapshots; reject inherited trust commits before file or lock access.
9
+ - Preserve the primary exception or nonlocal exit when atomic-file and archive-process cleanup also fails.
10
+ - Expose JSON Pointer field provenance through configuration previews and leave runtime dependency versions to host lockfiles.
11
+
12
+ ## 0.18.0 - 2026-10-23
13
+
14
+ - Add on_receipt completion callbacks to command, script, save and custom operations, retaining completed responses after later logging, processing, budget or cancellation failures.
15
+ - Retain PTY channels after incomplete cleanup and retry cleanup before reopening or recovering a connection.
16
+ - Use owned Expect session scopes during SSH authentication so initialization cancellation releases terminal handles and cleanup failures preserve the authentication error.
17
+ - Use English diagnostics, CLI help and reports, and consume string-keyed progress documents from Runner 0.2.
18
+
5
19
  ## 0.17.2 - 2026-10-23
6
20
 
7
21
  - Migrate shared CLI entrypoints to ctx_cli 0.2.0 names without legacy aliases.
data/docs/architecture.md CHANGED
@@ -88,12 +88,20 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
88
88
 
89
89
  `KnownHosts` 在跨进程锁内重读和合并已认证的会话密钥,条目索引保留原顺序及已有重复行,新增行只登记一次。提交复用 `AtomicFile`;目录同步失败保留已替换文件并报告 `file_persistence_unconfirmed`,不能触发再次登记。不安全的非普通共享信任文件报告 `known_hosts_unsafe`,strict 模式仍由 OpenSSH 管理。写入器仅在实际提交时加载,纯会话引擎加载不引入存储层。
90
90
 
91
+ 信任快照及其 `.old` 文件由创建进程拥有。fork 副本关闭时只释放继承描述符;提交继承快照(即使没有新增密钥)在读取和加锁前报告 `known_hosts_inherited`。子进程需要重新打开 SSH 连接,取得自己的快照后再认证和登记;父进程的快照与连接继续有效。快照关闭失败时,KnownHosts 和 Ssh 保留清理责任,后续 close/open 重试,清理成功后才释放引用。
92
+
91
93
  提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
92
94
 
93
95
  直接备份在路径锁退出前保存完成元数据,锁关闭的普通故障转换为 `BackupFinalizeError`,其中 `backup` 保留结果,`underlying_type` 经过类型白名单。该错误只确认备份操作已完成,不制造文件同步回执;未变文件的历史持久性仍未补验。Fleet 的外层路径锁及 TFTP 归档锁同样保存最新结果,退出故障转为 saved_with_error 或 reported_with_error,已有设备诊断优先,中断继续传播。
94
96
 
95
97
  TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
96
98
 
99
+ SSH 归档的 Client 持有事务进程和管道,Protocol 只借用 IO,维护 16 KiB 控制帧、64 KiB 正文片段和阶段截止时间。`archived` 元数据、声明长度的二进制正文与 `delivered` 确认共用 280 秒期限;文件上限仍为 64 MiB,并发槽位仍为 4。Python 工作进程分片读取、复制和摘要核验,复制及交付前后均检查文件身份、大小与目录/锁边界,不保留整份正文或 base64 副本。
100
+
101
+ `AtomicFile.write_stream` 借出私有临时文件,只有正文长度、SHA-256 和交付确认全部通过后,才允许同步和原子替换。本地准备或写盘失败时,Client 尽可能继续有界读取并核验服务器副本,保留 `server_verified`,随后发送 abort,保持 staging;失败写入和设备上传均不重试。原子替换之后的同步故障保留可信回执中的 `local_path`。启动器只返回管道与等待线程,Client 是唯一清理者,取消和非局部退出不会被普通关闭错误覆盖。
102
+
103
+ AtomicFile 同样独占临时文件及父目录句柄的清理。取得句柄和登记临时目录项身份共用取消保护边界;清理仍尝试关闭及移除属于本次写入的临时目录项。生产者或目录同步期间的异常、取消及非局部退出优先于普通关闭错误;正常完成后的清理错误继续按原写入回执报告。
104
+
97
105
  TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
98
106
 
99
107
  失败证据核对与完成核对共用精确回显过滤。静态应答从已完成步骤的 `Command#interactions` 读取,`Interaction#static_response` 对动态回调返回 nil,核对不会重新执行挑战回调。失败核对逐步读取原文、回车历史和终端画面,不累积全部画面副本;颜色样式只影响显示,不能拆开诊断词或借光标擦除隐藏已经收到的失败。命令诊断及拓扑保存确认同样保留这类失败历史。
@@ -206,8 +214,14 @@ Fleet 可把回执与本地普通文件的时间、稳定性、摘要及目录
206
214
 
207
215
  提示符和交互标记是 Ruby 正则表达式,应短到能放入未匹配尾部;流式适配器不支持需要无限长历史的模式。收集输出的上限与匹配窗口上限不同。终端渲染器只处理常见行编辑控制符,不是完整屏幕终端模拟器。`Profile#terminal_size` 使用 `[宽, 高]`,PTY 适配器转换为 Ruby 的 `[行, 列]`。
208
216
 
217
+ 传输关闭先撤下活跃信道,使连接不可用;尚未回收的信道由内部 `@closing_channel` 保留。`hard_close` 正常返回且当前进程拥有的子进程已退出后才释放引用;异常、非局部退出及有界等待后仍存活的子进程都保留清理责任,后者明确报告 IOError。后续 `close` 重试原信道,完成后不重复清理。fork 副本仅关闭继承句柄,不要求回收父进程拥有的子进程。
218
+
219
+ 重新打开会先完成旧资源回收,失败时不创建新进程或 SSH 信任快照。私有 `close_resources` 供关闭与打开共用;公开 `close` 停止日志交付,而打开时保留新连接已配置的日志目标。SSH 临时信任快照在成功关闭后释放引用,失败时保留供重试。连接恢复只有在旧传输及日志清理成功后才能替换传输对象;失败仍报告原连接错误,保留旧所有者供后续关闭,不重放设备命令。注入信道工厂须遵守现有 Expect 的 `hard_close` / `alive?` 生命周期协议。
220
+
209
221
  PTY 输出上限直接读取信道公开的 `buffer_bytesize` 标量,避免正常分片反复取得累计正文副本;只有实际超限时才获取错误正文,关闭日志也不会关闭上限检查。传输与 SSH 归档认证统一使用当前 `Session`、`transcript`、`spawn(raw: true)` 和 `expect` 契约,不探测或回退到旧接口。运行要求为 expect-pty 0.9.4+、Ruby 3.4+。
210
222
 
223
+ SSH 归档认证借用 `Expect.spawn` 块中交付的私有会话;初始化与交接期间取消的回收责任由 Expect 工厂持有,不在连接器重复构造、登记和 ensure 清理。普通清理错误不能替换认证主异常;没有主异常时仍传播清理错误,新发生的致命清理异常沿用 Expect 契约。密码仍只发送一次,15 秒对话预算、输出隐私及调用方的主连接核对保持原有职责。
224
+
211
225
  `prepare_command` 钩子返回 `Command` 表示继续执行,返回 nil 表示跳过。其他返回值在替代命令发送前以 `ScriptError` 拒绝,保留原脚本位置和此前完成步骤,诊断不展开非法值。钩子内部主动执行的查询仍遵循原有执行与输出预算边界。
212
226
 
213
227
  `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
  # 返回保存配置命令;空数组表示设备不支持保存。
@@ -25,7 +25,8 @@ module Net
25
25
  Net::Connector::Netdisco::Tftp::Archive::Unavailable Net::Connector::Netdisco::Tftp::Archive::Failure]).freeze
26
26
  CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
27
27
  %i[authentication_failed connection_failed connection_refused no_route connection_reset
28
- connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe rsa_too_small unsupported_cipher
28
+ connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe known_hosts_inherited
29
+ rsa_too_small unsupported_cipher
29
30
  ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
30
31
  description_unconfirmed description_unsupported incomplete_configuration interface_missing
31
32
  neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
@@ -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
@@ -14,6 +14,7 @@ module Net
14
14
 
15
15
  # 建立本次连接的私有主机密钥快照,初始化失败时清理已取得的资源。
16
16
  def initialize(configuration, replace: false)
17
+ @owner_pid = Process.pid
17
18
  destination = configuration.known_hosts || File.join(Dir.home, ".ssh", "known_hosts")
18
19
  FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
19
20
  @destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
@@ -35,6 +36,11 @@ module Net
35
36
  # 在目标文件锁内合并新增信任;检测并发密钥变化后原子写回。
36
37
  # 会话只写私有快照;登记时重读共享信任并检查冲突,避免并发连接覆盖已有主机密钥。
37
38
  def commit
39
+ unless @owner_pid == Process.pid
40
+ raise ConnectionError.new("inherited known_hosts snapshot cannot be committed; reopen the SSH connection",
41
+ code: :known_hosts_inherited, host: @host, phase: :login), cause: nil
42
+ end
43
+
38
44
  # 纯会话引擎加载不触及文件业务;实际登记信任时才加载持久化协议。
39
45
  require_relative "../storage/atomic_file"
40
46
  begin
@@ -60,10 +66,10 @@ module Net
60
66
  end
61
67
  end
62
68
 
63
- # 关闭并删除私有密钥快照,延迟取消直至清理完成。
69
+ # 关闭私有密钥快照,创建进程同时删除文件;延迟取消直至清理完成。
64
70
  def close
65
71
  failure = nil
66
- # 清除引用至删除快照期间延后异步取消,不能留下失去所有者的临时文件。
72
+ # 关闭句柄和删除快照期间延后异步取消,完成清理后再释放引用。
67
73
  Thread.handle_interrupt(Exception => :never) do
68
74
  close_snapshot
69
75
  rescue Exception => error # rubocop:disable Lint/RescueException -- 保存同步清理故障,退出屏蔽范围后继续传播。
@@ -104,17 +110,24 @@ module Net
104
110
  raise failure, cause: nil if failure
105
111
  end
106
112
 
107
- # 清除快照引用并删除临时文件,清理错误不覆盖较早的失败。
113
+ # 创建进程负责删除文件;fork 副本只关闭继承句柄,失败时保留清理引用。
108
114
  def close_snapshot
109
115
  file = @file
110
- @file = nil
111
- File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
116
+ owned = @owner_pid == Process.pid
117
+ File.unlink(@path + ".old") if owned && @path && File.exist?(@path + ".old")
112
118
  rescue Exception # rubocop:disable Lint/RescueException -- 删除附属文件失败或中断后仍须关闭私有快照。
113
119
  failed = true
114
120
  raise
115
121
  ensure
116
122
  begin
117
- file&.close!
123
+ if owned
124
+ file&.close!
125
+ # Tempfile 会吞掉删除时的权限错误;路径仍存在表示删除责任尚未完成。
126
+ raise IOError, "known_hosts snapshot removal is incomplete" if file&.path
127
+ else
128
+ file&.close
129
+ end
130
+ @file = nil
118
131
  rescue Exception # rubocop:disable Lint/RescueException -- 私有快照清理不能覆盖先发生的删除故障。
119
132
  raise unless failed
120
133
  end
@@ -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
+ begin
238
+ super
239
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
240
+ failure = error
241
+ raise
242
+ ensure
243
+ begin
244
+ known_hosts&.close
245
+ @known_hosts = nil
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