net-connector 0.4.2 → 0.6.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -1
  3. data/README.md +92 -20
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +102 -64
  6. data/examples/netdisco_database.yml +16 -0
  7. data/lib/net/connector/device/base.rb +27 -107
  8. data/lib/net/connector/device/local_backup.rb +96 -0
  9. data/lib/net/connector/device/profile.rb +8 -3
  10. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  11. data/lib/net/connector/device/running_config.rb +44 -5
  12. data/lib/net/connector/device/save_config.rb +21 -0
  13. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  14. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  15. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  16. data/lib/net/connector/device/tftp/target.rb +63 -0
  17. data/lib/net/connector/device/tftp.rb +117 -0
  18. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  19. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  20. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  21. data/lib/net/connector/device/topology/strategy.rb +75 -0
  22. data/lib/net/connector/device/topology.rb +277 -0
  23. data/lib/net/connector/engine/authentication.rb +2 -2
  24. data/lib/net/connector/engine/configuration.rb +1 -1
  25. data/lib/net/connector/engine/dialogue.rb +21 -21
  26. data/lib/net/connector/engine/error_metadata.rb +50 -0
  27. data/lib/net/connector/engine/errors.rb +15 -1
  28. data/lib/net/connector/engine/execution.rb +17 -10
  29. data/lib/net/connector/engine/log/event.rb +66 -0
  30. data/lib/net/connector/engine/log/formatter.rb +16 -0
  31. data/lib/net/connector/engine/log/messages.rb +52 -0
  32. data/lib/net/connector/engine/log/stream.rb +56 -0
  33. data/lib/net/connector/engine/log.rb +105 -84
  34. data/lib/net/connector/engine/session.rb +83 -42
  35. data/lib/net/connector/engine/terminal_text.rb +33 -0
  36. data/lib/net/connector/engine/transport.rb +2 -2
  37. data/lib/net/connector/netdisco/batch.rb +9 -45
  38. data/lib/net/connector/netdisco/cli.rb +17 -10
  39. data/lib/net/connector/netdisco/client.rb +4 -3
  40. data/lib/net/connector/netdisco/config_file.rb +6 -3
  41. data/lib/net/connector/netdisco/database_client.rb +197 -0
  42. data/lib/net/connector/netdisco/device.rb +4 -5
  43. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  44. data/lib/net/connector/netdisco/fleet.rb +26 -39
  45. data/lib/net/connector/netdisco/report.rb +37 -16
  46. data/lib/net/connector/netdisco/result_store.rb +2 -2
  47. data/lib/net/connector/netdisco/settings.rb +26 -9
  48. data/lib/net/connector/netdisco.rb +1 -0
  49. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  50. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  51. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  52. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  53. data/lib/net/connector/storage.rb +15 -0
  54. data/lib/net/connector/textfsm.rb +72 -0
  55. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  56. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  57. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  59. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  60. data/lib/net/connector/vendor/h3c.rb +3 -3
  61. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  63. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  65. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  66. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  67. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  68. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  69. data/lib/net/connector/version.rb +1 -1
  70. data/lib/net/connector.rb +1 -6
  71. metadata +47 -39
  72. data/lib/net/connector/device/interface_description.rb +0 -44
  73. data/lib/net/connector/device/interface_name.rb +0 -56
  74. data/lib/net/connector/engine/base.rb +0 -4
  75. data/lib/net/connector/engine/log_messages.rb +0 -47
  76. data/lib/net/connector/engine/profile.rb +0 -4
  77. data/lib/net/connector/engine.rb +0 -4
  78. data/lib/net/connector/operations/local_backup.rb +0 -89
  79. data/lib/net/connector/operations/parse_output.rb +0 -77
  80. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  81. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  82. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  83. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  84. data/lib/net/connector/operations/running_config.rb +0 -16
  85. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  86. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  87. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  88. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  89. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  90. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  91. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  92. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  93. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  94. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  95. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  96. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  97. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  98. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  99. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  100. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  101. data/lib/net/connector/operations/topology/radware.rb +0 -11
  102. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  103. data/lib/net/connector/operations/topology.rb +0 -261
  104. data/lib/net/connector/operations.rb +0 -26
data/docs/architecture.md CHANGED
@@ -1,29 +1,24 @@
1
1
  # 设备操作架构
2
2
 
3
- 一个公开连接器对象对应一台设备的一段会话,负责连接状态、命令执行和厂商档案。调用方仍使用 `device.running_config`、`device.backup(path:)`、`device.tftp_backup(...)`。运行配置是设备的基本能力,因此设备入口、不可变档案和公共采集流程放在 `device/`。`Operations` 承载备份、解析、邻居发现和接口描述计划等业务;已有文件的导出只读取本地备份,不建立设备连接。
3
+ 一个连接器对应一台设备的一段会话。`Base` 组合设备能力,提供统一的命令执行和会话入口;各项能力的公开方法、流程和策略放在同一功能目录。公共层不反向依赖厂商,也不保留另一套 Operations 入口。
4
4
 
5
5
  | 层次 | 职责 | 位置 |
6
6
  | --- | --- | --- |
7
- | 设备会话 | 登录、命令交互、脚本和日志 | `engine/` |
8
- | 设备 | 公开入口、不可变档案、配置采集和接口命名 | `device/` |
9
- | 厂商组装 | 提示符、命令、交互、策略绑定及会话钩子 | `vendor/<厂商>.rb` |
10
- | 公共配置采集 | 执行、完整性判断和旧钩子适配 | `device/running_config.rb`、`device/running_config/strategy.rb` |
11
- | 厂商配置采集 | 清理文本、切换视图和检查响应 | `vendor/<厂商>/running_config.rb` |
12
- | 接口文本 | 名称匹配、简称、描述和公共接口视图命令 | `device/interface_name.rb`、`device/interface_description.rb` |
13
- | TextFSM 解析 | 选择模板,将命令或配置转为记录 | `operations/parse_output.rb`、`templates/` |
14
- | 拓扑与描述计划 | 读取邻居及旧描述、生成命令、重验后下发 | `operations/topology.rb` |
15
- | 本地备份 | 路径所有权、采集、比较哈希、保存与完成回执 | `operations/local_backup.rb`、`backup_lock.rb` |
16
- | 已存配置导出 | 不访问清单或设备,读取已有备份 | `operations/saved_config.rb` |
17
- | 私有文件读写 | 同 FD 验证读取;0600 替换、文件及目录同步 | `operations/safe_file.rb`、`private_file.rb` |
18
- | TFTP 备份 | 校验目标、执行导出、核对成功证据 | `operations/tftp_backup.rb` |
19
- | TFTP 厂商策略 | 命令、提示、源文件、成功证据和目标名称 | `vendor/<厂商>/tftp_backup.rb` |
20
- | 拓扑厂商策略 | 发现命令、解析证据、配置视图及特殊命令 | `vendor/<厂商>/topology.rb` |
21
- | 清单规划 | 选择就绪设备、限制厂商数量、记录跳过原因 | `netdisco/planner.rb` |
22
- | 清单计划 | 保存不可变快照,校验任务槽位、跳过原因和目标冲突 | `netdisco/plan.rb` |
23
- | 批量执行 | 分派独立设备任务,隔离回调故障 | `netdisco/worker.rb` |
24
- | 批量结果 | 汇总设备状态并保留报告故障 | `netdisco/batch.rb` |
25
- | 设备集合 | 读取清单、执行本地或 TFTP 任务、写报告 | `netdisco/fleet.rb` |
26
- | 设置 | 读取环境变量及 YAML 覆盖项 | `netdisco/settings.rb` |
7
+ | 会话引擎 | 登录、传输、命令、脚本及资源所有权 | `engine/` |
8
+ | 日志 | 事件上下文与生命周期;安全事件、格式和字节流分别实现 | `engine/log.rb`、`engine/log/` |
9
+ | 设备入口 | 能力组合、会话代理、通用脚本钩子 | `device/base.rb` |
10
+ | 设备档案 | 不可变规则与策略接口校验;Builder 负责声明 DSL | `device/profile.rb`、`device/profile/` |
11
+ | 配置采集与保存 | 采集策略生命周期、完整性校验、显式保存 | `device/running_config.rb`、`device/running_config/`、`device/save_config.rb` |
12
+ | 本地备份 | 采集、摘要比较、文件持久化和完成回执 | `device/local_backup.rb` |
13
+ | TFTP | 参数预检、上传、完成证据、目标和回执 | `device/tftp.rb`、`device/tftp/` |
14
+ | 拓扑 | 邻居发现、接口名称/描述、变更计划与分阶段执行 | `device/topology.rb`、`device/topology/` |
15
+ | 厂商差异 | 档案组装及各项能力的厂商策略 | `vendor/<厂商>.rb`、`vendor/<厂商>/` |
16
+ | 文件存储 | 路径锁、安全读取、私有原子写入、离线配置 | `storage.rb`、`storage/` |
17
+ | 解析 | TextFSM 模板选择、记录转换及异常映射 | `textfsm.rb`、`templates/` |
18
+ | 终端文本 | 严格 UTF-8 字节验证和终端渲染 | `engine/terminal_text.rb`、`engine/terminal_renderer.rb` |
19
+ | 清单与批量编排 | 清单预算、策略快照、计划校验、工作线程和报告 | `netdisco/` |
20
+
21
+ `Base` 引入 `RunningConfig::Capability`、`LocalBackup::Capability`、`Tftp::Capability`、`Topology::Capability`、`TextFSM::Capability` 及 `SaveConfig`。能力入口与实现共置,Base 不再重复业务包装。配置采集的临时策略绑定也由 RunningConfig 管理;备份与拓扑通过 `device.running_config` 复用同一流程。
27
22
 
28
23
  ## 公开业务方法与确认范围
29
24
 
@@ -33,18 +28,17 @@
33
28
 
34
29
  | 入口 | 正常返回 | 失败和前置约束 |
35
30
  | --- | --- | --- |
36
- | `execute`、`execute_script` / `run` | `Result`,`steps` 保留完整已完成响应 | 参数/脚本构造错误可直接抛出;执行、钩子和预算错误通常进入 Result,不自动重放 |
37
- | `running_config` / `collect_config` | `Result`,`config` 为通过完整性检查的清理副本 | 执行或清理失败保留步骤;自定义档案/策略构造错误仍可能直接抛出 |
31
+ | `execute_command`、`execute_script` | `Result`,`steps` 保留完整已完成响应 | 参数/脚本构造错误可直接抛出;执行、钩子和预算错误通常进入 Result,不自动重放 |
32
+ | `running_config` | `Result`,`config` 为通过完整性检查的清理副本 | 执行或清理失败保留步骤;自定义档案/策略构造错误仍可能直接抛出 |
38
33
  | `save_config` | `Result` | 不支持时为失败 Result;该入口确认命令响应,不提供拓扑读回或设备介质验证 |
39
34
  | `backup(path:)` | `Backup`,含 path、bytes、sha256、collected_at、change、previous_sha256 | 采集/文件错误直接抛出;已 rename 后的持久性错误携带完成回执,不能视为未写入 |
40
- | `tftp_backup` | 原三成员 `TftpBackup(server, path, completed_at)` | 参数先校验;失败/未确认直接抛出。完成后故障为 `TftpCompletionError`,保留回执 |
41
- | `tftp_backup_receipt` | 包含原 transfer 的 `TftpReceipt` | 与旧入口相同;当前执行路径只报告 `device_reported`,无服务端摘要 |
35
+ | `tftp_backup` | `TftpReceipt`,直接包含 server、path、时间、来源、格式及核验等级 | 参数先校验;失败/未确认直接抛出。完成后故障为 `TftpCompletionError`,保留回执 |
42
36
  | `parse_command`、`parse_config` | TextFSM 记录数组 | 命令失败、模板错误、严格 UTF-8/解析错误直接抛出,不用空数组代替未知输出 |
43
37
  | `neighbors`、`interface_descriptions` | `Neighbor` 数组、接口到描述的 Hash | 不支持、未知/部分输出或读取失败直接抛出;明确空表才允许空结果 |
44
38
  | `plan_interface_descriptions` | 冻结的 `Topology::Plan` | 只读证据和能力检查;PAN-OS 当前拒绝自动改写计划,无隐式 commit |
45
39
  | `apply_interface_descriptions(plan, confirmed:)` | `Result`,包括变更后已完成的读回/保存步骤 | 未确认、错误设备/旧命令序列、能力不足及重验失败在写入前直接抛出;`stale_plan` 是 `DeviceError`。开始变更后的领域失败保留步骤 |
46
- | `SavedConfig#read/#parse/#export` | 原始字节、记录数组;export 返回目标路径或 stdout 模式的 nil | 纯本地;缺失/歧义、不安全文件、快照失效、解析或写入错误直接抛出 |
47
- | `Fleet#backup_all/#tftp_backup_all` | 默认 `Batch`;显式 schema 2 为 `Report` | 设置、外部计划和清单错误在派发前抛出;设备/关闭/回调/报告普通故障保留在批次中,线程中断仍清理后传播 |
40
+ | `SavedConfig#read/#parse/#export` | 原始字节、记录数组;export 返回目标路径或 stdout 模式的 nil | 纯本地,只读取规范地址文件;缺失、不安全文件、解析或写入错误直接抛出 |
41
+ | `Fleet#backup_all/#tftp_backup_all` | 统一 `Report`,内部包含原始执行快照 `Batch` | 设置、外部计划和清单错误在派发前抛出;设备/关闭/回调/报告普通故障保留在批次中,线程中断仍清理后传播 |
48
42
 
49
43
  确认分为三个独立层次:
50
44
 
@@ -62,21 +56,21 @@
62
56
 
63
57
  `LocalBackup` 在采集前取得 `BackupLock`,持有到摘要比较、替换与结果构造结束。锁名由 realpath 父目录与归一化文件名决定;Unicode NFC、大小写折叠后的摘要同时覆盖尚未创建的目标。区分大小写的文件系统也保守合并这些锁,目标名称本身不变。锁文件用 NOFOLLOW/0600 打开,再验证普通文件、所有者、单硬链接和 inode;释放仅关闭 FD,不删除锁文件。默认 `flock(LOCK_EX | LOCK_NB)`,可选等待共用有限单调期限,超时均为 `BackupBusy`。
64
58
 
65
- Fleet 在旧命名基线读取、凭据解析及连接器构造之前加同一把锁,再向下层备份授权一次同进程、同 Fiber 的借用。借用在采集前消费,回调递归备份不能重复使用;没有全局路径缓存。路径锁在外、会话锁在内,`Base#backup` 拒绝已有会话操作中的嵌套调用。独立进程必须采用同一 flock 协议;调用方保护目录及祖先,锁不约束不合作的写入者。
59
+ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把锁,再向下层备份授权一次同进程、同 Fiber 的借用。借用在采集前消费,回调递归备份不能重复使用;没有全局路径缓存。路径锁在外、会话锁在内,`Base#backup` 拒绝已有会话操作中的嵌套调用。独立进程必须采用同一 flock 协议;调用方保护目录及祖先,锁不约束不合作的写入者。
66
60
 
67
- `SafeFile` 对一次 NOFOLLOW/NONBLOCK 打开的 FD 做 fstat 和读取,拒绝非普通文件。`SavedConfig#read/#fingerprint`、离线导出和解析复用该边界;`find` 仅兼容返回当时已验证的路径,不保证调用方日后重新打开时身份不变。直接备份继续安全替换末级符号链接,Fleet/SavedConfig 则保留拒绝契约。内容不变时再核对目录项身份,避免因路径替换跳过必要写入;正常未变文件保持 mtime。
61
+ `SafeFile` 对一次 NOFOLLOW/NONBLOCK 打开的 FD 做 fstat 和读取,拒绝非普通文件。`SavedConfig#read/#fingerprint`、离线导出和解析复用该边界;`find` 返回当时已验证的路径,不保证调用方日后重新打开时身份不变。直接备份继续安全替换末级符号链接,Fleet/SavedConfig 则保留拒绝契约。内容不变时再核对目录项身份,避免因路径替换跳过必要写入;正常未变文件保持 mtime。
68
62
 
69
- `PrivateFile.write` 仍返回传入路径,内部 `write_receipt` 执行临时文件写入、flush/fsync、rename、父目录 fsync,并跟踪 not_committed/committed/durable 和出错阶段。rename 前失败保留旧文件;之后失败保留新文件。目录 fsync 的 EINVAL/ENOSYS/ENOTSUP/EOPNOTSUPP/NotImplementedError 单独表示不支持;其他错误不能降级为成功。durable 只表示同步调用成功,未变内容不补验过去写入,断电恢复也未在本地测试中验证。
63
+ `Storage::PrivateFile.write` 统一返回 `Receipt`,执行临时文件写入、flush/fsync、rename、父目录 fsync,并跟踪 not_committed/committed/durable 和出错阶段。rename 前失败保留旧文件;之后失败保留新文件。目录 fsync 的 EINVAL/ENOSYS/ENOTSUP/EOPNOTSUPP/NotImplementedError 单独表示不支持;其他错误不能降级为成功。durable 只表示同步调用成功,未变内容不补验过去写入,断电恢复也未在本地测试中验证。
70
64
 
71
- 提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;旧 Data 成员、JSON schema 和 strict 策略不变,设备命令不会自动重放。
65
+ 提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
72
66
 
73
67
  TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
74
68
 
75
69
  TFTP 内置策略的 `validate_options!` 是纯参数校验,执行于 H3C 源文件探测之前。整个探测、脚本、证据核对、回执和事件记录由原有 `with_operation(:tftp_backup)` 独占;其他线程、Fiber 及回调重入均不能插入设备命令。租约只保护当前会话,不能隔离其他设备连接或服务器上的同名文件。
76
70
 
77
- `tftp_backup` 继续返回原三字段 `TftpBackup`;`tftp_backup_receipt` 以不可变 `TftpReceipt` 组合来源、格式、请求与实际路径、核验等级。只有设备完成证据时为 `device_reported`,没有服务器 SHA-256。H3C 自动源探测为 startup,显式文件为 saved_file/unknown;山石为 startup/dat,Cisco 为 running/cfg,PAN-OS 为 running/xml,Radware 为 native_archive/tgz。扩展策略未声明时为 unknown,不能据文件扩展名或字节数推断来源或服务器内容。
71
+ `tftp_backup` 只返回不可变 `TftpReceipt`,直接包含来源、格式、请求路径和实际 `path`、完成时间及核验等级。只有设备完成证据时为 device_reported,没有服务器 SHA-256。H3C 自动源探测为 startup,显式文件为 saved_file/unknown;山石为 startup/dat,Cisco 为 running/cfg,PAN-OS 为 running/xml,Radware 为 native_archive/tgz。公共策略的默认来源为 unknown,不能据扩展名或字节数推断服务器内容。
78
72
 
79
- 明确完成证据先生成最小回执,再执行路径提取、元数据钩子及事件记录。已完成步骤之后的清理错误同样保留回执;路径不可用时保留 nil,不把请求文件名冒充实际目标。`TftpCompletionError` 只含受控码、类型和回执,不带原始消息、输出或 cause。Fleet 仅信任库定义的精确错误类及匹配产物;第三方异常上的 transfer 字段和错误子类不能供应完成事实。默认报告成员及 strict 成功规则不变,已上传但收尾失败使用现有 reported_with_error/partial 分类。
73
+ 明确完成证据先生成最小回执,再执行路径提取、元数据钩子及事件记录。已完成步骤之后的清理错误同样保留回执;路径不可用时保留 nil,不把请求文件名冒充实际目标。`TftpCompletionError` 只含受控码、类型和回执,不带原始消息、输出或 cause。Fleet 仅信任库定义的精确错误类及匹配产物;第三方异常上的 receipt 字段和错误子类不能供应完成事实。已上传但收尾失败使用 reported_with_error/partial 分类。
80
74
 
81
75
  `Topology` 读取邻居和旧描述,冻结计划,要求显式确认,并在写入前重验全部证据和重建的命令。`ImmediateStrategy` 将即时生效设备的修改/退出视图、运行配置读回、保存拆开:读回未确认或有未识别接口块时停止,不发送保存。重验、下发、读回、保存及阶段间隙都持有原有会话租约;只有所属线程和 Fiber 能顺序执行脚本,其他调用、关闭请求和脚本回调重入均返回 `SessionBusy`。租约不隔离其他设备会话或管理员。
82
76
 
@@ -102,21 +96,27 @@ PAN-OS 使用候选配置模型。[官方提交说明](https://docs.paloaltonetw
102
96
 
103
97
  `Worker` 按线程完成顺序接收终止通知,设备结果仍写入原清单槽位。任一线程中断时,调用方无需等待先创建的慢线程;创建后续线程失败时,也会停止并等待已启动的任务清理资源。普通设备故障继续转换为逐台结果,回调故障单独记录。
104
98
 
105
- 设备耗时由 CLOCK_MONOTONIC 度量,包含 on_start、设备任务及关闭,截止于 on_result 前。批次 v2 的总耗时覆盖目录准备和全部 worker/callback,截止于报告写入前;两者不以 UTC 时间相减。Outcome 以非成员元数据保留耗时和安全 Diagnostic,with 在 Worker 时间赋值和产物迁移时保留它们;显式替换审计时间会清除旧计时,替换错误字段会清除旧诊断。手工创建的旧 Outcome 没有单调计时,继续按墙钟差计算并将负值限制为零。Data 的 members/deconstruct/to_h 不添加字段。
99
+ 设备耗时由 CLOCK_MONOTONIC 度量,包含 on_start、设备任务及关闭,截止于 on_result 前。报告的总耗时覆盖目录准备和全部 worker/callback,截止于报告写入前;两者不以 UTC 时间相减。Outcome 的 duration_ms 和 Diagnostic 直接声明为 Data 成员,未测量的耗时为 nil。`with` 替换审计时间时清除原计时,替换错误字段时清除原诊断;显式提供的新值仍经过验证。
106
100
 
107
- 默认 Fleet 返回 Batch、保存 schema 1,旧 success? 和 status 不变。显式 report_schema: 2 返回组合式 Report;success_policy: :selected 自动选择 v2,selected 配合 schema 1 在清单读取前被拒绝。Report 委托旧 Batch 的业务访问器和严格 success?/status,单独提供 policy 和 policy_success?;原批次可通过 report.batch 访问。selected 必须至少包含一个成功结果,其他状态只能为 filtered/sample_limit,且 callback_errors 为空、report_error 为 nil。部分成功、任何其他跳过和未知状态都阻止策略成功。
101
+ Fleet 始终返回 Report,委托 Batch 的执行结果及严格 success?/status,单独提供 policy 和 policy_success?。默认 strict 要求非空且全部成功;selected 必须至少包含一个成功结果,其他状态只能为 filtered/sample_limit,且 callback_errors 为空、report_error 为 nil。部分成功、任何其他跳过和未知状态都阻止策略成功。
108
102
 
109
- v2 summary 增加 schema_version、policy、policy_success、coverage、单调 duration_ms、逐台 diagnostic 及 report_diagnostic。coverage 仅按已尝试状态计数,失败和部分成功也算尝试,不能替代成功判定。批次报告使用调用方原有 write(result, directory:) 签名;默认仍传 Batch,v2 传提供相同业务访问器的 Report。已存文件是写入前快照;写入自身失败时,返回对象与 CLI 才能携带最终报告故障。只对已有 Batch 调用 report 不重跑任务,也不能补出原先未记录的批次总耗时或报告写入阶段。
103
+ 报告 summary 使用单一 schema_version: 2,包含 policy、policy_success、coverage、单调 duration_ms、逐台 diagnostic 及 report_diagnostic。coverage 仅按已尝试状态计数,失败和部分成功也算尝试。`ResultStore#write(report, directory:)` 始终接收 Report。已存文件是写入前快照;写入自身失败时,返回对象与 CLI 携带最终报告故障。`Batch#build_report` 只构造报告,不重跑任务,也不能补出未记录的总耗时。
110
104
 
111
- Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态;不调用异常 inspect/to_h,不保留异常引用、正文、消息、回溯、命令、source 或 line。未知错误码/阶段为 nil,未知类型归为 StandardError。v2 会重新筛选旧 Batch 的 error_code/error_type、callback_errors 和 report_error,不能因旧字段名看似安全就直接扩充它。产物阶段仅来自与实际 Backup/TftpBackup 匹配的库内精确错误类;通用文件回执只在报告写入边界使用,不能冒充设备备份完成。默认旧 JSON 的成员与自定义错误字段值保持原契约。
105
+ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态;不调用异常 inspect/to_h,不保留异常引用、正文、消息、回溯、命令、source 或 line。未知错误码/阶段为 nil,未知类型归为 StandardError。Report 也筛选手工构造结果中的 error_code/error_type、callback_errors 和 report_error。产物阶段仅来自与实际 Backup/TftpReceipt 匹配的库内精确错误类;通用文件回执只在报告写入边界使用,不能冒充设备备份完成。
112
106
 
113
107
  `Planner` 先按厂商采样,再按实际 TFTP 文件名排除覆盖冲突,保留采样顺序中的首台设备。未入选设备仍标记为 `sample_limit`,只有入选后目标重名才标记为 `remote_filename_collision`。`Plan#validate!` 校验任务与清单的对应关系;调用方传入或通过 `with` 修改的计划也必须在读取凭据、创建目录及设备 I/O 前通过校验。冲突规则适用于全部厂商,包括不同地址规范化后产生相同文件名的情况。
114
108
 
115
109
  `Settings` 保留动态秘密来源,`snapshot(mode:)` 只复制允许公开的非敏感字段,并在副作用前复用 `Configuration`、`Planner` 和 `Client.options` 校验。Fleet 的一次规划/执行只使用该份冻结策略;CLI 通过 `for_run` 将规划与执行绑定到同一策略。每台设备的凭据读取与策略解析分开,自定义凭据解析器给出的显式连接选项仍保留原优先级。带 `plan:` 的执行不再查询清单;以后新建的批次可以读取新策略和新的 API 凭据。
116
110
 
117
- `Client#devices` 每次持有独立 `InventoryBudget`,认证、分页、旧式查询共同累计正文字节和去重前记录数,并共享单调时钟 deadline。默认 HTTP 使用 `read_body`,先检查实际接收字节再追加,不信任 Content-Length。每阶段缩短原生连接/读/写超时;总期限还覆盖慢速响应头。期限观察线程只关闭本次拥有的 HTTP 连接,防止 chunked 的收尾读取拖延返回,调用结束即唤醒并 join。默认 HTTP 不做隐式 GET 重试;这一机制不进入设备命令路径。
111
+ `Client#devices` 每次持有独立 `InventoryBudget`,认证、分页、QueryRequired 查询共同累计正文字节和去重前记录数,并共享单调时钟 deadline。默认 HTTP 使用 `read_body`,先检查实际接收字节再追加,不信任 Content-Length。每阶段缩短原生连接/读/写超时;总期限还覆盖慢速响应头。期限观察线程只关闭本次拥有的 HTTP 连接,防止 chunked 的收尾读取拖延返回,调用结束即唤醒并 join。默认 HTTP 不做隐式 GET 重试;这一机制不进入设备命令路径。
118
112
 
119
- 注入的旧式 `requester` 仍只接收 URI 和 request。它返回后才接受长度和期限检查,不在任意用户 Ruby 回调中注入异步异常。解析和集合操作完成后也检查期限,但这不构成任意 CPU 回调的抢占保证。预算失败仅报告错误码与安全类型,没有部分清单、响应正文或底层 cause。HTTPS 的标准证书检查不变;明文 HTTP 默认兼容,可通过显式策略禁止。
113
+ 注入的 `requester` 接收 URI 和 request。它返回后才接受长度和期限检查,不在任意用户 Ruby 回调中注入异步异常。解析和集合操作完成后也检查期限,但这不构成任意 CPU 回调的抢占保证。预算失败仅报告错误码与安全类型,没有部分清单、响应正文或底层 cause。HTTPS 的标准证书检查不变;默认允许明文 HTTP,可通过显式策略禁止。
114
+
115
+ `Settings#inventory_source` 选择 HTTP `Client` 或 PostgreSQL `DatabaseClient`,两者只向 Fleet 提供 `devices`,不改变设备映射、计划和执行接口。数据库 SQL 与标量参数由用户配置,不内置业务表名或筛选;结果用列别名适配 `Client::FIELDS`,只保留这份现有字段白名单。连接参数由 `DatabaseClient::CONNECTION_ENV` 集中映射到 Netdisco 的 `NETDISCO_DB_*` 环境变量,全部排除在纯策略快照外;每次新建清单客户端时读取最新值。
116
+
117
+ `DatabaseClient` 每次调用独占 PostgreSQL 连接,在只读事务中用扩展查询协议声明游标,原生解析器拒绝多语句和非查询输入。FETCH 大小及次数复用清单预算,单行模式使 libpq 不缓存整页;每行在追加前计数,额外列也消耗字节预算。驱动必须先解码单行,所以单个超大字段仍可超过 Ruby 预算的瞬时内存。总 deadline 覆盖连接和所有语句,每次 FETCH 前缩短 statement_timeout;正常完成回滚只读事务,异常关闭连接,任何失败均不交付部分清单。NOTICE、原始数据库异常及 cause 不进入日志或 CLI;稳定错误码区分连接失败、查询失败、无效清单和预算超限。SQL/参数可在 show-config 中查看,不能用于传递凭据。
118
+
119
+ 数据库集成测试运行 `bundle exec rake test:postgres`,通过 `pg_config --bindir`(或 `NET_CONNECTOR_TEST_PG_BINDIR`)定位服务端工具。测试只创建临时 SCRAM 数据库和私有 Unix socket,退出时停止并删除;不会读取真实 Netdisco 凭据或连接已有服务。覆盖 SQL/参数、只读限制、真实认证失败、查询中途失败、各项预算、连接回收以及 CLI 计划。
120
120
 
121
121
  ## 迁移与尚未启用的能力
122
122
 
@@ -128,7 +128,7 @@ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态
128
128
  | 文件锁及持久性 | 备份前获取稳定的私有锁文件,调用方不要按目录文件总数推断备份数或在运行中删除锁文件;已提交但收尾失败仍有产物,不自动覆盖重试 |
129
129
  | 清单和脚本预算 | 清单预算默认有界;max_script_output_bytes 默认为 nil、显式启用。两者都不是整批设备执行的硬 deadline,累计脚本超限不表示命令未执行 |
130
130
  | HTTP | HTTPS 校验保持;默认允许既有明文 HTTP,部署可显式禁止。设置快照不冻结逐设备动态凭据解析 |
131
- | 报告和成功策略 | 默认 strict/schema 1 保持;selected 自动使用 schema 2,只有预期过滤/采样跳过可忽略。设备部分成功、回调/报告错误仍阻止策略成功 |
131
+ | 报告和成功策略 | 统一 Report/schema 2;默认 strict,selected 只忽略预期过滤/采样跳过。设备部分成功、回调/报告错误仍阻止策略成功 |
132
132
  | 解析 | 只在解析副本上严格检查 UTF-8;非法数据直接报错。原始备份/导出不转码,其他设备编码等待真实样本再扩展 |
133
133
 
134
134
  任务书 NC-12(协作取消和整批 deadline)为后置可选项,本轮 **deferred**:没有新增
@@ -162,13 +162,13 @@ NC-07C(TFTP 服务端验证适配器)同为可选扩展,本轮 **deferred*
162
162
 
163
163
  提示符和交互标记是 Ruby 正则表达式,应短到能放入未匹配尾部;流式适配器不支持需要无限长历史的模式。收集输出的上限与匹配窗口上限不同。终端渲染器只处理常见行编辑控制符,不是完整屏幕终端模拟器。`Profile#terminal_size` 使用 `[宽, 高]`,PTY 适配器转换为 Ruby 的 `[行, 列]`。
164
164
 
165
- `max_script_output_bytes` 为正 Integer 或 nil。计数属于每个 Execution,主命令以及准备、提示符和后处理钩子的 `Execution#query` 共用计数,包含原始分页标记和最终提示符,不能用 `capture: false` 绕过。登录和提权认证保留各自原有单响应上限。每次脚本新建计数器;一次租约中的多个脚本、多个设备和多个批次不共用它。
165
+ `max_script_output_bytes` 为正 Integer 或 nil。计数属于每个 Execution,主命令以及准备、提示符和后处理钩子的 `Execution#execute_command` 共用计数,包含原始分页标记和最终提示符,不能用 `capture: false` 绕过。登录和提权认证保留各自原有单响应上限。每次脚本新建计数器;一次租约中的多个脚本、多个设备和多个批次不共用它。
166
166
 
167
- 累计等于上限时当前脚本可正常结束,但下一条查询在发送前失败;超过上限的完整主响应先进入 steps,再返回 `ScriptOutputLimitExceeded`,后处理和下一命令不会继续。追加查询沿用不进入公开 steps 的旧契约,但计入预算并在错误中保留实际查询命令。吞掉查询预算异常的钩子不能让超额脚本报告成功。已执行命令不重放;确认的 TFTP 完成行仍可构造带收尾错误的回执。
167
+ 累计等于上限时当前脚本可正常结束,但下一条查询在发送前失败;超过上限的完整主响应先进入 steps,再返回 `ScriptOutputLimitExceeded`,后处理和下一命令不会继续。追加查询不进入主脚本的公开 steps,但计入预算并在错误中保留实际查询命令。吞掉查询预算异常的钩子不能让超额脚本报告成功。已执行命令不重放;确认的 TFTP 完成行仍可构造带收尾错误的回执。
168
168
 
169
- 累计检查不在读取中途切断当前命令,最多还会接收一个受 `max_output_bytes` 限制的响应;已完成步骤不截断或丢弃。配置清理、解析、用户回调和反复 `Result#output` 的副本不计入该字节预算,因此它不是 RSS 或整批内存硬上限。保持默认 nil 是兼容性决定,合成基准不足以确定适合所有设备的默认阈值。设置通过 YAML `ssh.max_script_output_bytes`、`NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES` 和 CLI 同名选项进入批次策略快照,优先级为 CLI > ENV > YAML;默认不向旧连接器工厂添加新键,显式凭据 resolver 仍可覆盖连接参数。
169
+ 累计检查不在读取中途切断当前命令,最多还会接收一个受 `max_output_bytes` 限制的响应;已完成步骤不截断或丢弃。配置清理、解析、用户回调和反复 `Result#output` 的副本不计入该字节预算,因此它不是 RSS 或整批内存硬上限。合成基准不足以确定适合所有设备的默认阈值,因此默认不限制累计值。设置通过 YAML `ssh.max_script_output_bytes`、`NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES` 和 CLI 同名选项进入批次策略快照,优先级为 CLI > ENV > YAML;未配置时不传递该可选键,显式凭据 resolver 可覆盖连接参数。
170
170
 
171
- Ruby 对象显式拥有资源并使用关键字参数。`Profile` 提供有限声明入口,厂商策略负责差异行为。公开方法、厂商钩子、结果对象和 CLI JSON 字段延续现有契约;内部不做运行时方法注入,也没有工作流 DSL。
171
+ Ruby 对象显式拥有资源并使用关键字参数。`Profile` 提供有限声明入口,厂商策略负责差异行为。公开方法、厂商钩子、结果对象和 CLI JSON 字段以当前文档为准;内部不做运行时方法注入,也没有工作流 DSL。
172
172
 
173
173
  ## 关键依赖的维护状态与替代方案
174
174
 
@@ -192,7 +192,7 @@ Ruby 对象显式拥有资源并使用关键字参数。`Profile` 提供有限
192
192
  | 依赖 | 已有隔离边界 | 替代方案与验收条件 |
193
193
  | --- | --- | --- |
194
194
  | `expect-pty` | `Transports::Pty` 包装信道,设备构造支持 `transport:` 注入 | 可维护受控分支,或实现 Ruby `PTY` / `IO.select` 适配器;必须通过有序匹配、分片、写入超时、EOF、中断、子进程回收及日志脱敏测试 |
195
- | `textfsm` | `Operations::ParseOutput` 集中构造解析器和映射异常 | 可维护受控分支,或在该入口接入另一解析实现;必须保持索引选择、模板语义、输出字段及错误码,并验证现有和新增模板样本 |
195
+ | `textfsm` | `Net::Connector::TextFSM` 集中构造解析器和映射异常 | 可维护受控分支,或在该入口接入另一解析实现;必须保持索引选择、模板语义、输出字段及错误码,并验证现有和新增模板样本 |
196
196
 
197
197
  传输替换还不是完全即插即用:`Session`、`ResponseReader`、`Authentication`、
198
198
  `Execution` 等直接使用 `Expect.monotonic`,配置及命令校验使用
@@ -250,15 +250,15 @@ router = VariantRouter.new(host: "192.0.2.10", username: "operator")
250
250
  router.supports?(:tftp_backup) # => true,不连接设备
251
251
  ```
252
252
 
253
- 配置采集可把 `running_config_strategy` 绑定到 `Net::Connector::RunningConfig::Strategy` 子类。策略定义清理、结果步骤、视图提示符和逐响应检查。PAN-OS 候选差异只影响配置采集;直接 `execute("show config diff")` 仍返回该命令输出。静态命令列表留在档案中。已有的 `collect_config`、`clean_config` 和受保护的 `config_result_step` 钩子仍可覆盖并调用 `super`。`Base` 负责通用脚本与锁内回调,不决定配置是否完整。
253
+ 配置采集可把 `running_config_strategy` 绑定到 `Net::Connector::RunningConfig::Strategy` 子类。策略实现 `clean`、`result_step`、`prompt_text` 和 `validate_response!`;PAN-OS 候选差异只影响配置采集,直接 `execute_command("show config diff")` 返回该命令输出。静态命令列表留在档案中。`clean_config` 和受保护的 `config_result_step` 钩子可覆盖并调用 `super`。Base 负责通用脚本与锁内回调,不决定配置是否完整。
254
254
 
255
- 拓扑策略通过 `topology_strategy YourStrategy` 绑定。类方法 `supports?` 声明三种拓扑能力,实例方法提供命令、模板、输出完整性和接口拼写。厂商使用自定义清单标签时,还须通过 `neighbor_template` 提供 TextFSM 模板,因为内置索引只匹配已有厂商键。变更策略必须声明 `leave_configuration`、`verification_commands`、`persistence_commands` 和 `persistence_confirmed?`;仅实现旧 finish_commands 的策略仍可读取,但不能据此猜测保存边界,计划返回 description_stages_unsupported。即时生效且使用 interface 块的设备可继承 `Topology::ImmediateStrategy`;自定义采集必须执行已列入计划的命令并返回完成步骤。
255
+ 拓扑策略通过 `topology_strategy YourStrategy` 绑定。类方法和实例方法 `supports?` 声明三种拓扑能力,其余方法提供命令、模板、输出完整性和接口拼写。Profile 校验完整接口,包括 `validate_descriptions!`、`change_error_code`、`enter_configuration_command`、`leave_configuration_commands`、`verification_commands`、`persistence_commands` 和 `persistence_confirmed?`。只读策略继承默认空阶段;可写策略必须明确每个阶段,不能用合并的收尾命令推断保存边界。即时生效且使用 interface 块的设备可继承 `Topology::ImmediateStrategy`。自定义采集必须执行已列入计划的命令并返回完成步骤;自定义厂商标签通过 `neighbor_template` 提供模板。
256
256
 
257
257
  业务操作针对单个连接器构造,并提供 `call`。TFTP 策略只处理厂商传输差异,公共操作统一成功和失败规则。生成的文件名经过 `TftpTarget` 校验及长度限制;带作用域的 IPv6 地址会转为安全 ASCII 标记,特别长的地址使用稳定 SHA-256 标记。原本合法的文件名保留拼写,只有整体过长才缩短清单名称。TFTP 返回值表示设备报告上传完成,服务器文件核对仍由调用方负责。
258
258
 
259
- TFTP 策略的类方法 `filename(host, label: nil)` 是不产生 I/O 的命名接口,默认使用 `file_extension` 声明的扩展名。Netdisco 只清理清单名称,不再维护厂商扩展名或固定文件名的分支。Radware 和山石分别声明 `tgz`、`dat`,PAN-OS 返回固定名称。旧自定义策略未实现该类方法时仍使用通用 `cfg` 名称。H3C、华为继承公共 `Tftp::FileUpload`,共用上传脚本、默认源文件名和完成证据;各自只负责取得并校验源文件。
259
+ TFTP 策略的类方法 `filename(host, label: nil)` 是必需的无 I/O 命名接口,默认使用 `file_extension` 声明的扩展名。Netdisco 只清理清单名称,不维护厂商扩展名或固定文件名分支。Radware 和山石分别声明 tgz、dat,PAN-OS 返回固定名称。H3C、华为继承公共 `Tftp::FileUpload`,共用上传脚本、默认源文件名和完成证据,各自负责源文件探测与校验。
260
260
 
261
- 自定义 TFTP 策略可增加 `validate_options!(target, source_file:, vrf:)`,要求不访问设备;可增加 `receipt_metadata(target, source_file:, explicit_source:)`,只返回 configuration_kind/source_file/format/requested_path 字段。重写 script 的子类必须同时重写相应钩子,才启用自身的预检及元数据契约;继承的厂商限制不会偷偷施加到旧扩展脚本。缺少钩子时仍做通用安全字符串校验,保持旧执行接口,但不宣称厂商参数全部已在 I/O 前验证,也不继承父脚本的配置来源声明。
261
+ TFTP 策略必须实现 `validate_options!(target, source_file:, vrf:)`、`receipt_metadata(target, source_file:, explicit_source:)`、`resolve_source_file`、`default_path`、`remote_path`、`script` 和 `device_reported_complete?`。可继承 `Tftp::Strategy` 的公共默认值;重写上传流程时必须一并审视继承的参数约束和来源声明。预检不得访问设备,元数据只允许 configuration_kind/source_file/format/requested_path。接口缺失在 Profile 构造时失败,不再动态回退到另一套行为。
262
262
 
263
263
  服务器核验适配器尚未接入;TFTP API 不会自动生成 server_verified 结果。PAN-OS 固定名仍按现有计划拒绝同批碰撞,串行运行不改变该限制,也不保证跨进程或跨批次隔离。
264
264
 
@@ -280,23 +280,49 @@ TFTP 策略的类方法 `filename(host, label: nil)` 是不产生 I/O 的命名
280
280
  不能把不确定的执行结果当成“尚未执行”。跨进程租约、断点恢复和持久队列
281
281
  属于调用平台的职责,当前 `Worker` 只负责一个进程内的有界并发与资源清理。
282
282
 
283
- ## 加载与兼容路径
283
+ ## 加载入口与厂商策略
284
+
285
+ `require "net/connector"` 加载设备 API 和引擎,厂商通过 autoload 按需加载。TextFSM 能力模块可预先组合,但外部 `textfsm` gem 只在实际解析时加载。仅需会话引擎时使用 `require "net/connector/engine/core"`;离线文件操作使用 `net/connector/storage`,不加载厂商。
284
286
 
285
- `require "net/connector"` 只加载设备 API 和引擎,不预先加载厂商规则或 TextFSM。每个厂商入口只组装自身规则和公共父类;解析操作按需加载。更底层的调用方可用 `require "net/connector/engine/core"`,不加载设备定义、业务操作或厂商规则。`engine/base`、`engine/profile`、`engine` 保留为旧入口的转发路径。
287
+ 公共策略位于 `RunningConfig::Strategy` / `Rendered`、`Tftp::Strategy` / `FileUpload`、`Topology::Strategy` / `ImmediateStrategy`。厂商文件直接依赖公共策略,由 Profile 绑定;没有反向加载和厂商转发别名。
286
288
 
287
- `require "net/connector/netdisco"`、`SavedConfig#find/#read/#export` 和 CLI 离线导出也不加载 TextFSM;`SavedConfig#parse` 才加载解析入口。每次解析构造新的有状态 Parser。`ParseOutput` 将输入副本按 UTF-8 字节校验,再复用终端渲染器的严格模式;原文中的非法字节不能被控制符擦除后绕过检查,渲染过程中损坏的多字节字符也会被拒绝。错误统一为不含正文和底层 cause 的 `invalid_output_encoding`。拓扑在厂商表头匹配、计数前先校验原文,保留原有完整性规则。日志渲染仍转义非法字节;备份字节不变。当前不自动推断或转码其他编码。
289
+ `TerminalText.utf8` 在副本上验证设备或文件的原始字节,`TerminalText.render` 再使用严格终端渲染。Ruby 源码默认 UTF-8 不决定 PTY 或 `File.binread` 返回的编码。非法原始字节不能因控制符擦除而通过检查;渲染损坏多字节字符时同样拒绝,错误为不含正文或 cause 的 `invalid_output_encoding`。拓扑在表头匹配和计数前复用验证,TextFSM 每次解析创建独立 Parser。日志允许转义非法字节,原始备份不转码。
288
290
 
289
- 旧的 `Operations::RunningConfig`、`Operations::RunningConfig::<Vendor>`、`Operations::Tftp::<Vendor>`、`Operations::Topology::<Vendor>` 常量和 require 路径都转发到同一实现类,不维护两份逻辑。已有公开结果常量也通过 autoload 保留。新增厂商代码应直接使用厂商目录下的类。
291
+ ### 当前命名与接口调整
292
+
293
+ 项目在开发阶段只维护当前接口,不保留旧路径转发或旧方法别名。
294
+
295
+ | 原名称或入口 | 当前入口 |
296
+ | --- | --- |
297
+ | `engine`、`engine/base`、`engine/profile` | `net/connector`;底层分别为 `engine/core`、`device/base`、`device/profile` |
298
+ | `Operations::*`、`operations/` | 设备流程为 `device/`;文件为 `Storage` / `storage/`;解析为 `TextFSM` / `textfsm.rb` |
299
+ | 公共层中的厂商策略别名 | `Net::Connector::<Vendor>::RunningConfig` / `TftpBackup` / `Topology`,位于 `vendor/<厂商>/` |
300
+ | `record_event` | `log_event` |
301
+ | `execute`、引擎 `query` / `exchange` | `execute_command` |
302
+ | `run`、引擎 `execute` 脚本入口 | `execute_script` |
303
+ | `collect_config` | `running_config` |
304
+ | `tftp_backup_receipt`、三字段 `TftpBackup` | `tftp_backup` 返回 `TftpReceipt`;实际目标为 `path`;完成后错误通过 `receipt` 取回 |
305
+ | `PrivateFile.write_receipt` | `Storage::PrivateFile.write` 返回持久化回执 |
306
+ | `report_schema`、`--report-schema` | 删除版本选择;Fleet 始终返回 Report,summary 固定 schema 2 |
307
+ | `Batch#report`、`Device#connector` | `build_report`、`build_connector` |
308
+ | `Settings#credentials_for` | `device_credentials_for`;非敏感设置由批次 Policy 单独提供 |
309
+ | `check_response` | `validate_response!` |
310
+ | 拓扑 `enter_configuration` / `leave_configuration` | `enter_configuration_command` / `leave_configuration_commands` |
311
+ | TFTP `source_file` / `complete?` | `resolve_source_file` / `device_reported_complete?` |
312
+ | `command_scope` / `output_scope` | `with_command_redaction` / `with_sensitive_output` |
313
+ | `LegacyIndex` 和旧名称文件查找 | 只读取规范 `<IP>.txt`,无目录扫描和迁移分支 |
314
+
315
+ 工厂方法用 `build_*` 表明返回新对象;抛错校验使用 `validate_*!`;块作用域使用明确的 `with_*`。单命令、脚本、命令文本构造和完成证据判断分别命名,避免从通用的 execute/check/complete 猜测行为。`legacy_ssh` 表示设备 SSH 协议协商选项,Netdisco QueryRequired 则是服务端 API 行为,两者均独立于本库旧版本接口。
290
316
 
291
317
  ## 接口描述规则
292
318
 
293
- 原始 `Neighbor` 字段和计划证据完整保留发现值。`InterfaceName.key` 用于匹配本机接口别名与运行配置名称;`InterfaceName.configuration` 保留配置命令的接口展开规则。`InterfaceName.short` 只决定描述里对端接口的显示形式,绝不替换本机下发命令中的接口名。
319
+ 原始 `Neighbor` 字段和计划证据完整保留发现值。`Topology::InterfaceName.key` 用于匹配本机接口别名与运行配置名称;`Topology::InterfaceName.configuration` 保留配置命令的接口展开规则。`Topology::InterfaceName.short` 只决定描述里对端接口的显示形式,绝不替换本机下发命令中的接口名。
294
320
 
295
- `InterfaceDescription.format(neighbor, abbreviate: true, lowercase: false)` 是各厂商默认计划共用的纯函数,生成 `To <名称> <接口>`。已知接口族会缩写,并保留原有大小写:Ethernet/Eth 为 Eth,GigabitEthernet/GE/Gi 为 Gi,Ten-GigabitEthernet/TenGigabitEthernet/XGE/Te 为 Te,FastEthernet/Fa 为 Fa,port-channel/Po 为 Po。端口编号与子接口后缀保留。`ge-0/0/1`、`100GE1/0/1`、`Port 12` 等未知形式默认不变;这是有限映射,不声称识别全部厂商命名。
321
+ `Topology::InterfaceDescription.format(neighbor, abbreviate: true, lowercase: false)` 是各厂商默认计划共用的纯函数,生成 `To <名称> <接口>`。已知接口族会缩写,并保留原有大小写:Ethernet/Eth 为 Eth,GigabitEthernet/GE/Gi 为 Gi,Ten-GigabitEthernet/TenGigabitEthernet/XGE/Te 为 Te,FastEthernet/Fa 为 Fa,port-channel/Po 为 Po。端口编号与子接口后缀保留。`ge-0/0/1`、`100GE1/0/1`、`Port 12` 等未知形式默认不变;这是有限映射,不声称识别全部厂商命名。
296
322
 
297
323
  默认建议因此从 `To peer Ethernet1/2` 变为 `To peer Eth1/2`。`abbreviate: false` 保留原拼写,`lowercase: true` 仅将对端接口改为小写。自定义代码块拿到原始邻居,可生成完整描述。输出仍必须经过 80 字节及字符校验、证据复核、确认和回读。
298
324
 
299
- `InterfaceDescription.commands(interface:, description:, leave: "exit")` 为 IOS/NX-OS 和山石分别生成 `interface`、`description`、退出命令;H3C 使用 `leave: "quit"`。进入、退出全局配置视图和保存完成证据由厂商负责。旧 finish_commands 及 PAN-OS 的纯命令构造/超时辅助方法保留,公共执行流程不会借此启用未经验证的候选提交。命令构造器本身不连接设备,也不能绕开已审核的拓扑计划。
325
+ `Topology::InterfaceDescription.commands(interface:, description:, leave: "exit")` 为 IOS/NX-OS 和山石分别生成 `interface`、`description`、退出命令;H3C 使用 `leave: "quit"`。进入、退出全局配置视图和保存完成证据由厂商负责。各阶段命令分别由策略声明,PAN-OS 的命令构造/超时辅助方法不会启用未经验证的候选提交。命令构造器本身不连接设备,也不能绕开已审核的拓扑计划。
300
326
 
301
327
  ## 敏感命令的生命周期
302
328
 
@@ -308,25 +334,37 @@ TFTP 策略的类方法 `filename(host, label: nil)` 是不产生 I/O 的命名
308
334
 
309
335
  `RunningConfig` 将每个厂商采集步骤标记为输出敏感,包括 PAN-OS 的前后候选差异查询。`Base#perform_script` 在已持有会话锁、已登录后保护批次准备,在执行完命令后保护结果选择和清理;两处都在离开范围前归一化异常。命令范围覆盖厂商准备、追加查询和回调,即使钩子用新命令替换原命令也继承原有保护。敏感标记不改变锁的 Thread/Fiber 所有权,不开放回调重入,不触发自动重放。
310
336
 
311
- 这改变了配置采集的日志默认值:text/debug/raw 文件及注入的 logger 不再得到配置正文,没有关闭该保护的采集开关。显式的 `execute`/`Script` 保持原默认值,调用方编写读取配置的命令时应指定 `output_sensitive: true`;命令文本含秘密时仍须同时使用 `sensitive: true`。普通脚本下一步及下一次操作恢复各自的诊断规则。配置正文不会加入长期脱敏词表,也不会为了日志而清洗 `Result.steps`、`Result.config` 或备份字节。
337
+ 这改变了配置采集的日志默认值:text/debug/raw 文件及注入的 logger 不再得到配置正文,没有关闭该保护的采集开关。显式的 `execute_command`/`Script` 保持原默认值,调用方编写读取配置的命令时应指定 `output_sensitive: true`;命令文本含秘密时仍须同时使用 `sensitive: true`。普通脚本下一步及下一次操作恢复各自的诊断规则。配置正文不会加入长期脱敏词表,也不会为了日志而清洗 `Result.steps`、`Result.config` 或备份字节。
312
338
 
313
339
  保护覆盖连接器管理的日志与异常边界。调用方自行将 `result.output`、`value!` 或钩子收到的原始响应写入其他系统,仍是在显式处理敏感业务数据。脱敏不表示设备命令未执行;失败结果中的已完成步骤必须保留,不能据此自动重试命令。
314
340
 
315
- ## 本地备份标识与旧文件
341
+ ## 本地备份标识
342
+
343
+ 批量备份只用规范化管理地址命名为 `<IP>.txt`,IPv6 的 `:` 改为 `_`。清单名称保留在结果元数据及 TFTP 文件名中。`Storage::SavedConfig` 只读取该规范路径,不扫描其他名称;缺失时 required 查找或读取抛出 ENOENT,`find(required: false)` 返回 nil。
344
+
345
+ 新建规范文件时报告 created,之后只与同一路径的内容比较。设备改名不会改变身份或基线,其他名称文件不会被读取、迁移或覆盖。Fleet 在凭据解析和连接前检查规范目标,拒绝符号链接和非普通文件;持有路径锁直至备份结束。`SafeFile` 在同一 FD 上完成检查和读取;它与 flock 均不能阻止不合作的外部写入者,目录保护仍由调用方负责。
346
+
347
+ ## 日志组件与事件
348
+
349
+ `Log` 管理日志资源、级别和上下文;`Log::Event` 冻结已脱敏的标量字段;`Log::Formatter` 提供标准 Logger 文本格式;`Log::Transcript` 以行组织设备回显,`Log::RedactingWriter` 将分片直接交给 Expect::Redactor。日志模块不再实现另一套秘密匹配算法。
350
+
351
+ 连接、命令和操作失败统一经过 `Log#log_failure`。错误类型、错误码和阶段由 `ErrorMetadata` 的固定词表过滤,Netdisco 报告复用同一规则;未知错误码不输出,未知阶段使用引擎当前阶段,未知类型归为 StandardError。异常正文仍先由会话脱敏,防止配置钩子把未登记的配置放进错误元数据后进入日志。
352
+
353
+ 操作后处理无论抛出异常还是返回失败 Result,都在敏感作用域退出前归一化错误,再记录完成事件;Result 的已完成步骤及配置内容保持原样。最终处理继承实际执行的命令及交互敏感性,包括准备钩子、替换命令和追加查询;只累计标记,不长期保留秘密,也不影响后续普通命令的日志。
316
354
 
317
- 批量备份只用规范化管理地址命名为 `<IP>.txt`,IPv6 的 `:` 改为 `_`。清单名称仍保留在结果元数据及 TFTP 文件名中。`SavedConfig` 优先使用规范文件;缺失时只接受唯一的旧版 `<名称>-<IP>.txt`。匹配多个旧文件时明确失败,不按修改时间随意选择。
355
+ 错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause 或调用栈。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
318
356
 
319
- 首次成功的规范备份会与唯一旧文件的哈希比较,旧文件保持不变,并报告 `changed` 或 `unchanged`。采集失败不会创建规范文件。连接设备前先拒绝符号链接和非普通文件。旧文件存在歧义时,应保留原件,由操作人员核对后把当前正确配置放到规范路径,再恢复备份或导出。
357
+ 每次连接有独立 session_id,每次发送命令分配 command_id;operation、phase、source、line 贯穿命令开始、回显、完成和错误。command_complete 在 info 级别记录 duration_ms、response_bytes,status: response_received 只表示响应已完成。operation_complete 在脚本准备、执行、回调及最终处理之后记录成功或失败,不代替文件持久性或 TFTP 服务端核验。
320
358
 
321
- Fleet 每次 `backup_all` 创建共享的 `SavedConfig(indexed: true)`,首次规范文件缺失时在互斥锁内构建旧命名索引,成功或失败均只扫描一次。文件按规范化地址分组,IPv6 zone 不参与下划线还原;匹配多个等价地址文件仍拒绝。索引只保存冻结的文件名及 dev/ino/mode/size/mtime/ctime,不缓存正文。读取前后检查同一 FD 的身份,并核对最终目录项;快照后改变或不可读时报 `SavedConfigChanged`,不使用失效基线连接设备。索引构建失败返回安全的 IOError,不发布部分表。
359
+ `device.log_event(name, level: :info, **fields)` 是唯一自定义事件入口。会话身份和命令上下文由引擎提供,不能从 fields 覆盖。事件名和字段值经终端渲染与脱敏后冻结;非法字段名丢弃,复杂对象和非有限浮点数隐藏,不调用任意对象的 inspect/to_s。敏感范围内自定义事件整体隐藏,包括事件名和任意字段,防止配置钩子输出未登记的秘密。
322
360
 
323
- 快照期间新增的旧名称文件留给下一批识别;每次查找仍先检查规范路径。直接创建的 `SavedConfig` 默认保持逐次实时查找。元数据检查不是文件系统事务,也不能在时间戳精度不足或非合作写入者持续修改时证明内容不可变;调用方仍须保护父目录及旧文件。索引不保留在 Fleet 实例或进程全局缓存中。
361
+ 注入的 logger 由调用方持有;连接器不修改级别、formatter、progname,也不关闭它。每次写入同时检查配置与 logger 当前级别。消息对象支持 to_s/inspect 和安全 to_h,应用可自行输出 JSON。自有文本日志使用毫秒时间及逐行事件;raw 文件只写经过敏感保护的字节。上下文退出前先收尾渲染与脱敏缓冲,避免上一命令尾部被标成下一条命令。
324
362
 
325
363
  ## 公开契约与 Rails 接入
326
364
 
327
- 应用代码优先使用 `Net::Connector.open/build`、`Base` 的公开设备方法、`Command`、`Script`、结果对象,以及 `Netdisco::Fleet`。厂商扩展使用 `Profile` DSL 和文档中列出的策略接口;`Profile::Builder`、`Session` 的状态字段、工作线程调度及解析辅助方法属于内部实现。兼容 require 路径只转发到当前实现,新增扩展使用厂商目录下的类。项目仍处于 0.x,公开契约的变更会在更新记录中说明。
365
+ 应用代码优先使用 `Net::Connector.open/build`、`Base` 的公开设备方法、`Command`、`Script`、结果对象,以及 `Netdisco::Fleet`。厂商扩展使用 `Profile` DSL 和文档中列出的策略接口;`Profile::Builder`、`Session` 的状态字段、工作线程调度及解析辅助方法属于内部实现。扩展直接加载当前设备模块或厂商目录下的类。项目仍处于 0.x,公开契约的变更会在更新记录中说明。
328
366
 
329
- gem 不依赖 Rails,也不自动创建数据库模型或管理应用的连接池。`logger: Rails.logger` 由应用持有,连接器只追加设备标记,不修改或关闭它。数据库报告通过 `ResultStore::Database` 注入仓储,写报告发生在调用线程中;逐设备凭据解析、连接器工厂和回调则运行在工作线程中。
367
+ gem 不依赖 Rails,也不自动创建数据库模型或管理应用的连接池。`logger: Rails.logger` 由应用持有,连接器写入安全事件对象,不修改或关闭它。数据库报告通过 `ResultStore::Database` 注入仓储,写报告发生在调用线程中;逐设备凭据解析、连接器工厂和回调则运行在工作线程中。
330
368
 
331
369
  在 Rails 内调用涉及模型、自动加载或应用状态的线程代码时,应由应用用 `Rails.application.executor.wrap` 包住相应调用。仅在外层包住 `fleet.backup_all` 不会覆盖子线程。例如,凭据解析器可以写为:
332
370
 
@@ -0,0 +1,16 @@
1
+ # 数据库连接只从 NETDISCO_DB_* 环境变量读取;此文件可直接编辑并纳入版本控制。
2
+ netdisco:
3
+ source: postgres
4
+ query: |
5
+ SELECT host(ip) AS ip, name, dns, vendor, os, model, os_ver, serial
6
+ FROM device
7
+ WHERE vendor = $1
8
+ ORDER BY ip
9
+ query_params: [H3C]
10
+ page_size: 500
11
+ max_devices: 100000
12
+ inventory_timeout: 300
13
+
14
+ backup:
15
+ directory: ./backups
16
+ concurrency: 4
@@ -5,14 +5,23 @@ require "forwardable"
5
5
  require_relative "../engine/core"
6
6
  require_relative "profile"
7
7
  require_relative "running_config"
8
- require_relative "interface_description"
9
- require_relative "../operations"
8
+ require_relative "save_config"
9
+ require_relative "local_backup"
10
+ require_relative "tftp"
11
+ require_relative "topology"
12
+ require_relative "../textfsm"
10
13
 
11
14
  module Net
12
15
  module Connector
13
16
  # 设备连接门面;厂商子类提供语法和钩子,组合对象负责实际执行。
14
17
  class Base
15
18
  extend Forwardable
19
+ include RunningConfig::Capability
20
+ include SaveConfig
21
+ include LocalBackup::Capability
22
+ include Tftp::Capability
23
+ include Topology::Capability
24
+ include TextFSM::Capability
16
25
 
17
26
  attr_reader :configuration, :command_timeout
18
27
 
@@ -121,7 +130,7 @@ module Net
121
130
  def close = @session.close
122
131
 
123
132
  # 将一条文本命令包装成脚本并执行。
124
- def execute(text, **, &)
133
+ def execute_command(text, **, &)
125
134
  execute_script(Script.new([Command.new(text, **)]), &)
126
135
  end
127
136
 
@@ -131,66 +140,9 @@ module Net
131
140
  perform_script(script, &)
132
141
  end
133
142
 
134
- # 使用简短名称执行脚本。
135
- alias run execute_script
136
-
137
- # 读取运行配置并返回清理后的配置结果。
138
- def running_config
139
- RunningConfig.new(self).call
140
- end
141
-
142
- # 执行命令并按厂商、命令或显式 TextFSM 模板返回结构化记录。
143
- def parse_command(command, template: nil, template_dir: nil)
144
- parser = Operations::ParseOutput.new(template_dir: template_dir)
145
- parser.call(execute(command).value!, template: template, vendor: vendor, command: command, host: host)
146
- end
147
-
148
- # 采集运行配置并使用指定 TextFSM 模板提取结构化记录。
149
- def parse_config(template:, template_dir: nil)
150
- parser = Operations::ParseOutput.new(template_dir: template_dir)
151
- parser.call(running_config.value!, template: template, host: host)
152
- end
153
-
154
- # 执行 CDP 或 LLDP 查询并返回统一的链路邻居记录。
155
- def neighbors = Operations::Topology.new(self).neighbors
156
-
157
- # 读取运行配置中的接口描述或端口名称。
158
- def interface_descriptions = Operations::Topology.new(self).descriptions
159
-
160
- # 以邻居和现有配置为证据,生成待确认的接口描述变更计划。
161
- def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
162
- Operations::Topology.new(self).plan_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
163
- end
164
-
165
- # 明确确认且现场证据未变化时执行接口描述计划。
166
- def apply_interface_descriptions(plan, confirmed: false)
167
- Operations::Topology.new(self).apply(plan, confirmed: confirmed)
168
- end
169
-
170
143
  # 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
171
144
  def with_operation(name, &block) = @session.with_operation(name, &block)
172
145
 
173
- # 采集配置并以原子方式保存为私有文件。
174
- # 采集失败时保留已有备份文件。
175
- def backup(path:, lock_timeout: 0)
176
- @session.assert_path_lock_order!(:backup)
177
- Operations::LocalBackup.new(self).call(path: path, lock_timeout: lock_timeout)
178
- end
179
-
180
- # 要求设备直接向 TFTP 服务器导出原生配置。
181
- # 完成仅表示设备报告传输成功,未读取服务器端文件。
182
- def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
183
- Operations::TftpBackup.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
184
- end
185
-
186
- # 明确请求来源、格式及设备报告等级;旧入口继续返回原有三字段对象。
187
- def tftp_backup_receipt(host:, path: nil, source_file: nil, vrf: nil)
188
- Operations::TftpBackup.new(self).call_receipt(host: host, path: path, source_file: source_file, vrf: vrf)
189
- end
190
-
191
- # 配置采集是设备的基础能力,两个公共入口共享同一流程。
192
- def collect_config = RunningConfig.new(self).call
193
-
194
146
  # 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
195
147
  def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
196
148
  perform_script(script, operation: name, prompt: prompt, after_command: after_command,
@@ -201,20 +153,10 @@ module Net
201
153
  def current_prompt = @session.prompt
202
154
 
203
155
  # 将业务事件写入当前设备会话日志。
204
- def record_event(name, **details)
156
+ def log_event(name, **details)
205
157
  @session.log_event(name, **details)
206
158
  end
207
159
 
208
- # 执行厂商保存配置命令;不支持时返回显式失败结果。
209
- def save_config
210
- if save_commands.empty?
211
- return Result.new(error: @session.error(UnsupportedOperation, "saving configuration is not supported",
212
- phase: :save))
213
- end
214
-
215
- execute_script(save_commands)
216
- end
217
-
218
160
  # 进入特权模式,并把当前提示符记录到会话。
219
161
  def enable
220
162
  @session.perform(:enable) { @session.enable(enable_command, enable_prompt) }
@@ -226,28 +168,11 @@ module Net
226
168
  @session.interact(input: input, output: output, escape: escape, timeout: timeout)
227
169
  end
228
170
 
229
- # 返回读取运行配置所需的设备命令;厂商必须实现。
230
- def config_commands
231
- commands = profile.config_commands
232
- return commands if commands
233
-
234
- raise NotImplementedError, "#{self.class} must define running configuration commands"
235
- end
236
-
237
- # 返回保存配置命令;空数组表示设备不支持保存。
238
- def save_commands = profile.save_commands
239
-
240
- # 清理运行配置文本;厂商可移除设备回显噪声。
241
- def clean_config(text) = config_strategy.clean(text)
242
-
243
171
  # 返回不包含凭据的连接状态摘要。
244
172
  def inspect = "#<#{self.class} host=#{host.inspect} state=#{state}>"
245
173
 
246
174
  protected
247
175
 
248
- # 默认取最后一个已完成步骤;厂商可选择配置所在的业务步骤。
249
- def config_result_step(result) = config_strategy.result_step(result)
250
-
251
176
  # 匹配设备分页提示,供对话层自动发送翻页响应。
252
177
  def pager_pattern = profile.pager_pattern
253
178
 
@@ -321,22 +246,6 @@ module Net
321
246
 
322
247
  private
323
248
 
324
- # 采集器仅在持有会话锁的结果处理阶段绑定策略,让旧方法钩子的 super
325
- # 复用响应校验状态。其他 Fiber 的离线清理仍使用自己的临时策略。
326
- def with_config_strategy(strategy)
327
- previous = @config_strategy_scope
328
- @config_strategy_scope = [Fiber.current, strategy]
329
- yield
330
- ensure
331
- @config_strategy_scope = previous
332
- end
333
-
334
- # 只允许当前 Fiber 复用采集中的策略;离线调用创建独立策略。
335
- def config_strategy
336
- scope = @config_strategy_scope
337
- scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
338
- end
339
-
340
249
  # 将厂商提示、失败模式和对话钩子组装成不可变对话语法。
341
250
  def build_dialogue
342
251
  Dialogue.new(
@@ -364,9 +273,20 @@ module Net
364
273
  execution.context[:privilege] = privilege
365
274
  output_sensitive = script.any?(&:output_sensitive?)
366
275
  @session.perform(:script) do
367
- @session.output_scope(output_sensitive) { before_batch(execution) }
368
- result = execution.execute(script, &on_step)
369
- @session.output_scope(output_sensitive) { finalize ? finalize.call(result) : result }
276
+ @session.log_script(operation: operation, steps: execution.steps) do
277
+ @session.with_sensitive_output(output_sensitive) { before_batch(execution) }
278
+ result = execution.execute_script(script, &on_step)
279
+ private_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
280
+ @session.with_sensitive_output(private_result) do
281
+ result = finalize ? finalize.call(result) : result
282
+ # 回调也可直接返回失败;与抛错共用脱敏边界,保留已完成步骤及业务配置。
283
+ if result.is_a?(Result) && result.failure?
284
+ result = Result.new(steps: result.steps, config: result.config,
285
+ error: @session.normalize_error(result.error, phase: :script))
286
+ end
287
+ result
288
+ end
289
+ end
370
290
  end
371
291
  rescue Error => error
372
292
  Result.new(steps: execution ? execution.steps : [], error: error)