ctx_runner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +22 -0
- data/README.md +60 -0
- data/docs/superpowers/plans/2026-10-21-runner.md +83 -0
- data/docs/verification-2026-10-22.md +35 -0
- data/examples/execution.rb +30 -0
- data/examples/report.rb +16 -0
- data/lib/ctx_runner/batch.rb +149 -0
- data/lib/ctx_runner/collection.rb +62 -0
- data/lib/ctx_runner/execution.rb +104 -0
- data/lib/ctx_runner/fingerprint.rb +36 -0
- data/lib/ctx_runner/observation.rb +38 -0
- data/lib/ctx_runner/query.rb +44 -0
- data/lib/ctx_runner/report.rb +220 -0
- data/lib/ctx_runner/version.rb +5 -0
- data/lib/ctx_runner.rb +10 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ff3a46f0ebe797a901ddcadbae2203d4074e45fa27642ae105c4756a1425a865
|
|
4
|
+
data.tar.gz: e06e7ce433130362c644f9d8c2efabcf317e688ba779a6efb647775a05a81a21
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 86a015cecb44ae07990cca0e129fb269bae3d6f8b11ff7d12ec2011b472e94829a15e9b16503a4b1afdead0ca0313380e3069097134015d261dde35a1afa305b
|
|
7
|
+
data.tar.gz: dd14c83315b9d4f919d6326c1fb82b2dfc39e4fac7189fae844f547b1cfaba4678f5070203cad2cc7a2db799e298129294ab37aedd6be619dac5b723e947a8d3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-22)
|
|
4
|
+
|
|
5
|
+
- Centralize batch completion, cancellation priority and presentation failures; close result registration before finalization and preserve immutable summaries.
|
|
6
|
+
|
|
7
|
+
- Preserve successful sibling views when another query projection fails.
|
|
8
|
+
|
|
9
|
+
- Centralize collection lifecycle, shared query execution and immutable observation contracts.
|
|
10
|
+
- Share batch accounting and durable evidence publication across domain consumers.
|
|
11
|
+
- Execute confirmed typed changes with immutable journal checkpoints, readback verification and no replay.
|
|
12
|
+
- Preserve JSON configuration fingerprints while rejecting normalized key collisions.
|
|
13
|
+
- Add offline examples, fault-contract tests, Ruby 4.0.6 engineering gates and isolated package verification.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Song Huang
|
|
4
|
+
Copyright (c) 2026 Gatework contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ctx_runner
|
|
2
|
+
|
|
3
|
+
网络证据采集、批次交付与受控变更的公共业务库。沿用 ctx_parser 的普通 Ruby 对象、显式参数、不可变快照及独立 gem 工程范式。
|
|
4
|
+
|
|
5
|
+
## 职责
|
|
6
|
+
|
|
7
|
+
| 对象 | 负责的业务 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `Collection` | 一次连接器交付、阶段诊断、禁止重复执行 |
|
|
10
|
+
| `Query` | 单命令读取解析、共享视图、失败与来源绑定 |
|
|
11
|
+
| `Observation` | 事实外壳、可用性、时间类型、深度快照及安全展示 |
|
|
12
|
+
| `Batch` | 单设备结果登记、报告交付、输出失败补写 |
|
|
13
|
+
| `Report` | 私有批次目录、文件发布、检查点、完成判定及恢复 |
|
|
14
|
+
| `Fingerprint` | 稳定配置身份,拒绝归一化键冲突 |
|
|
15
|
+
| `Execution` | 已确认增量的前提核验、执行记账、效果不确定状态及回读验收 |
|
|
16
|
+
|
|
17
|
+
ctx_parser 负责解析事实,ctx_driver 负责借用会话并交付命令 Capture,ctx_runner 负责组织业务过程。厂商语法、健康判定、AP 转接租约和变更规则留在领域模块。连接器拥有连接及关闭,Runner 不隐式连接、重连、提权或重放。
|
|
18
|
+
|
|
19
|
+
## 使用
|
|
20
|
+
|
|
21
|
+
Ruby >= 4.0.6。安装后 `require "ctx_runner"`,或按能力加载 `ctx_runner/report` 等文件。加载库不建立网络连接或创建目录。
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
run = CtxRunner::Collection.new
|
|
25
|
+
run.open(connector) do |session|
|
|
26
|
+
reader = CtxDriver::Reader.new(session: session)
|
|
27
|
+
query = CtxRunner::Query.new(reader: reader, parser: profile)
|
|
28
|
+
query.read("show example", names: [:example], unavailable: failure_projection) do |name, result, capture|
|
|
29
|
+
CtxRunner::Observation.new(name: name, **result.to_h,
|
|
30
|
+
evidence: capture.evidence, started_at: capture.started_at,
|
|
31
|
+
completed_at: capture.completed_at)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
# run.failure 是安全的 code/stage/error_type,不包含设备正文或异常消息。
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
领域 Profile 实现 `parse_many(names, body, complete:, command:)`;不可用投影接收 `(name, capture, failure)`。一次 Query 对应一条命令,多个视图共享同一份 Capture。
|
|
38
|
+
|
|
39
|
+
`Collection.new(strict: true)` 对连接器零次/重复回调抛出 ArgumentError,兼容严格宿主契约;默认记录 session_not_opened/session_reentered。连接关闭错误不会覆盖已完成的领域事实。借用会话直接使用 Reader/Query,无需再进入 Collection。
|
|
40
|
+
|
|
41
|
+
`Batch.new(report:, progress:, requested:)` 接收报告和进度协作者。`collect(device) { snapshot }` 返回不含原始配置的设备摘要;`run(present:) { ... }` 包住调度和领域后处理;块必须在返回或抛错前回收全部任务。Runner 统一取消、执行失败、报告及输出失败,返回不可变 Completion(status、summary、安全 failure、finalization_error、recovery_failed)。取消优先于收尾失败,展示失败只补写报告、不重采或重复展示。进入收尾即关闭结果登记,run 不可重放。CLI 将 complete/incomplete/failed/interrupted 映射到 0/1/2/130。`finish` 是内部交付步骤;调度和网络资源仍由宿主拥有。
|
|
42
|
+
|
|
43
|
+
`Report.new(root)` 创建排他的私有批次,目录 0700、文件 0600。`finish(results, requested:) { |summary| text }` 先保存未完成检查点,再发布领域文本,最后发布最终 JSON。`Report::Error#committed?` 区分是否已替换;失败不能被当作可靠发布。`recovery_failed?` 提示恢复也未确认。
|
|
44
|
+
|
|
45
|
+
`Execution` 只接受宿主已经校验、向用户展示且明确确认的计划。领域 change 实现 `commands`、`eligible?(before)`、`satisfied?(after, before:)`;journal 实现 `write_execution(state)`。每次回调获得快照。发送前保存 pending,发送后保留 acknowledged;失败后的 uncertain 禁止自动重放。`run { |operation| connector.open(&operation) }` 在连接器正常退出后才发布 configuration_verified;这不表示业务健康已验收。
|
|
46
|
+
|
|
47
|
+
完整可运行离线例子见 [examples](examples)。
|
|
48
|
+
|
|
49
|
+
## 开发与验证
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bundle install
|
|
53
|
+
bundle exec rake
|
|
54
|
+
bundle exec rake build
|
|
55
|
+
ruby script/verify_package.rb pkg/ctx_runner-0.1.0.gem
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
默认 rake 运行 RSpec、RuboCop 和离线示例。CI 沿用 ctx_parser 的 Ruby 4.0.6 / Linux、macOS 矩阵和固定 action 提交。源码联调在消费仓库显式设置 `CTX_RUNNER_PATH=../ctx_runner`;普通验证使用已安装依赖。首次发版前须先构建安装 runner 候选包再验证消费包。
|
|
59
|
+
|
|
60
|
+
首个版本为 0.1.0。发布流程复用 CI 制品,验证 GitHub Release 与 RubyGems 下载字节。
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ctx_runner Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** Use executing-plans to implement this plan task-by-task. Preserve unrelated work and do not commit or publish without authorization.
|
|
4
|
+
|
|
5
|
+
**Goal:** 集中公共采集、证据交付和受控变更业务,迁移真实消费方,沿用 ctx_parser 工程范式。
|
|
6
|
+
|
|
7
|
+
**Architecture:** CtxRunner 组合 CtxDriver 和 CtxParser;领域提供查询/事实投影与变更策略。连接器继续拥有会话和操作租约;Runner 不加载网络或数据库。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby >= 4.0.6、RSpec、RuboCop Omakase、Rake、独立 gem。
|
|
10
|
+
|
|
11
|
+
**Spec:** ../../../../module-architecture-review-2026-10-21.md 及用户已确认的 ctx_runner 分层方案。
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- 名称采用明确业务名词:Report、Collection、Batch、Execution、Fingerprint;不引入通用回调框架。
|
|
16
|
+
- 中文实现注释,英文 RSpec/Changelog;显式 require;不可变值使用 CtxParser::Immutable。
|
|
17
|
+
- 厂商解析器名称与布局保持;领域策略不迁入公共库。
|
|
18
|
+
- 保留失败证据、取消、来源字节摘要、禁止重放、私有报告和持久化确认契约。
|
|
19
|
+
- 当前授权为实现与本地验证,不包含提交、推送、发布。
|
|
20
|
+
|
|
21
|
+
## Review Focus
|
|
22
|
+
|
|
23
|
+
- 文件 rename 后 fsync 或取消失败不得留下可信的成功摘要。
|
|
24
|
+
- 注入回调修改输入不能改变已确认计划或已校验请求。
|
|
25
|
+
- 无会话/重复会话不能虚报完成或重放命令。
|
|
26
|
+
- 无线 AP 操作租约不能被统一连接包装破坏。
|
|
27
|
+
- 已安装依赖和候选源码/包分别验证,不能依赖相邻源码路径才能工作。
|
|
28
|
+
|
|
29
|
+
## Tasks
|
|
30
|
+
|
|
31
|
+
### Task 1: Evidence reports and gem foundation
|
|
32
|
+
|
|
33
|
+
Files: lib/ctx_runner/report.rb, spec/report_spec.rb, examples/report.rb, gemspec, Rakefile, AGENTS.md; VXLAN/Wireless cli/report.rb.
|
|
34
|
+
|
|
35
|
+
Interfaces: Report.new(root, error_class:) owns a private batch; publish(relative, body), prepare_directory(path), serializable(value), finish(results, requested:, ...) { |summary| text }, recovery_failed?, restore_checkpoint(body, relative:).
|
|
36
|
+
|
|
37
|
+
- [x] Write behavior specs for private evidence, incomplete checkpoint, post-rename failure and cancellation; run RED.
|
|
38
|
+
- [x] Implement report publication and completion protocol; run GREEN.
|
|
39
|
+
- [x] Replace domain publication/state machine with composition; retain rendering and error class names.
|
|
40
|
+
- [x] Run runner and both consumer report/review/CLI specs, lint and offline example.
|
|
41
|
+
|
|
42
|
+
### Task 2: Typed change execution
|
|
43
|
+
|
|
44
|
+
Files: lib/ctx_runner/fingerprint.rb, execution.rb, specs/examples; both change_plan.rb and cli/apply.rb.
|
|
45
|
+
|
|
46
|
+
Interfaces: Fingerprint.canonical(value)/call(value); Execution owns intent and command acknowledgements; domain change supplies commands, eligible?, satisfied?.
|
|
47
|
+
|
|
48
|
+
- [x] Pin cancellation, stale preconditions, mutation, log failure before/after send, duplicate session and readback failure before migration.
|
|
49
|
+
- [x] Implement and migrate both apply paths, retaining CLI confirmation and domain plan selection.
|
|
50
|
+
- [x] Verify both apply suites and fingerprint compatibility for existing legal JSON inputs.
|
|
51
|
+
|
|
52
|
+
### Task 3: Collection and evidence contracts
|
|
53
|
+
|
|
54
|
+
Files: lib/ctx_runner/collection.rb, query.rb, observation.rb, specs/examples; three collectors and domain adapters.
|
|
55
|
+
|
|
56
|
+
- [x] Pin unique session, independent failure, shared query, stopped session, close error and borrowed ownership.
|
|
57
|
+
- [x] Implement lifecycle and read/parse/evidence coordination; migrate all three collectors with domain strategies retained.
|
|
58
|
+
- [x] Preserve Checker applicability/discovery/scopes, VXLAN configuration ordering, Wireless AP scope and leases.
|
|
59
|
+
- [x] Unify observation envelope validation while retaining domain schemas and public representations.
|
|
60
|
+
|
|
61
|
+
### Task 4: Batch delivery
|
|
62
|
+
|
|
63
|
+
Files: lib/ctx_runner/batch.rb, specs/examples; both cli/collect.rb.
|
|
64
|
+
|
|
65
|
+
- [x] Pin result retention, output failures, cancellation and repeated invocation.
|
|
66
|
+
- [x] Centralize collection accounting and report delivery; migrate both CLIs without changing options/inventory policies.
|
|
67
|
+
- [x] Verify actual CLI entrypoints, full consumer suites and offline examples.
|
|
68
|
+
|
|
69
|
+
### Task 5: Engineering gates and final audit
|
|
70
|
+
|
|
71
|
+
- [x] Complete README, architecture, CHANGELOG, AGENTS, CI and isolated package verification matching ctx_parser conventions.
|
|
72
|
+
- [x] Full runner/default Rake plus affected consumer default tasks; current-source integration and isolated candidate installation.
|
|
73
|
+
- [x] Fresh review, resolve material findings, audit requirements and remaining duplicated business state machines.
|
|
74
|
+
|
|
75
|
+
## Progress / rulings
|
|
76
|
+
|
|
77
|
+
- Initial: ctx_runner absent; seven module source state rechecked. Net-connector unrelated untracked documents preserved.
|
|
78
|
+
- Ruling: user explicitly requested implementation after reviewing architecture; proceed without another design approval round.
|
|
79
|
+
- Ruling: retain domain publication error types through explicit error-class injection; shared Report owns protocol, consumer Report owns presentation.
|
|
80
|
+
|
|
81
|
+
- Final: seven named capabilities implemented; ctx_checker, vxlan and wireless consume the shared workflows. Domain parsers, policy and connection ownership remain with their existing owners.
|
|
82
|
+
- Evidence: see `../../verification-2026-10-22.md` for contract coverage, source/installed/package gates and validation limits.
|
|
83
|
+
- Review: mutable summary, retained callbacks, cancellation after replacement, initial checkpoint cancellation and rejected-path cleanup findings resolved with regression coverage.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ctx_runner 本地验收(2026-10-22)
|
|
2
|
+
|
|
3
|
+
## 目标与实现
|
|
4
|
+
|
|
5
|
+
将重复公共业务流程收敛到独立 gem,沿用 ctx_parser 工程规范。命名使用 Collection、Query、Observation、Batch、Report、Fingerprint、Execution;普通对象组合、显式 require、两空格缩进、中文实现注释、英文 RSpec,复用 CtxParser::Immutable。未引入通用工作流 DSL。
|
|
6
|
+
|
|
7
|
+
| 范围 | 实现及验收依据 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| 私有证据与完成协议 | Report;report_spec 覆盖目录权限、未完成检查点、不可变摘要、取消及拒绝路径;消费方 report/review specs 验证发布失败和恢复 |
|
|
10
|
+
| 受控变更 | Execution/Fingerprint;执行 specs 验证先记账后发送、重复调用、回调失效、发送后日志失败与中断;两方 apply specs 验证漂移、确认、回读失败和领域增量 |
|
|
11
|
+
| 采集与来源 | Collection/Query/Observation;唯一会话、关闭失败、共享命令、错误投影与独立快照;三个 collector suites 保留借用会话、覆盖范围和无线 AP 租约 |
|
|
12
|
+
| 批量交付 | Batch;保存失败仍保留结果、设备异常隔离、输出失败补写而不重放;消费 CLI suites 验证取消和调度器收尾 |
|
|
13
|
+
| 领域边界 | ctx_checker 保留 schema/评估;VXLAN 保留配置顺序和未配置判定;Wireless 保留 AP 作用域与转接;厂商解析器名称不变 |
|
|
14
|
+
| 工程 | Rake 默认 RSpec/RuboCop/离线 examples;gemspec 明确包清单;固定 action SHA 的 Linux/macOS CI;隔离安装脚本;README、CHANGELOG、AGENTS |
|
|
15
|
+
|
|
16
|
+
## 验证记录
|
|
17
|
+
|
|
18
|
+
Ruby 4.0.6,Bundler 4.0.20。所有命令在各自仓库通过 zsh -ic 执行。
|
|
19
|
+
|
|
20
|
+
| 仓库 | RSpec | RuboCop | 离线示例与构建 |
|
|
21
|
+
| --- | ---: | ---: | --- |
|
|
22
|
+
| ctx_runner | 30 / 0 failures | 23 files / 0 offenses | 通过 |
|
|
23
|
+
| ctx_checker | 700 / 0 failures | 192 files / 0 offenses | 通过 |
|
|
24
|
+
| vxlan | 453 / 0 failures | 106 files / 0 offenses | 通过 |
|
|
25
|
+
| wireless | 316 / 0 failures | 91 files / 0 offenses | 通过 |
|
|
26
|
+
|
|
27
|
+
消费模块分别运行了 `CTX_RUNNER_PATH=../ctx_runner bundle exec rake` 源码联调,以及安装最新 Runner 候选后、不设置该变量的 `bundle install --local`、`bundle exec rake` 和 `bundle exec rake build`。普通入口的完整测试合计 1499 examples,0 failures。源码联调之后 Report 的路径恢复保护又补充了回归;最终普通安装测试使用包含该修复的候选。
|
|
28
|
+
|
|
29
|
+
Runner 的 `ruby script/verify_package.rb pkg/ctx_runner-0.1.0.gem` 在空 GEM_HOME 安装并运行包内示例。消费模块隔离验证显式传入 Runner 0.1.0、net-connector 0.16.1、expect-pty 0.9.5 候选,精确激活后运行包内示例与 CLI 验证。VXLAN/Wireless 参数顺序为目标、connector、expect、runner;Checker 为目标后跟依赖包(runner、expect、connector)。
|
|
30
|
+
|
|
31
|
+
独立复核的 Report/Execution/Collection 回归 20 examples、0 failures;已解决摘要所有权、失效回调、替换后取消、初始检查点取消和拒绝路径清理问题。
|
|
32
|
+
|
|
33
|
+
## 证据边界
|
|
34
|
+
|
|
35
|
+
本地合成数据及隔离安装证明库契约和普通入口可运行,不表示真实设备认证、远程 CI 已执行或版本已发布。本次不提交、不推送、不发布,也不改变仓库可见性。ctx_parser、ctx_driver、expect-ruby 无变更,net-connector-trunk 原有两份未跟踪设计文档保留。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_runner"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
# 合成领域动作,只演示前提、执行记账与回读验收;没有设备连接。
|
|
7
|
+
class ExampleChange
|
|
8
|
+
def commands = [ "set mode enabled" ].freeze
|
|
9
|
+
def eligible?(config) = config["mode"] == "disabled"
|
|
10
|
+
def satisfied?(config, before:) = before["mode"] == "disabled" && config["mode"] == "enabled"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ExampleSession
|
|
14
|
+
def initialize = @mode = "disabled"
|
|
15
|
+
def read_configuration = { "mode" => @mode }
|
|
16
|
+
def write(command)
|
|
17
|
+
raise "Unexpected command" unless command == "set mode enabled"
|
|
18
|
+
@mode = "enabled"
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Dir.mktmpdir do |root|
|
|
24
|
+
report = CtxRunner::Report.new(File.join(root, "execution"))
|
|
25
|
+
plan = { "device" => { "host" => "192.0.2.1" }, "expected_config_sha256" => CtxRunner::Fingerprint.call("mode" => "disabled") }
|
|
26
|
+
execution = CtxRunner::Execution.new(plan: plan, change: ExampleChange.new, journal: report, plan_sha256: "a" * 64)
|
|
27
|
+
result = execution.run { |operation| operation.call(ExampleSession.new) }
|
|
28
|
+
raise "Unverified configuration" unless result["status"] == "configuration_verified"
|
|
29
|
+
raise "Lost command evidence" unless result["steps"].one?
|
|
30
|
+
end
|
data/examples/report.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_runner"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
# 离线证据发布;临时目录由示例拥有,不接触设备或宿主配置。
|
|
7
|
+
Dir.mktmpdir do |root|
|
|
8
|
+
report = CtxRunner::Report.new(File.join(root, "reports"))
|
|
9
|
+
report.publish("evidence.json", JSON.generate(source: "synthetic", records: []))
|
|
10
|
+
device = { "host" => "192.0.2.1", "name" => "example" }
|
|
11
|
+
summary = report.finish([ { device: device, status: :complete, report_saved: true } ], requested: [ device ]) do |value|
|
|
12
|
+
"Evidence: #{value.fetch(:status)}; health was not evaluated.\n"
|
|
13
|
+
end
|
|
14
|
+
raise "Report incomplete" unless summary[:status] == :complete
|
|
15
|
+
raise "Missing evidence" unless File.file?(File.join(report.directory, "evidence.json"))
|
|
16
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/immutable"
|
|
4
|
+
require "ctx_driver/failure"
|
|
5
|
+
|
|
6
|
+
module CtxRunner
|
|
7
|
+
# 批量采集的交付账本;调度器负责并发与回收,领域块只返回设备快照。
|
|
8
|
+
class Batch
|
|
9
|
+
Completion = Data.define(:status, :summary, :failure, :finalization_error, :recovery_failed) do
|
|
10
|
+
def initialize(**attributes)
|
|
11
|
+
super(**CtxParser::Immutable.copy(attributes))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inspect = "#<#{self.class.name} status=#{status}>"
|
|
15
|
+
alias to_s inspect
|
|
16
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# 块拥有调度及回收;返回后统一交付,取消优先于收尾失败。
|
|
20
|
+
def run(present: nil)
|
|
21
|
+
raise ArgumentError, "Batch cannot be replayed" if @running
|
|
22
|
+
raise ArgumentError, "Batch requires an operation" unless block_given?
|
|
23
|
+
@running = entered = true
|
|
24
|
+
options = { interrupted: false, report_error: nil, execution_error: nil }
|
|
25
|
+
failure = nil
|
|
26
|
+
begin
|
|
27
|
+
yield
|
|
28
|
+
rescue Interrupt
|
|
29
|
+
options[:interrupted] = true
|
|
30
|
+
rescue StandardError, LoadError => error
|
|
31
|
+
failure = classify(error)
|
|
32
|
+
options[failure[:kind] == :report ? :report_error : :execution_error] = failure[:detail]
|
|
33
|
+
end
|
|
34
|
+
@closed = true
|
|
35
|
+
settling = true
|
|
36
|
+
summary, finalization_error = settle(options)
|
|
37
|
+
if summary && present
|
|
38
|
+
presenting = true
|
|
39
|
+
begin
|
|
40
|
+
present.call(summary)
|
|
41
|
+
rescue Interrupt
|
|
42
|
+
options[:interrupted] = true
|
|
43
|
+
summary, finalization_error = settle(options)
|
|
44
|
+
rescue StandardError, LoadError => error
|
|
45
|
+
failure ||= classify(error)
|
|
46
|
+
options[:report_error] = diagnostic(error)
|
|
47
|
+
summary, finalization_error = settle(options)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
presenting = false
|
|
51
|
+
status = if options[:interrupted]
|
|
52
|
+
:interrupted
|
|
53
|
+
elsif failure || finalization_error
|
|
54
|
+
:failed
|
|
55
|
+
else
|
|
56
|
+
summary[:status] == :complete ? :complete : :incomplete
|
|
57
|
+
end
|
|
58
|
+
completion = Completion.new(status: status, summary: summary, failure: failure, finalization_error: finalization_error,
|
|
59
|
+
recovery_failed: @report.respond_to?(:recovery_failed?) && @report.recovery_failed?)
|
|
60
|
+
completed = true
|
|
61
|
+
completion
|
|
62
|
+
ensure
|
|
63
|
+
@closed = true if entered
|
|
64
|
+
# throw/非局部 return 也要收尾,但绝不覆盖原退出或再次展示。
|
|
65
|
+
if entered && !completed && (!settling || presenting)
|
|
66
|
+
begin
|
|
67
|
+
finish(interrupted: true)
|
|
68
|
+
rescue StandardError, LoadError, Interrupt
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(report:, progress:, requested:)
|
|
75
|
+
@requested = CtxParser::Immutable.copy(requested)
|
|
76
|
+
@report, @progress = report, progress
|
|
77
|
+
@results, @lock = [], Mutex.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 先登记设备事实,再保存报告;报告失败仍可在最终摘要中定位未交付结果。
|
|
81
|
+
def collect(device)
|
|
82
|
+
raise ArgumentError, "Batch is closed" if @closed
|
|
83
|
+
device = CtxParser::Immutable.copy(device)
|
|
84
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
@progress.start(device)
|
|
86
|
+
snapshot = nil
|
|
87
|
+
begin
|
|
88
|
+
snapshot = yield
|
|
89
|
+
dimensions = snapshot.dimensions.transform_values do |value|
|
|
90
|
+
{ status: value.status, records: value.records.size, issues: value.issues.map { |issue| issue[:code] }.uniq }
|
|
91
|
+
end
|
|
92
|
+
result = { device: device, status: snapshot.dimensions.fetch(:collection).status, dimensions: dimensions }
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
result = { device: device, status: :failed, error: CtxDriver::Failure.code(error, :collection_failed), dimensions: {} }
|
|
95
|
+
end
|
|
96
|
+
result[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
97
|
+
result[:report_saved] = false
|
|
98
|
+
@lock.synchronize { @results << result }
|
|
99
|
+
@report.write(CtxParser::Immutable.copy(result), snapshot: snapshot)
|
|
100
|
+
@lock.synchronize { result[:report_saved] = true }
|
|
101
|
+
saved = CtxParser::Immutable.copy(result)
|
|
102
|
+
@progress.result(saved)
|
|
103
|
+
saved
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def results
|
|
107
|
+
@lock.synchronize { CtxParser::Immutable.copy(@results).sort_by { |result| result[:device].fetch("host") }.freeze }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 调度器回收后调用;输出诊断只补写报告,绝不重新执行设备操作。
|
|
111
|
+
def finish(interrupted: false, report_error: nil, execution_error: nil)
|
|
112
|
+
snapshot = results
|
|
113
|
+
options = { interrupted: interrupted, requested: @requested, report_error: report_error, execution_error: execution_error }
|
|
114
|
+
summary = @report.finish(snapshot, **options, output_error: @progress.output_error)
|
|
115
|
+
@progress.finish(summary)
|
|
116
|
+
if @progress.output_error && !summary[:output_error]
|
|
117
|
+
summary = @report.finish(snapshot, **options, output_error: @progress.output_error)
|
|
118
|
+
end
|
|
119
|
+
summary
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inspect = "#<#{self.class.name}>"
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def settle(options)
|
|
127
|
+
[ CtxParser::Immutable.copy(finish(**options)), nil ]
|
|
128
|
+
rescue Interrupt
|
|
129
|
+
options[:interrupted] = true
|
|
130
|
+
begin
|
|
131
|
+
[ CtxParser::Immutable.copy(finish(**options)), nil ]
|
|
132
|
+
rescue StandardError, LoadError, Interrupt => error
|
|
133
|
+
[ nil, diagnostic(error) ]
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError, LoadError => error
|
|
136
|
+
[ nil, diagnostic(error) ]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def classify(error)
|
|
140
|
+
kind = error.is_a?(SystemCallError) || error.is_a?(IOError) ? :report : :execution
|
|
141
|
+
{ kind: kind, error_type: error.class.name, detail: kind == :report ? diagnostic(error) : error.class.name }.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def diagnostic(error)
|
|
145
|
+
type = @report.class.const_get(:Error) if @report.class.const_defined?(:Error)
|
|
146
|
+
type && error.instance_of?(type) ? error.message : error.class.name
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_driver/failure"
|
|
4
|
+
|
|
5
|
+
module CtxRunner
|
|
6
|
+
# 约束一次连接器交付,不接管连接或操作租约;已取得的事实由领域块保留。
|
|
7
|
+
class Collection
|
|
8
|
+
attr_reader :failure, :stage
|
|
9
|
+
|
|
10
|
+
def initialize(strict: false)
|
|
11
|
+
raise ArgumentError, "Strict session contract must be boolean" unless [ true, false ].include?(strict)
|
|
12
|
+
@strict, @used = strict, false
|
|
13
|
+
@stage = :connect
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 准备阶段由领域计划显式声明,关闭阶段仅在采集块正常完成后进入。
|
|
17
|
+
def stage=(value)
|
|
18
|
+
raise ArgumentError, "Invalid collection stage" unless %i[connect prepare collect close].include?(value)
|
|
19
|
+
@stage = value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def open(connector)
|
|
23
|
+
raise ArgumentError, "Collection cannot be replayed" if @used
|
|
24
|
+
raise ArgumentError, "Collection requires an operation" unless block_given?
|
|
25
|
+
@used = true
|
|
26
|
+
acquire(connector) { |session| yield session }
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inspect = "#<#{self.class.name} stage=#{stage}>"
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def acquire(connector)
|
|
35
|
+
yielded = false
|
|
36
|
+
active = true
|
|
37
|
+
connector.open do |session|
|
|
38
|
+
raise IOError, "Session provider scope has ended" unless active
|
|
39
|
+
if yielded
|
|
40
|
+
contract_failure(:session_reentered)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
yielded = true
|
|
44
|
+
@stage = :collect
|
|
45
|
+
yield session
|
|
46
|
+
@stage = :close
|
|
47
|
+
end
|
|
48
|
+
contract_failure(:session_not_opened) unless yielded
|
|
49
|
+
rescue StandardError => error
|
|
50
|
+
raise unless CtxDriver::Failure.connection_error?(error, connector)
|
|
51
|
+
details = CtxDriver::Failure.details(error, fallback: :connection_failed, stage: stage)
|
|
52
|
+
@failure = (stage == :close ? details.merge(code: :session_close_failed) : details).freeze
|
|
53
|
+
ensure
|
|
54
|
+
active = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def contract_failure(code)
|
|
58
|
+
raise ArgumentError, "Connector must provide one session" if @strict
|
|
59
|
+
@failure ||= { code: code, stage: stage }.freeze
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/immutable"
|
|
4
|
+
require "ctx_driver/failure"
|
|
5
|
+
require_relative "fingerprint"
|
|
6
|
+
|
|
7
|
+
module CtxRunner
|
|
8
|
+
# 只执行已确认的单项领域变更;日志、设备效果与配置验收分别记账。
|
|
9
|
+
class Execution
|
|
10
|
+
# 领域动作负责有限命令及前后置条件;日志接收独立的不可变检查点。
|
|
11
|
+
def initialize(plan:, change:, journal:, plan_sha256:, item: nil)
|
|
12
|
+
@plan = CtxParser::Immutable.copy(plan)
|
|
13
|
+
unless @plan.is_a?(Hash) && @plan["device"].is_a?(Hash) &&
|
|
14
|
+
[ @plan["expected_config_sha256"], plan_sha256 ].all? { |value| value.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value) }
|
|
15
|
+
raise ArgumentError, "Execution requires a verified plan and fingerprints"
|
|
16
|
+
end
|
|
17
|
+
unless %i[commands eligible? satisfied?].all? { |method| change.respond_to?(method) } && journal.respond_to?(:write_execution)
|
|
18
|
+
raise ArgumentError, "Execution requires a typed change and journal"
|
|
19
|
+
end
|
|
20
|
+
@commands = CtxParser::Immutable.copy(change.commands)
|
|
21
|
+
unless @commands.is_a?(Array) && @commands.any? && @commands.all? { |command| command.is_a?(String) && !command.strip.empty? && !/[\x00-\x1f\x7f]/.match?(command) }
|
|
22
|
+
raise ArgumentError, "Change commands must be individual lines"
|
|
23
|
+
end
|
|
24
|
+
@change, @journal = change, journal
|
|
25
|
+
@state = { "status" => "confirmed", "device" => @plan["device"], "plan_sha256" => plan_sha256.dup.freeze,
|
|
26
|
+
"expected_config_sha256" => @plan["expected_config_sha256"], "commands" => @commands, "steps" => [] }
|
|
27
|
+
@state["plan_item"] = CtxParser::Immutable.copy(item) if item
|
|
28
|
+
@started = @used = @journal_failed = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 由连接器提供唯一会话并清理;关闭失败同样不能发布配置已验证状态。
|
|
32
|
+
def run
|
|
33
|
+
raise ArgumentError, "Execution cannot be replayed" if @used
|
|
34
|
+
raise ArgumentError, "Execution requires a session provider" unless block_given?
|
|
35
|
+
@used = true
|
|
36
|
+
perform { |operation| yield operation }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def started? = @started
|
|
40
|
+
def journal_failed? = @journal_failed
|
|
41
|
+
def state = CtxParser::Immutable.copy(@state)
|
|
42
|
+
def inspect = "#<#{self.class.name} status=#{@state.fetch('status')}>"
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# 保存意图先于连接;回调次数和完成状态不依赖连接器返回值。
|
|
47
|
+
def perform
|
|
48
|
+
opened = verified = finished = recorded = false
|
|
49
|
+
active = true
|
|
50
|
+
save
|
|
51
|
+
yield lambda { |session|
|
|
52
|
+
raise IOError, "Session provider scope has ended" unless active
|
|
53
|
+
raise IOError, "Session callback repeated" if opened
|
|
54
|
+
opened = true
|
|
55
|
+
before = CtxParser::Immutable.copy(session.read_configuration)
|
|
56
|
+
unless Fingerprint.call(before) == @plan["expected_config_sha256"] && @change.eligible?(before)
|
|
57
|
+
raise ArgumentError, "Configuration changed or preconditions failed"
|
|
58
|
+
end
|
|
59
|
+
@commands.each_with_index { |command, index| execute(session, command, index) }
|
|
60
|
+
after = CtxParser::Immutable.copy(session.read_configuration)
|
|
61
|
+
raise IOError, "Configuration verification failed" unless @change.satisfied?(after, before: before)
|
|
62
|
+
@state["observed_config_sha256"] = Fingerprint.call(after)
|
|
63
|
+
verified = true
|
|
64
|
+
}
|
|
65
|
+
raise IOError, "Session did not verify the configuration" unless verified
|
|
66
|
+
@state["status"] = "configuration_verified"
|
|
67
|
+
save
|
|
68
|
+
finished = true
|
|
69
|
+
state
|
|
70
|
+
rescue StandardError, Interrupt => error
|
|
71
|
+
recorded = true
|
|
72
|
+
type = error.is_a?(Interrupt) ? "interrupted" : CtxDriver::Failure.details(error, fallback: :execution_failed, stage: :execute).fetch(:error_type)
|
|
73
|
+
record_failure(type)
|
|
74
|
+
raise
|
|
75
|
+
ensure
|
|
76
|
+
active = false
|
|
77
|
+
# 非局部退出与线程取消也不能留下可重放的成功意图。
|
|
78
|
+
record_failure("aborted") unless finished || recorded
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 发送前记录未决命令;已执行事实在发送后的日志失败时继续保留。
|
|
82
|
+
def execute(session, command, index)
|
|
83
|
+
@state["status"] = "executing"
|
|
84
|
+
@state["pending_command"] = index
|
|
85
|
+
save
|
|
86
|
+
@started = true
|
|
87
|
+
raise IOError, "Command was not acknowledged" unless session.write(command) == true
|
|
88
|
+
@state["steps"] << { "index" => index, "status" => "acknowledged" }
|
|
89
|
+
@state.delete("pending_command")
|
|
90
|
+
save
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def save = @journal.write_execution(state)
|
|
94
|
+
|
|
95
|
+
# 次级日志故障不覆盖主异常,也不允许继续发送命令。
|
|
96
|
+
def record_failure(code)
|
|
97
|
+
@state["status"] = started? ? "uncertain" : "failed"
|
|
98
|
+
@state["error"] = code
|
|
99
|
+
save
|
|
100
|
+
rescue StandardError, Interrupt
|
|
101
|
+
@journal_failed = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "ctx_parser/immutable"
|
|
6
|
+
|
|
7
|
+
module CtxRunner
|
|
8
|
+
# 配置映射顺序不影响身份;列表顺序和全部字段仍参与旧版摘要协议。
|
|
9
|
+
module Fingerprint
|
|
10
|
+
def self.call(value) = Digest::SHA256.hexdigest(JSON.generate(canonical(value)))
|
|
11
|
+
|
|
12
|
+
# 先拒绝非纯数据与循环,再将键归一化;重名键不能静默覆盖来源事实。
|
|
13
|
+
def self.canonical(value)
|
|
14
|
+
normalize(CtxParser::Immutable.copy(value), 0)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.normalize(value, depth)
|
|
18
|
+
raise ArgumentError, "Configuration nesting exceeds the fingerprint budget" if depth > 128
|
|
19
|
+
|
|
20
|
+
case value
|
|
21
|
+
when Hash
|
|
22
|
+
normalized = {}
|
|
23
|
+
value.each do |key, entry|
|
|
24
|
+
raise ArgumentError, "Configuration keys must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
25
|
+
name = key.to_s
|
|
26
|
+
raise ArgumentError, "Configuration keys collide after normalization" if normalized.key?(name)
|
|
27
|
+
normalized[name] = normalize(entry, depth + 1)
|
|
28
|
+
end
|
|
29
|
+
normalized.sort.to_h
|
|
30
|
+
when Array then value.map { |entry| normalize(entry, depth + 1) }
|
|
31
|
+
else value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
private_class_method :normalize
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/immutable"
|
|
4
|
+
|
|
5
|
+
module CtxRunner
|
|
6
|
+
# 不可变的事实外壳;领域可继承外壳或复用校验,记录身份和业务字段仍由领域负责。
|
|
7
|
+
class Observation < Data.define(:name, :status, :records, :issues, :evidence, :started_at, :completed_at)
|
|
8
|
+
STATUSES = %i[complete partial failed unsupported not_requested].freeze
|
|
9
|
+
|
|
10
|
+
def self.validate(name:, status:, records:, issues:, evidence:, statuses: STATUSES)
|
|
11
|
+
raise ArgumentError, "Observation name must be a symbol" unless name.is_a?(Symbol)
|
|
12
|
+
raise ArgumentError, "Invalid observation status" unless statuses.include?(status)
|
|
13
|
+
unless [ records, issues, evidence ].all? { |entries| entries.is_a?(Array) && entries.all? { |entry| entry.is_a?(Hash) } }
|
|
14
|
+
raise ArgumentError, "Observation entries must be arrays of hashes"
|
|
15
|
+
end
|
|
16
|
+
if %i[failed unsupported not_requested not_applicable].include?(status) && records.any?
|
|
17
|
+
raise ArgumentError, "Unavailable observations cannot contain records"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(name:, status:, records: [], issues: [], evidence: [], started_at: nil, completed_at: nil)
|
|
22
|
+
self.class.validate(name: name, status: status, records: records, issues: issues, evidence: evidence)
|
|
23
|
+
unless [ started_at, completed_at ].all? { |time| time.nil? || time.is_a?(Time) }
|
|
24
|
+
raise ArgumentError, "Observation timestamps must be Time values or nil"
|
|
25
|
+
end
|
|
26
|
+
super(name: name, status: status, records: CtxParser::Immutable.copy(records),
|
|
27
|
+
issues: CtxParser::Immutable.copy(issues), evidence: CtxParser::Immutable.copy(evidence),
|
|
28
|
+
started_at: started_at&.dup&.freeze, completed_at: completed_at&.dup&.freeze)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def complete? = status == :complete
|
|
32
|
+
def partial? = status == :partial
|
|
33
|
+
def available? = complete? || partial?
|
|
34
|
+
def inspect = "#<#{self.class.name} name=#{name} status=#{status} records=#{records.size}>"
|
|
35
|
+
alias to_s inspect
|
|
36
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/immutable"
|
|
4
|
+
require "ctx_driver/failure"
|
|
5
|
+
|
|
6
|
+
module CtxRunner
|
|
7
|
+
# 一条查询只读取解析一次;共享视图交付相同来源,失败不影响其他查询。
|
|
8
|
+
class Query
|
|
9
|
+
def initialize(reader:, parser:)
|
|
10
|
+
@reader, @parser = reader, parser
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# 不可用投影负责领域诊断结构;成功投影负责将事实与来源绑定。
|
|
14
|
+
def read(command, names:, unavailable:)
|
|
15
|
+
unless names.is_a?(Array) && names.any? && names.uniq == names && names.all? { |name| name.is_a?(Symbol) }
|
|
16
|
+
raise ArgumentError, "Query views must be unique symbols"
|
|
17
|
+
end
|
|
18
|
+
names = CtxParser::Immutable.copy(names)
|
|
19
|
+
command = CtxParser::Immutable.copy(command)
|
|
20
|
+
capture = @reader.read(command)
|
|
21
|
+
failure = capture.failure
|
|
22
|
+
if capture.success?
|
|
23
|
+
begin
|
|
24
|
+
parsed = @parser.parse_many(names, capture.body, complete: true, command: command)
|
|
25
|
+
rescue StandardError => error
|
|
26
|
+
failure = CtxDriver::Failure.details(error, fallback: :command_processing_failed, stage: :parse)
|
|
27
|
+
else
|
|
28
|
+
return names.to_h do |name|
|
|
29
|
+
value = begin
|
|
30
|
+
yield(name, parsed.fetch(name), capture)
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
details = CtxDriver::Failure.details(error, fallback: :command_processing_failed, stage: :parse)
|
|
33
|
+
unavailable.call(name, capture, details)
|
|
34
|
+
end
|
|
35
|
+
[ name, value ]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
names.to_h { |name| [ name, unavailable.call(name, capture, failure) ] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect = "#<#{self.class.name}>"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "ctx_parser/immutable"
|
|
8
|
+
|
|
9
|
+
module CtxRunner
|
|
10
|
+
# 持有私有证据批次,统一检查点、发布和失败恢复;领域只提供展示内容。
|
|
11
|
+
class Report
|
|
12
|
+
attr_reader :directory
|
|
13
|
+
|
|
14
|
+
# 仅交付发布阶段,不保留底层异常正文或敏感报告内容。
|
|
15
|
+
class Error < IOError
|
|
16
|
+
attr_reader :relative, :phase
|
|
17
|
+
|
|
18
|
+
# 保留安全的发布阶段与提交状态,不携带底层异常正文。
|
|
19
|
+
def initialize(relative:, phase:, committed:)
|
|
20
|
+
@relative, @phase, @committed = relative.dup.freeze, phase, committed
|
|
21
|
+
super("报告写入失败:#{relative};阶段 #{phase};#{committed ? '已替换,收尾未确认' : '尚未替换'}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 区分原子替换已完成与目录同步尚未确认。
|
|
25
|
+
def committed? = @committed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 批次目录排他创建,重复时间戳不会覆盖上一批次。
|
|
29
|
+
def initialize(root, error_class: Error)
|
|
30
|
+
raise ArgumentError, "Report error must inherit from Report::Error" unless error_class.is_a?(Class) && error_class <= Error
|
|
31
|
+
@error_class = error_class
|
|
32
|
+
root = File.expand_path(root)
|
|
33
|
+
prepare_directory(root)
|
|
34
|
+
stat = File.lstat(root)
|
|
35
|
+
unless stat.directory? && (stat.mode & 0o077).zero?
|
|
36
|
+
raise ArgumentError, "报告根目录须为真实私有目录,权限0700;请设置独立目录并检查 chmod 0700"
|
|
37
|
+
end
|
|
38
|
+
stem = Time.now.utc.strftime("%Y-%m-%d_%H-%M-%S")
|
|
39
|
+
sequence = 0
|
|
40
|
+
begin
|
|
41
|
+
@directory = File.join(root, sequence.zero? ? stem : "#{stem}_#{sequence}").freeze
|
|
42
|
+
Dir.mkdir(directory, 0o700)
|
|
43
|
+
rescue Errno::EEXIST
|
|
44
|
+
sequence += 1
|
|
45
|
+
retry
|
|
46
|
+
end
|
|
47
|
+
sync_directory(root)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 摘要不嵌入配置事实,只汇总状态与诊断,供终端和自动化共用。
|
|
51
|
+
def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil, review: nil)
|
|
52
|
+
raise ArgumentError, "Report requires a presentation block" unless block_given?
|
|
53
|
+
@recovery_failed = false
|
|
54
|
+
# 按地址索引一次,避免大批次逐个扫描所有已完成结果。
|
|
55
|
+
completed_hosts = results.to_h { |result| [ result.fetch(:device).fetch("host"), true ] }
|
|
56
|
+
pending = requested.reject { |device| completed_hosts.key?(device.fetch("host")) }
|
|
57
|
+
complete = !report_error && !execution_error && !output_error && pending.empty? && results.any? && results.all? { |r| r[:status] == :complete && r[:report_saved] != false }
|
|
58
|
+
summary = { status: interrupted ? :interrupted : (complete ? :complete : :incomplete),
|
|
59
|
+
total: [ requested.size, results.size ].max, completed: results.size, pending: pending, counts: results.map { |r| r[:status] }.tally, devices: results,
|
|
60
|
+
directory: directory, evidence_only: true }
|
|
61
|
+
summary[:review] = review if review
|
|
62
|
+
summary[:report_error] = report_error if report_error
|
|
63
|
+
summary[:execution_error] = execution_error if execution_error
|
|
64
|
+
summary[:output_error] = output_error if output_error
|
|
65
|
+
summary = CtxParser::Immutable.copy(serializable(summary))
|
|
66
|
+
# 先保存未完成检查点;文本发布或最终提交失败都不能留下成功摘要。
|
|
67
|
+
checkpoint = summary.merge(status: interrupted ? :interrupted : :incomplete,
|
|
68
|
+
report_error: report_error || :report_finalization_unconfirmed)
|
|
69
|
+
checkpoint("summary.json", checkpoint)
|
|
70
|
+
publish("summary.txt", yield(summary))
|
|
71
|
+
finalize("summary.json", summary, checkpoint: checkpoint)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 新一轮发布必须先撤销旧完成结论;非局部退出也不能绕过这条边界。
|
|
75
|
+
def checkpoint(relative, value)
|
|
76
|
+
relative = checked_relative(relative)
|
|
77
|
+
validated = true
|
|
78
|
+
published = false
|
|
79
|
+
publish(relative, JSON.pretty_generate(serializable(value)) + "\n")
|
|
80
|
+
published = true
|
|
81
|
+
ensure
|
|
82
|
+
invalidate_completed_summary(relative: relative) if validated && !published
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 领域报告正文完成后提交最终状态;取消和提交后失败共用恢复协议。
|
|
86
|
+
def finalize(relative, summary, checkpoint:)
|
|
87
|
+
relative = checked_relative(relative)
|
|
88
|
+
summary = CtxParser::Immutable.copy(serializable(summary))
|
|
89
|
+
checkpoint = CtxParser::Immutable.copy(serializable(checkpoint))
|
|
90
|
+
checkpoint_body = JSON.pretty_generate(checkpoint) + "\n"
|
|
91
|
+
finalized = false
|
|
92
|
+
begin
|
|
93
|
+
publish(relative, JSON.pretty_generate(serializable(summary)) + "\n")
|
|
94
|
+
finalized = true
|
|
95
|
+
rescue Error => error
|
|
96
|
+
# 未替换时原检查点仍安全,不做可能破坏它的多余恢复写入。
|
|
97
|
+
restore_checkpoint(checkpoint_body, relative: relative) if error.committed?
|
|
98
|
+
finalized = true
|
|
99
|
+
raise
|
|
100
|
+
rescue Interrupt
|
|
101
|
+
restore_checkpoint(JSON.pretty_generate(serializable(checkpoint.merge(status: :interrupted))) + "\n", relative: relative)
|
|
102
|
+
finalized = true
|
|
103
|
+
raise
|
|
104
|
+
ensure
|
|
105
|
+
# throw、线程终止等不经过 rescue;仍撤销尚未确认持久性的最终状态。
|
|
106
|
+
restore_checkpoint(checkpoint_body, relative: relative) unless finalized
|
|
107
|
+
end
|
|
108
|
+
summary
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 每次发送配置前后发布检查点;失败由执行器停止后续写入。
|
|
112
|
+
def write_execution(state)
|
|
113
|
+
publish("execution.json", JSON.pretty_generate(state) + "\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 恢复写入或删除受阻时,调用方须把退出码及此状态作为未确认诊断。
|
|
117
|
+
def recovery_failed? = @recovery_failed == true
|
|
118
|
+
|
|
119
|
+
# 新建目录的名字属于父目录;文件 fsync 不能替代这条持久化链。
|
|
120
|
+
def prepare_directory(path)
|
|
121
|
+
missing, parent = [], path
|
|
122
|
+
until File.directory?(parent)
|
|
123
|
+
missing << parent
|
|
124
|
+
parent = File.dirname(parent)
|
|
125
|
+
end
|
|
126
|
+
FileUtils.mkdir_p(path, mode: 0o700)
|
|
127
|
+
missing.reverse_each do |created|
|
|
128
|
+
parent = File.dirname(created)
|
|
129
|
+
sync_directory(File.symlink?(parent) ? File.realpath(parent) : parent)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 批次创建与文件发布共享目录同步边界,失败直接交还宿主停止执行。
|
|
134
|
+
def sync_directory(path)
|
|
135
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) { |parent| parent.fsync }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 新一次收尾失败时,旧成功摘要不再代表当前状态;安全检查点继续保留。
|
|
139
|
+
def invalidate_completed_summary(relative: "summary.json")
|
|
140
|
+
relative = checked_relative(relative)
|
|
141
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
142
|
+
saved = JSON.parse(File.read(File.join(directory, relative)))
|
|
143
|
+
discard_summary(relative: relative) if saved["status"] == "complete"
|
|
144
|
+
end
|
|
145
|
+
rescue Errno::ENOENT
|
|
146
|
+
nil
|
|
147
|
+
rescue SystemCallError, JSON::ParserError, Interrupt
|
|
148
|
+
@recovery_failed = true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 屏蔽恢复中的次级取消,始终把最先观察到的发布失败或取消交还调用方。
|
|
152
|
+
def restore_checkpoint(body, relative: "summary.json")
|
|
153
|
+
relative = checked_relative(relative)
|
|
154
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
155
|
+
begin
|
|
156
|
+
publish(relative, body)
|
|
157
|
+
rescue Error, Interrupt => error
|
|
158
|
+
@recovery_failed = true
|
|
159
|
+
discard_summary(relative: relative) unless error.is_a?(Error) && error.committed?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
rescue Interrupt
|
|
163
|
+
@recovery_failed = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# 存储拒绝恢复时尽力移除不可信摘要,不删除已获得的设备事实。
|
|
167
|
+
def discard_summary(relative: "summary.json")
|
|
168
|
+
relative = checked_relative(relative)
|
|
169
|
+
File.unlink(File.join(directory, relative))
|
|
170
|
+
rescue Errno::ENOENT
|
|
171
|
+
nil
|
|
172
|
+
rescue SystemCallError
|
|
173
|
+
@recovery_failed = true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Time 使用 ISO-8601,递归转换 Data 只发生在显式持久化边界。
|
|
177
|
+
def serializable(value)
|
|
178
|
+
case value
|
|
179
|
+
when Time then value.iso8601(6)
|
|
180
|
+
when Data then serializable(value.to_h)
|
|
181
|
+
when Hash then value.transform_values { |item| serializable(item) }
|
|
182
|
+
when Array then value.map { |item| serializable(item) }
|
|
183
|
+
else value
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 临时文件与目标在同一目录,写入失败不发布半份报告。
|
|
188
|
+
def publish(relative, body)
|
|
189
|
+
relative = checked_relative(relative)
|
|
190
|
+
target = File.join(directory, relative)
|
|
191
|
+
phase, committed = :temporary_file, false
|
|
192
|
+
Tempfile.create([ ".ctx-runner-", ".tmp" ], File.dirname(target)) do |file|
|
|
193
|
+
file.chmod(0o600)
|
|
194
|
+
phase = :write
|
|
195
|
+
file.write(body)
|
|
196
|
+
phase = :file_sync
|
|
197
|
+
file.flush
|
|
198
|
+
file.fsync
|
|
199
|
+
phase = :rename
|
|
200
|
+
File.rename(file.path, target)
|
|
201
|
+
committed = true
|
|
202
|
+
phase = :directory_sync
|
|
203
|
+
sync_directory(File.dirname(target))
|
|
204
|
+
phase = :cleanup
|
|
205
|
+
end
|
|
206
|
+
rescue SystemCallError, IOError, NotImplementedError
|
|
207
|
+
raise @error_class.new(relative: relative, phase: phase, committed: committed), cause: nil
|
|
208
|
+
end
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
# 发布、恢复与删除使用同一条路径契约;拒绝路径时不能执行收尾副作用。
|
|
212
|
+
def checked_relative(relative)
|
|
213
|
+
unless relative.is_a?(String) && !relative.empty? && !relative.start_with?("/") &&
|
|
214
|
+
relative.split("/", -1).none? { |part| [ "", ".", ".." ].include?(part) } && !relative.include?("\0")
|
|
215
|
+
raise ArgumentError, "Report path must remain inside the batch"
|
|
216
|
+
end
|
|
217
|
+
relative.dup.freeze
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/ctx_runner.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ctx_runner/version"
|
|
4
|
+
require_relative "ctx_runner/observation"
|
|
5
|
+
require_relative "ctx_runner/collection"
|
|
6
|
+
require_relative "ctx_runner/query"
|
|
7
|
+
require_relative "ctx_runner/batch"
|
|
8
|
+
require_relative "ctx_runner/report"
|
|
9
|
+
require_relative "ctx_runner/fingerprint"
|
|
10
|
+
require_relative "ctx_runner/execution"
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ctx_runner
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Gatework contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ctx_parser
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
- - ">="
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.2.6
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - "~>"
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.2'
|
|
29
|
+
- - ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.2.6
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: ctx_driver
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.2'
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: 0.2.5
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - "~>"
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0.2'
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: 0.2.5
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: digest
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
type: :runtime
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: json
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
type: :runtime
|
|
74
|
+
prerelease: false
|
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
- !ruby/object:Gem::Dependency
|
|
81
|
+
name: time
|
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
type: :runtime
|
|
88
|
+
prerelease: false
|
|
89
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
- !ruby/object:Gem::Dependency
|
|
95
|
+
name: tempfile
|
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
type: :runtime
|
|
102
|
+
prerelease: false
|
|
103
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
- !ruby/object:Gem::Dependency
|
|
109
|
+
name: fileutils
|
|
110
|
+
requirement: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '0'
|
|
115
|
+
type: :runtime
|
|
116
|
+
prerelease: false
|
|
117
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0'
|
|
122
|
+
description: Shared collection, evidence publication and controlled change workflows
|
|
123
|
+
with explicit ownership and failure evidence.
|
|
124
|
+
executables: []
|
|
125
|
+
extensions: []
|
|
126
|
+
extra_rdoc_files: []
|
|
127
|
+
files:
|
|
128
|
+
- CHANGELOG.md
|
|
129
|
+
- LICENSE
|
|
130
|
+
- README.md
|
|
131
|
+
- docs/superpowers/plans/2026-10-21-runner.md
|
|
132
|
+
- docs/verification-2026-10-22.md
|
|
133
|
+
- examples/execution.rb
|
|
134
|
+
- examples/report.rb
|
|
135
|
+
- lib/ctx_runner.rb
|
|
136
|
+
- lib/ctx_runner/batch.rb
|
|
137
|
+
- lib/ctx_runner/collection.rb
|
|
138
|
+
- lib/ctx_runner/execution.rb
|
|
139
|
+
- lib/ctx_runner/fingerprint.rb
|
|
140
|
+
- lib/ctx_runner/observation.rb
|
|
141
|
+
- lib/ctx_runner/query.rb
|
|
142
|
+
- lib/ctx_runner/report.rb
|
|
143
|
+
- lib/ctx_runner/version.rb
|
|
144
|
+
licenses:
|
|
145
|
+
- MIT
|
|
146
|
+
metadata:
|
|
147
|
+
allowed_push_host: https://rubygems.org
|
|
148
|
+
rubygems_mfa_required: 'true'
|
|
149
|
+
rdoc_options: []
|
|
150
|
+
require_paths:
|
|
151
|
+
- lib
|
|
152
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - ">="
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: 4.0.6
|
|
157
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
158
|
+
requirements:
|
|
159
|
+
- - ">="
|
|
160
|
+
- !ruby/object:Gem::Version
|
|
161
|
+
version: '0'
|
|
162
|
+
requirements: []
|
|
163
|
+
rubygems_version: 4.0.16
|
|
164
|
+
specification_version: 4
|
|
165
|
+
summary: Run evidence collection and controlled network changes.
|
|
166
|
+
test_files: []
|