net-connector 0.7.2 → 0.8.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +16 -11
  5. data/docs/RELEASING.md +2 -2
  6. data/docs/architecture.md +22 -1
  7. data/lib/net/connector/device/base.rb +7 -1
  8. data/lib/net/connector/device/tftp.rb +14 -8
  9. data/lib/net/connector/engine/authentication.rb +1 -0
  10. data/lib/net/connector/engine/dialogue.rb +0 -132
  11. data/lib/net/connector/engine/error_metadata.rb +5 -3
  12. data/lib/net/connector/engine/errors.rb +0 -92
  13. data/lib/net/connector/engine/known_hosts.rb +14 -1
  14. data/lib/net/connector/engine/log.rb +17 -1
  15. data/lib/net/connector/engine/redactor.rb +98 -0
  16. data/lib/net/connector/engine/response_reader.rb +139 -0
  17. data/lib/net/connector/engine/session.rb +6 -2
  18. data/lib/net/connector/netdisco/backup_run.rb +22 -3
  19. data/lib/net/connector/netdisco/cli/options.rb +3 -0
  20. data/lib/net/connector/netdisco/cli.rb +11 -8
  21. data/lib/net/connector/netdisco/connection.rb +0 -1
  22. data/lib/net/connector/netdisco/database_client.rb +4 -2
  23. data/lib/net/connector/netdisco/device.rb +2 -0
  24. data/lib/net/connector/netdisco/diagnostic.rb +4 -0
  25. data/lib/net/connector/netdisco/fleet.rb +10 -1
  26. data/lib/net/connector/netdisco/plan.rb +13 -0
  27. data/lib/net/connector/netdisco/planner.rb +2 -1
  28. data/lib/net/connector/netdisco/progress.rb +25 -8
  29. data/lib/net/connector/netdisco/report/files.rb +11 -6
  30. data/lib/net/connector/netdisco/report/text.rb +1 -1
  31. data/lib/net/connector/netdisco/report.rb +19 -20
  32. data/lib/net/connector/netdisco/result_store.rb +5 -7
  33. data/lib/net/connector/netdisco/rules.rb +1 -0
  34. data/lib/net/connector/netdisco/settings.rb +6 -0
  35. data/lib/net/connector/netdisco/tftp_archive.rb +28 -13
  36. data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
  37. data/lib/net/connector/netdisco/worker.rb +2 -0
  38. data/lib/net/connector/netdisco.rb +1 -21
  39. data/lib/net/connector/vendor/palo_alto/running_config.rb +26 -6
  40. data/lib/net/connector/version.rb +1 -1
  41. metadata +7 -6
  42. data/lib/net/connector/netdisco/tftp_history.rb +0 -16
  43. /data/{exe/net-connector-backup → bin/net-backup} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d0c8bdb5d1bb484683d8bc2b5da59f3c75f506479b4911776ca7b1ce9a82bc1
4
- data.tar.gz: 8ee5fc708d89da6778af7aa5742775969e81cd1b1cea55ff9adc031aea2bb433
3
+ metadata.gz: c0d4b8cc4601018ae7e2f2838e59c57720cff0ca3bfda47cbbfb423c67f4620b
4
+ data.tar.gz: 6b8527cee1080118ff83574a80a9d71cedb01d47427b7e5c8d35621e2f206187
5
5
  SHA512:
6
- metadata.gz: 04f5fa015e849927a3fda6b858f41b06bc352167e698962a62817d716b7267cd54feb5a0c219e2c48f976e200527ad72a95268a4cb40c70ef1b241f1965398b8
7
- data.tar.gz: 21395d46e9fad39a3e8dd45ddfa512031576d4e89121d9d911ce3973e384becc51474d5035d2b6efdb9771f854a4a893976655409ab1012e5fcec9764f131bcf
6
+ metadata.gz: c8070ed62134a1eac35ed652183fc608d276e12a35aef4bfefda3d3cb39c31d354386fe8ebdb49dec2cf9721f41caca047e671927b8f456e97de72ac0acfc897
7
+ data.tar.gz: b04fd3c47d8d09c68961db1c3eb320bca4b76c94f501b9fe107410b13169b747413cbb2c8fdc4d60dbe88f59f963319c4f0313bcb95571c7bb915eb76870e64f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # 更新记录
2
2
 
3
- ## 未发布
3
+ ## 0.8.0 - 2026-10-09
4
+
5
+ - 破坏性变更:命令入口改为 `bin/net-backup`,gem 安装后使用 `net-backup`;移除 `TftpHistory`、`TftpArchive#path_for/#capture` 和 `ResultStore::Text` 兼容接口,调用方需更新路径与接口名称。
6
+ - 引擎按职责拆出 `engine/redactor.rb` 与 `engine/response_reader.rb`;Netdisco 模块显式声明依赖,可独立加载并调用,聚合入口不再维护隐式加载顺序。
7
+ - TFTP 归档与结果存储使用 `TftpArchive#upload_filename/#upload_and_archive` 与 `ResultStore::Json`。
8
+ - 脚本日志收尾失败时保留已完成配置和原业务错误,完成事件不重复发送;登录、命令、TFTP 失败诊断及日志清理不再覆盖主错误或用户中断。
9
+ - 进度终端写入或刷新失败后停止显示,批次继续保存设备结果、事件和报告;显示故障只记录一次安全回调诊断,末尾显示首次失败时补写诊断并更新最终退出状态。
10
+ - PAN-OS 多行 set 配置增量跟踪引号状态,逻辑命令闭合后才进行完整语法校验,避免反复解析不断增长的证书等文本。
11
+ - 每厂商有限采样使用 `Enumerable#min_by(n)`,减少大清单的全量排序开销,保留采样、冲突选择和清单输出顺序。
12
+
13
+ ## 0.7.3 - 2026-10-07
14
+
15
+ - 修复外部备份计划可重复执行同一管理地址的问题,采样或跳过部分条目也不能绕过重复地址检查。
16
+ - TFTP 归档在文件已替换、同步或收尾失败时保留已写入副本的路径和失败阶段,报告保留明确的归档错误码,首次归档自动创建报告目录。
17
+ - 报告补写在文件替换后失败时仍保留已提交路径,并保留首次报告故障,避免把已有文件误报为未保存。
18
+ - 主机密钥共享锁最多等待 `login_timeout` 秒,超时返回 `known_hosts_busy` 并保留原有信任记录,避免认证后无限等待。
19
+ - 减少完整报告生成时的临时对象,文本报告和进度仅计算汇总,关闭进度输出时跳过收尾统计。
20
+ - 发布检查同时识别“未发布”和 `Unreleased`,存在未归档变更时拒绝发布。
4
21
 
5
22
  ## 0.7.2 - 2026-10-06
6
23
 
data/CONTRIBUTING.md CHANGED
@@ -28,7 +28,7 @@ bundle exec rake lint
28
28
  - 复用现有 Ruby 对象、不可变档案和厂商策略。复杂的资源所有权、锁、截止时间及脱敏生命周期继续用中文注释说明原因。
29
29
  - 测试应证明业务行为:失败不覆盖备份、中断释放全部资源、命令不自动重放、错误不泄露凭据。并发测试用队列等同步手段控制执行顺序。
30
30
  - 使用虚构凭据和 `192.0.2.0/24` 等文档地址。不要提交设备配置、备份、私有地址或日志;扫描规则同样适用于测试和示例。
31
- - 行为或公开契约变更写入 `CHANGELOG.md` 的 `Unreleased`,同步修改相应文档。保留与本次任务无关的工作区改动。
31
+ - 行为或公开契约变更写入 `CHANGELOG.md` 的“未发布”(兼容 `Unreleased`),同步修改相应文档。保留与本次任务无关的工作区改动。
32
32
 
33
33
  ### Ruby 范式与语法边界
34
34
 
data/README.md CHANGED
@@ -232,6 +232,8 @@ Fleet 统一返回 `Netdisco::Report`,JSON 的 `schema_version` 固定为 `2`
232
232
 
233
233
  `success_policy: :selected` 要求至少一台设备成功,其他记录只因 `filtered` 或 `sample_limit` 跳过,而且没有部分成功、回调或报告错误。缺少凭据、重复地址、无效地址、未知厂商、目标冲突和未知状态均会阻止成功。`coverage.complete` 只表示每条清单记录都已尝试任务;失败任务也计入尝试,不能据此判断配置已保存。自定义 `ResultStore#write(report, directory:)` 始终接收 Report,并通过 `summary` 获取统一 JSON 结构。
234
234
 
235
+ 外部构造的计划也必须把同一管理地址的全部条目标记为 `duplicate_host` 并跳过,不能通过采样恢复其中一条的执行资格;IPv6 的等价写法视为同一地址。只需汇总时使用 `report.statistics`,它返回 `summary` 除 `devices` 外的字段,不生成设备明细,也不缓存调用方持有的批次容器。
236
+
235
237
  ```sh
236
238
  export NETDISCO_URL=https://netdisco.example/netdisco
237
239
  export NETDISCO_USERNAME=inventory-reader
@@ -260,7 +262,9 @@ end
260
262
  exit 1 unless batch.success?
261
263
  ```
262
264
 
263
- 命令行程序 `net-connector-backup` 的 YAML 文件只允许非敏感设置;Netdisco 和设备凭据留在环境变量中。常用环境变量优先于 YAML,复杂参数只通过 YAML 或 CLI 设置。批量示例也支持 `NC_CONFIG`,可从 [完整配置示例](examples/backup.yml) 开始。只有传入 `--config FILE` 或设置 `NC_CONFIG` 时才加载文件:
265
+ gem 安装后的命令为 `net-backup`;源码入口为 `bin/net-backup`,可在项目目录运行 `bundle exec bin/net-backup --help`。原命令 `net-connector-backup` 已改名。
266
+
267
+ 命令行程序 `net-backup` 的 YAML 文件只允许非敏感设置;Netdisco 和设备凭据留在环境变量中。常用环境变量优先于 YAML,复杂参数只通过 YAML 或 CLI 设置。批量示例也支持 `NC_CONFIG`,可从 [完整配置示例](examples/backup.yml) 开始。只有传入 `--config FILE` 或设置 `NC_CONFIG` 时才加载文件:
264
268
 
265
269
  ```yaml
266
270
  netdisco:
@@ -289,12 +293,12 @@ tftp:
289
293
  ```
290
294
 
291
295
  ```sh
292
- net-connector-backup --config config.yml --show-config
293
- net-connector-backup --config config.yml --plan --host 192.0.2.7
294
- net-connector-backup --config config.yml --host 192.0.2.7
295
- net-connector-backup --config config.yml --tftp --plan
296
- net-connector-backup --config config.yml --tftp --all
297
- net-connector-backup --config config.yml --export 192.0.2.7 --output ./exports/device.cfg
296
+ net-backup --config config.yml --show-config
297
+ net-backup --config config.yml --plan --host 192.0.2.7
298
+ net-backup --config config.yml --host 192.0.2.7
299
+ net-backup --config config.yml --tftp --plan
300
+ net-backup --config config.yml --tftp --all
301
+ net-backup --config config.yml --export 192.0.2.7 --output ./exports/device.cfg
298
302
  ```
299
303
 
300
304
  ### PostgreSQL 联机查询
@@ -324,10 +328,10 @@ export NETDISCO_DB_PASS='replace-me'
324
328
  export NETDISCO_DB_SSLMODE=verify-full
325
329
  export NETDISCO_DB_SSLROOTCERT=/etc/net-connector/database-ca.crt
326
330
 
327
- net-connector-backup --config examples/inventory_sql.yml --show-config
328
- net-connector-backup --config examples/inventory_sql.yml --plan
331
+ net-backup --config examples/inventory_sql.yml --show-config
332
+ net-backup --config examples/inventory_sql.yml --plan
329
333
  # 覆盖查询参数;实际备份仍需设置 NC_DEVICE_* 凭据。
330
- net-connector-backup --config examples/inventory_sql.yml --plan --query-params '["Cisco"]'
334
+ net-backup --config examples/inventory_sql.yml --plan --query-params '["Cisco"]'
331
335
  ```
332
336
 
333
337
  SQL、参数和来源也可分别通过 `--query SQL`、`--query-params JSON`、`--source postgres` 覆盖 YAML,不再从环境变量读取。SQL 和参数属于可公开配置,会出现在 `--show-config` 中;数据库密码只放在连接环境变量中。连接信息不进入策略快照、计划、报告或 `inspect`。已有 Fleet 每次重新查询时读取最新连接凭据;传入已有 `plan:` 执行时不会重新查询。
@@ -342,7 +346,7 @@ SQL、参数和来源也可分别通过 `--query SQL`、`--query-params JSON`、
342
346
 
343
347
  CLI 的计划与批次摘要使用 JSON。默认 `--success-policy strict` 使用严格规则:非空清单且全部成功、回调及报告正常时为 `0`;空清单或有跳过、部分成功、失败时为 `1`;清单或配置错误为 `2`。`--host` 未在清单中找到也返回 `2`。由于其他清单记录会标记为过滤,默认单主机备份成功时批次退出码仍可能是 `1`;应查看 JSON 中的 `succeeded`、`skipped` 和逐台 `status`。
344
348
 
345
- 显式使用 `--success-policy selected` 后,CLI 按上述 selected 规则决定退出码,保留严格的 `status: incomplete` 与跳过计数,另列 `policy_success`。例如 `net-connector-backup --config config.yml --host 192.0.2.7 --success-policy selected`。成功策略由本次 CLI/API 参数指定,不改变已批准的清单选择,也不触发重试。
349
+ 显式使用 `--success-policy selected` 后,CLI 按上述 selected 规则决定退出码,保留严格的 `status: incomplete` 与跳过计数,另列 `policy_success`。例如 `net-backup --config config.yml --host 192.0.2.7 --success-policy selected`。成功策略由本次 CLI/API 参数指定,不改变已批准的清单选择,也不触发重试。
346
350
 
347
351
  本地批量示例和 TFTP 批量示例默认全量;显式 `--sample N`、`NC_SAMPLE_PER_VENDOR` 或 YAML `backup.limit_per_vendor` 才限制每厂商数量(示例允许 1 至 5)。优先级为命令行抽样 > 环境变量 > YAML。并发默认 4,可用 `NC_CONCURRENCY` 覆盖。两类示例在备份目录下创建唯一批次目录。TFTP 批量示例在可访问服务器目录时使用 hostname-ip 远端文件名并逐批归档;无法访问服务器目录时使用带批次标识的远端文件名,避免覆盖旧文件;上传完成与服务器文件验证是不同状态。
348
352
 
@@ -456,3 +460,4 @@ TFTP 的 `verification` 汇总与设备条目的 `server_file_verified` 区分
456
460
 
457
461
  使用 `accept_new` / `replace` 时,SSH 在每个会话的临时 known_hosts 中协商,认证后通过独立锁文件合并到共享文件。
458
462
  网络登录不占用共享锁;失败登录不删除原信任记录,`replace` 只替换当前主机。该同步协调本库进程,外部手工工具应避免同时改写同一文件。
463
+ 认证后的合并锁另有最多 `login_timeout` 秒的等待预算,超时返回 `ConnectionError`(`code: :known_hosts_busy`),关闭会话且保留共享信任记录;此预算只约束锁等待,不是整批任务的期限。
data/docs/RELEASING.md CHANGED
@@ -13,7 +13,7 @@ bundle exec rake release:check
13
13
  ## 准备版本
14
14
 
15
15
  1. 在 `lib/net/connector/version.rb` 设置新的稳定 `X.Y.Z` 版本。
16
- 2. 将 CHANGELOG 的 `Unreleased` 内容移入对应的 `## X.Y.Z` 或
16
+ 2. 将 CHANGELOG 的“未发布”或 `Unreleased` 内容移入对应的 `## X.Y.Z` 或
17
17
  `## X.Y.Z - YYYY-MM-DD` 段;版本说明不能为空。
18
18
  3. 提交最终源码,确认工作区干净,并将提交推送到目标 GitHub 仓库的 `main`。
19
19
  4. 执行 dry run,保留生成的 gem 和 SHA-256,审核后选择下面一种发布方式。
@@ -27,7 +27,7 @@ dry run 执行本地检查,创建独占的 `tmp/release/<version>/candidate-*/
27
27
  它允许未提交的工作区用于预检,但仍要求版本与发布说明已经整理完成。
28
28
  正式发布额外要求干净且未变化的 Git 提交。
29
29
 
30
- 当前存在尚未归档的 `Unreleased` 时,发布脚本(包括 dry run)会拒绝继续;
30
+ 当前任一“未发布”或 `Unreleased` 段包含尚未归档的内容时,发布脚本(包括 dry run)会拒绝继续;
31
31
  需要先整理版本。日常使用 `release:check` 不受此限制。
32
32
 
33
33
  ## 本地发布
data/docs/architecture.md CHANGED
@@ -5,6 +5,8 @@
5
5
  | 层次 | 职责 | 位置 |
6
6
  | --- | --- | --- |
7
7
  | 会话引擎 | 登录、传输、命令、脚本及资源所有权 | `engine/` |
8
+ | 对话协议与响应读取 | 交互规则和响应值独立于有状态的读取、截止时间与输出预算 | `engine/dialogue.rb`、`engine/response_reader.rb` |
9
+ | 错误与脱敏 | 错误类型和安全诊断;秘密作用域及 Expect 脱敏适配独立实现 | `engine/errors.rb`、`engine/redactor.rb` |
8
10
  | 日志 | 事件上下文与生命周期;安全事件、格式和字节流分别实现 | `engine/log.rb`、`engine/log/` |
9
11
  | 设备入口 | 能力组合、会话代理、通用脚本钩子 | `device/base.rb` |
10
12
  | 设备档案 | 不可变规则与策略接口校验;Builder 负责声明 DSL | `device/profile.rb`、`device/profile/` |
@@ -17,6 +19,7 @@
17
19
  | 解析 | TextFSM 模板选择、记录转换及异常映射 | `textfsm.rb`、`templates/` |
18
20
  | 终端文本 | 严格 UTF-8 字节验证和终端渲染 | `engine/terminal_text.rb`、`engine/terminal_renderer.rb` |
19
21
  | 清单与批量编排 | 清单预算、策略快照、计划校验、工作线程和报告 | `netdisco/` |
22
+ | 命令行入口 | 转交给 CLI,参数处理与业务流程由库负责 | `bin/net-backup` |
20
23
 
21
24
  `Base` 引入 `RunningConfig::Capability`、`LocalBackup::Capability`、`Tftp::Capability`、`Topology::Capability`、`TextFSM::Capability` 及 `SaveConfig`。能力入口与实现共置,Base 不再重复业务包装。配置采集的临时策略绑定也由 RunningConfig 管理;备份与拓扑通过 `device.running_config` 复用同一流程。
22
25
 
@@ -54,6 +57,8 @@
54
57
 
55
58
  采集命令匹配当前会话的完整提示符行,不以末尾单个 `#`、`>` 或 `]` 判断完成。PAN-OS 切换视图时保留已认证的设备身份。缺少最终提示符会使采集失败,旧备份保持不变;只有提示符或命令回显的响应属于 `:incomplete_configuration`,不是成功的空配置。PAN-OS 在 `show` 前后都检查候选配置差异。
56
59
 
60
+ PAN-OS 的 set 文本逐行增量跟踪单引号、双引号和转义状态,整条逻辑命令闭合后才由 Shellwords 校验;未闭合的多行内容仍拒绝。证书等长值不再每追加一行就重新解析完整前缀,校验仍在原会话租约内完成。
61
+
57
62
  IOS/NX-OS 清理器处理单条配置命令的响应,不把正文当成多命令会话记录。banner 内的命令、进度及完成提示均属于配置内容;易变时间注释仅从首个配置语句之前的响应头删除。终端渲染器成段处理普通文本,控制序列仍逐字节解释;光标覆盖、行尾空白、UTF-8 校验和逐行字节限额保持一致。
58
63
 
59
64
  `LocalBackup` 在采集前取得 `BackupLock`,持有到摘要比较、替换与结果构造结束。锁名由 realpath 父目录与归一化文件名决定;Unicode NFC、大小写折叠后的摘要同时覆盖尚未创建的目标。区分大小写的文件系统也保守合并这些锁,目标名称本身不变。锁文件用 NOFOLLOW/0600 打开,再验证普通文件、所有者、单硬链接和 inode;释放仅关闭 FD,不删除锁文件。默认 `flock(LOCK_EX | LOCK_NB)`,可选等待共用有限单调期限,超时均为 `BackupBusy`。
@@ -66,6 +71,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
66
71
 
67
72
  提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
68
73
 
74
+ TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
75
+
69
76
  TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
70
77
 
71
78
  TFTP 内置策略的 `validate_options!` 是纯参数校验,执行于 H3C 源文件探测之前。整个探测、脚本、证据核对、回执和事件记录由原有 `with_operation(:tftp_backup)` 独占;其他线程、Fiber 及回调重入均不能插入设备命令。租约只保护当前会话,不能隔离其他设备连接或服务器上的同名文件。
@@ -104,6 +111,8 @@ Fleet 始终返回 Report,委托 Batch 的执行结果及严格 success?/statu
104
111
 
105
112
  报告 summary 使用单一 schema_version: 2,包含 policy、policy_success、coverage、单调 duration_ms、逐台 diagnostic 及 report_diagnostic。coverage 仅按已尝试状态计数,失败和部分成功也算尝试。`ResultStore#write(report, directory:)` 始终接收 Report。已存文件是写入前快照;写入自身失败时,返回对象与 CLI 携带最终报告故障。`Batch#build_report` 只构造报告,不重跑任务,也不能补出未记录的总耗时。
106
113
 
114
+ `Report#statistics` 仅计算汇总,字段等同于 `summary.except(:devices)`,供文本与进度输出使用;完整设备明细由 JSON 输出时生成。两者不缓存结果,保留调用方持有 Batch 容器的既有语义。静默进度不读取报告。
115
+
107
116
  Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态;不调用异常 inspect/to_h,不保留异常引用、正文、消息、回溯、命令、source 或 line。未知错误码/阶段为 nil,未知类型归为 StandardError。Report 也筛选手工构造结果中的 error_code/error_type、callback_errors 和 report_error。产物阶段仅来自与实际 Backup/TftpReceipt 匹配的库内精确错误类;通用文件回执只在报告写入边界使用,不能冒充设备备份完成。
108
117
 
109
118
  `Planner` 先按厂商采样,再按实际 TFTP 文件名排除覆盖冲突,保留采样顺序中的首台设备。未入选设备仍标记为 `sample_limit`,只有入选后目标重名才标记为 `remote_filename_collision`。`Plan#validate!` 校验任务与清单的对应关系;调用方传入或通过 `with` 修改的计划也必须在读取凭据、创建目录及设备 I/O 前通过校验。冲突规则适用于全部厂商,包括不同地址规范化后产生相同文件名的情况。
@@ -284,7 +293,11 @@ TFTP 策略必须实现 `validate_options!(target, source_file:, vrf:)`、`recei
284
293
 
285
294
  ## 加载入口与厂商策略
286
295
 
287
- `require "net/connector"` 加载设备 API 和引擎,厂商通过 autoload 按需加载。TextFSM 能力模块可预先组合,但外部 `textfsm` gem 只在实际解析时加载。仅需会话引擎时使用 `require "net/connector/engine/core"`;离线文件操作使用 `net/connector/storage`,不加载厂商。
296
+ `require "net/connector"` 加载设备 API 和引擎,厂商由 `vendor_class` 按需加载。TextFSM 能力模块可预先组合,但外部 `textfsm` gem 只在实际解析时加载。仅需会话引擎时使用 `require "net/connector/engine/core"`;离线文件操作使用 `net/connector/storage`,不加载厂商。
297
+
298
+ `engine/dialogue` 只定义 Interaction、Dialogue 与 Response;读取循环位于 `engine/response_reader`。`engine/errors` 不加载脱敏运行时,`engine/redactor` 负责秘密作用域。Session 明确加载并持有读取器与脱敏器。
299
+
300
+ Netdisco 的 Settings、Planner、Fleet、CLI 等文件各自声明实际依赖,单独 require 后即可调用,不依赖 `netdisco.rb` 的预设加载顺序。总入口只组合 CLI 和 BackupRun;Batch 通过 autoload 创建 Report,避免两者循环加载。私有 InventoryBudget 由 Client 加载。Progress 的终端显示、Fleet 的设备执行、Report 的结果解释和 Storage 的文件持久化保持独立职责。
288
301
 
289
302
  公共策略位于 `RunningConfig::Strategy` / `Rendered`、`Tftp::Strategy` / `FileUpload`、`Topology::Strategy` / `ImmediateStrategy`。厂商文件直接依赖公共策略,由 Profile 绑定;没有反向加载和厂商转发别名。
290
303
 
@@ -307,6 +320,10 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
307
320
 
308
321
  | 原名称或入口 | 当前入口 |
309
322
  | --- | --- |
323
+ | `exe/net-connector-backup` | `bin/net-backup`;安装后命令为 `net-backup` |
324
+ | TFTP 策略 `interaction` | private `build_interaction`,构造交互规则;`device.interact` 仍表示立即进入人工终端交互 |
325
+ | `TftpHistory`、`TftpArchive#path_for/#capture` | `TftpArchive#upload_filename/#upload_and_archive` |
326
+ | `ResultStore::Text` | `ResultStore::Json`,与 `Report::Text` 的文本报告职责区分 |
310
327
  | `Base#login_dialogues` / `confirmation_dialogues` | protected `login_interactions` / `confirmation_interactions`;子类覆盖同步改名,继续支持 `super` |
311
328
  | `Fleet#backup_one` / `tftp_backup_one` / `run_one` | private `backup_device` / `tftp_backup_device` / `run_device`;公开批次入口不变 |
312
329
  | `engine`、`engine/base`、`engine/profile` | `net/connector`;底层分别为 `engine/core`、`device/base`、`device/profile` |
@@ -367,6 +384,8 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
367
384
 
368
385
  操作后处理无论抛出异常还是返回失败 Result,都在敏感作用域退出前归一化错误,再记录完成事件;Result 的已完成步骤及配置内容保持原样。最终处理继承实际执行的命令及交互敏感性,包括准备钩子、替换命令和追加查询;只累计标记,不长期保留秘密,也不影响后续普通命令的日志。
369
386
 
387
+ 业务执行与完成事件的异常范围分开:完成事件只尝试一次,观察者失败不能触发第二次相同通知。已有登录、命令或业务错误优先于失败日志的错误;脚本最终处理已生成的 Result.config 与 steps 不因日志收尾丢失。日志上下文刷新和传输接线恢复仍执行清理,但保留正在传播的主错误及 Interrupt。TFTP 的失败证据同样先决定错误码,再尝试记录失败事件。
388
+
370
389
  错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause 或调用栈。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
371
390
 
372
391
  单条命令的完整响应在输出日志和完成事件之前保存;即使这些日志处理失败,Session 仍在脱敏作用域退出前把响应交给 Execution,保留已完成步骤与输出预算。TFTP 可从失败结果的完成步骤构造设备回执,再报告日志错误;不会据此重放命令。
@@ -377,6 +396,8 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
377
396
 
378
397
  注入的 logger 由调用方持有;连接器不修改级别、formatter、progname,也不关闭它。每次写入同时检查配置与 logger 当前级别。消息对象支持 to_s/inspect 和安全 to_h,应用可自行输出 JSON。自有文本日志使用毫秒时间及逐行事件;raw 文件只写经过敏感保护的字节。上下文退出前先收尾渲染与脱敏缓冲,避免上一命令尾部被标成下一条命令。
379
398
 
399
+ 人类进度显示由 Progress 单独管理:终端 write/flush 的 I/O 故障会停用后续显示,并只保留固定词表内的错误类型,不携带原始异常。心跳照常回收,设备任务、events.jsonl 与报告持久化继续进行;BackupRun 将显示故障合并为一次批次 callback_errors。若首次故障发生在报告保存后的最终显示阶段,仅补写一次报告诊断,最终 JSON 与退出码采用更新后的策略结果;已有报告错误保持优先,不重放设备备份或事件。
400
+
380
401
  `log_file` 使用 NOFOLLOW/NONBLOCK 打开,再对同一 FD 检查普通文件类型,随后才修改权限或写入。FIFO、设备文件和目录不能用作日志文件;应用拥有的输出流通过 `logger` 接入。日志打开先于登录,不能依赖登录超时为文件打开提供期限。
381
402
 
382
403
  ## 公开契约与 Rails 接入
@@ -249,17 +249,23 @@ module Net
249
249
 
250
250
  execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
251
251
  output_sensitive = script.any?(&:output_sensitive?)
252
+ result = nil
252
253
  @session.perform(:script) do
253
254
  @session.log_script(operation: operation, steps: execution.steps) do
254
255
  @session.with_sensitive_output(output_sensitive) { before_batch(execution) }
255
256
  result = execution.execute_script(script, &on_step)
256
257
  sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
257
258
  @session.with_sensitive_output(sensitive_result) do
258
- finalize_script_result(result, finalize)
259
+ result = finalize_script_result(result, finalize)
259
260
  end
260
261
  end
261
262
  end
262
263
  rescue Error => error
264
+ # 脚本完成后的日志收尾仍可能失败,已经生成的配置与主错误保持权威。
265
+ if result.is_a?(Result)
266
+ return Result.new(steps: result.steps, config: result.config, error: result.error || error)
267
+ end
268
+
263
269
  Result.new(steps: execution ? execution.steps : [], error: error)
264
270
  end
265
271
 
@@ -84,18 +84,24 @@ module Net
84
84
  # 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
85
85
  def confirm_transfer!(strategy, target, result)
86
86
  if transfer_failed?(result)
87
- @device.log_event("tftp_backup", level: :error, status: "transfer_failed", phase: :tftp_backup,
88
- code: :transfer_failed, server: target.host)
89
- raise DeviceError.new("device reported TFTP backup failure",
90
- code: :transfer_failed, host: @device.host, phase: :tftp_backup)
87
+ reject_transfer!(target, :transfer_failed, "device reported TFTP backup failure")
91
88
  end
92
89
  return if strategy.device_reported_complete?(result)
93
90
 
94
91
  result.value! if result.failure?
95
- @device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
96
- code: :transfer_unconfirmed, server: target.host)
97
- raise DeviceError.new("device did not confirm TFTP backup completion",
98
- code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
92
+ reject_transfer!(target, :transfer_unconfirmed, "device did not confirm TFTP backup completion")
93
+ end
94
+
95
+ # 设备证据先决定业务失败;事件记录失败不能把明确的上传状态替换为日志故障。
96
+ def reject_transfer!(target, code, message)
97
+ failure = DeviceError.new(message, code: code, host: @device.host, phase: :tftp_backup)
98
+ begin
99
+ @device.log_event("tftp_backup", level: :error, status: code.to_s, phase: :tftp_backup,
100
+ code: code, server: target.host)
101
+ rescue Error
102
+ # 主错误仍在下方抛出,不重试事件或设备命令。
103
+ end
104
+ raise failure, cause: nil
99
105
  end
100
106
 
101
107
  # 识别设备回显中的传输失败信息。
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "expect/pty"
3
4
  require_relative "dialogue"
4
5
 
5
6
  module Net
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "terminal_renderer"
5
- require "expect/pty"
6
5
 
7
6
  module Net
8
7
  module Connector
@@ -113,136 +112,5 @@ module Net
113
112
  # 返回响应大小摘要,不展开原始内容。
114
113
  def inspect = "#<#{self.class} bytes=#{raw.bytesize}>"
115
114
  end
116
-
117
- # 在传输层上执行有界对话;收到字节或分页标记不会重置截止时间。
118
- class ResponseReader
119
- # 保存会话引用,以便读取器使用传输、配置和脱敏上下文。
120
- def initialize(session)
121
- @session = session
122
- end
123
-
124
- # 读取到结束提示、失败模式或超时,并处理交互响应和输出上限。
125
- def read(prompt:, interactions:, phase:, timeout: nil, deadline: nil, command: nil, failures: {})
126
- deadline ||= Expect.monotonic + timeout
127
- patterns = [*failures.keys, *interactions.map(&:pattern), prompt]
128
- raw = []
129
- output = []
130
- counts = Hash.new(0)
131
- bytes = 0
132
- loop do
133
- event = read_event(patterns, deadline)
134
- raw << event.before unless event.before.empty?
135
- output << event.before unless event.before.empty?
136
- bytes += event.before.bytesize + event.match.bytesize
137
- validate_event!(event, bytes, raw, phase, command)
138
-
139
- if event.index == patterns.size
140
- raw << event.match
141
- output << event.match
142
- elsif event.index == patterns.size - 1
143
- return finish_response(event.match, raw, output, phase, command)
144
- elsif event.index < failures.size
145
- raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
146
- else
147
- reply = interactions.fetch(event.index - failures.size)
148
- raw << event.match
149
- output << event.match if reply.capture?
150
- respond(reply, event.match, counts, deadline, phase, command)
151
- end
152
- next if Expect.monotonic < deadline
153
-
154
- raise build_read_error(nil, phase, command, raw.join), cause: nil
155
- end
156
- end
157
-
158
- private
159
-
160
- # 分页和流式输出只消耗剩余时间,不为下一次读取重新计时。
161
- def read_event(patterns, deadline)
162
- @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
163
- end
164
-
165
- # 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
166
- def build_connection_error(failure, phase, command)
167
- klass, code = failure
168
- @session.build_error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
169
- end
170
-
171
- # 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
172
- def validate_event!(event, bytes, raw, phase, command)
173
- if bytes > @session.configuration.max_output_bytes
174
- raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
175
- phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
176
- end
177
- raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
178
- end
179
-
180
- # 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
181
- def finish_response(prompt, raw, output, phase, command)
182
- if prompt.empty?
183
- raise @session.build_error(PromptError, "prompt pattern did not consume any output",
184
- phase: phase, command: command, output: raw.join), cause: nil
185
- end
186
-
187
- raw << prompt
188
- output << prompt
189
- raw_text = raw.join
190
- Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
191
- end
192
-
193
- # 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
194
- def respond(reply, prompt, counts, deadline, phase, command)
195
- counts[reply] += 1
196
- if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
197
- raise build_interaction_error("response rejected or repeated", phase, command), cause: nil
198
- end
199
-
200
- value = interaction_response(reply, prompt, phase, command)
201
- raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
202
-
203
- @session.redactor.remember(value.chomp) if reply.sensitive?
204
- @session.write(value, deadline: deadline, phase: phase, command: command)
205
- end
206
-
207
- # 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
208
- # 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
209
- def interaction_response(reply, prompt, phase, command)
210
- return reply.response(prompt) unless reply.sensitive?
211
-
212
- @session.redactor.with_scope(reuse: true) do
213
- @session.redactor.sensitive!
214
- reply.response(prompt)
215
- rescue => error
216
- raise @session.normalize_error(error, phase: phase, command: command), cause: nil
217
- end
218
- end
219
-
220
- # 将交互拒绝或响应缺失转换为认证或脚本错误。
221
- def build_interaction_error(message, phase, command)
222
- authenticating = %i[login enable].include?(phase)
223
- klass = authenticating ? AuthenticationError : ScriptError
224
- label = authenticating ? "authentication" : "command interaction"
225
- @session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
226
- end
227
-
228
- # 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
229
- def build_read_error(event, phase, command, output)
230
- if event&.error.is_a?(Exception)
231
- return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
232
- underlying: event.error, output: output)
233
- end
234
- klass = if event&.error == :eof
235
- ConnectionClosed
236
- elsif command&.prompt
237
- PromptError
238
- elsif %i[login enable].include?(phase)
239
- LoginTimeout
240
- else
241
- CommandTimeout
242
- end
243
- @session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
244
- command: command, output: output)
245
- end
246
- end
247
115
  end
248
116
  end
@@ -16,10 +16,11 @@ module Net
16
16
  Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
17
17
  Net::Connector::Storage::PrivateFile::WriteError
18
18
  Net::Connector::Storage::PrivateFile::PersistenceError
19
- Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported]).freeze
19
+ Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported
20
+ Net::Connector::Netdisco::TftpArchive::Unavailable Net::Connector::Netdisco::TftpArchive::ArchiveFailed]).freeze
20
21
  CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
21
22
  %i[authentication_failed connection_failed connection_refused no_route connection_reset
22
- connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
23
+ connection_timeout host_key_changed host_key_untrusted known_hosts_busy rsa_too_small unsupported_cipher
23
24
  ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
24
25
  description_unconfirmed description_unsupported incomplete_configuration interface_missing
25
26
  neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
@@ -27,7 +28,8 @@ module Net
27
28
  transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
28
29
  unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
29
30
  backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
30
- file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported]).freeze
31
+ file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported
32
+ tftp_history_unavailable tftp_archive_failed]).freeze
31
33
  PHASES = %i[connect login enable command script read write close logging backup collect discover parse
32
34
  plan apply verify persist save interact tftp_backup report callback].freeze
33
35
  private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "expect/redactor"
4
3
  require_relative "error_metadata"
5
4
 
6
5
  module Net
@@ -89,96 +88,5 @@ module Net
89
88
  # 返回底层异常类型摘要。
90
89
  def inspect = "#<#{self.class} type=#{type}>"
91
90
  end
92
-
93
- # 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
94
- class Redactor
95
- MASK = "[REDACTED]".b.freeze
96
-
97
- # 保存初始凭据,并建立第一版脱敏词表。
98
- def initialize(*secrets)
99
- unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
100
- raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
101
- end
102
-
103
- @configured = secrets.compact.map(&:b).map(&:freeze).freeze
104
- reset
105
- end
106
-
107
- # 重置动态词表,同时保留配置中的固定凭据。
108
- def reset
109
- @secrets = []
110
- @patterns = nil
111
- @scoped = false
112
- @sensitive = false
113
- @output_sensitive = false
114
- @configured.each { |secret| remember(secret) }
115
- end
116
-
117
- # 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
118
- def with_scope(reuse: false)
119
- return yield if reuse && @scoped
120
-
121
- previous = @secrets
122
- previous_patterns = @patterns
123
- previous_scoped = @scoped
124
- previous_sensitive = @sensitive
125
- previous_output_sensitive = @output_sensitive
126
- @secrets = previous.dup
127
- @scoped = true
128
- begin
129
- yield
130
- ensure
131
- @secrets = previous
132
- @patterns = previous_patterns
133
- @scoped = previous_scoped
134
- @sensitive = previous_sensitive
135
- @output_sensitive = previous_output_sensitive
136
- end
137
- end
138
-
139
- # 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
140
- def sensitive! = @sensitive = true
141
-
142
- # 判断当前脱敏范围是否包含敏感交互。
143
- def sensitive? = @sensitive
144
-
145
- # 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
146
- def output_sensitive!
147
- @output_sensitive = true
148
- sensitive!
149
- end
150
-
151
- # 后续查询继承输出边界,即使查询命令本身没有敏感标记。
152
- def output_sensitive? = @output_sensitive
153
-
154
- # 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
155
- def remember(secret)
156
- return if secret.nil? || secret.empty?
157
-
158
- bytes = secret.b
159
- return if @secrets.include?(bytes)
160
-
161
- @secrets << bytes.freeze
162
- @patterns = nil
163
- end
164
-
165
- # 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
166
- def call(text)
167
- Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
168
- end
169
-
170
- # 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
171
- def stream
172
- Expect::Redactor.new(patterns, replacement: MASK)
173
- end
174
-
175
- # 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
176
- def patterns
177
- @patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
178
- end
179
-
180
- # 返回脱敏器类型摘要。
181
- def inspect = "#<#{self.class}>"
182
- end
183
91
  end
184
92
  end
@@ -16,6 +16,7 @@ module Net
16
16
  FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
17
17
  @destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
18
18
  @host = configuration.host
19
+ @lock_timeout = configuration.login_timeout
19
20
  @host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
20
21
  @replace = replace
21
22
  @original = read_shared
@@ -100,13 +101,25 @@ module Net
100
101
  unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
101
102
  raise IOError, "known_hosts lock must be an owned private regular file"
102
103
  end
103
- file.flock(File::LOCK_EX)
104
+ acquire_lock(file)
104
105
  current = File.lstat(lock_path)
105
106
  raise IOError, "known_hosts lock changed" unless current.dev == stat.dev && current.ino == stat.ino
106
107
 
107
108
  yield
108
109
  end
109
110
  end
111
+
112
+ # 认证后的合并另有有限等待;持锁进程卡住时保留共享信任并交由会话正常清理。
113
+ def acquire_lock(file)
114
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
115
+ until file.flock(File::LOCK_EX | File::LOCK_NB)
116
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ if remaining <= 0
118
+ raise ConnectionError.new("known_hosts lock wait timed out", code: :known_hosts_busy, host: @host, phase: :login)
119
+ end
120
+ sleep [remaining, 0.01].min
121
+ end
122
+ end
110
123
  end
111
124
  end
112
125
  end