Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e897b7f0 | ||
|
|
e04b7877c3 | ||
|
|
e1f08650a1 | ||
|
|
2d75d101a0 | ||
|
|
6aa19e0810 | ||
|
|
0c8bea3748 | ||
|
|
a094d66e83 | ||
|
|
0db2645f2d | ||
|
|
15284f8b45 | ||
|
|
0d0d9f91f0 | ||
|
|
cb72983e02 | ||
|
|
b73592d4b9 | ||
|
|
3f66da541d | ||
|
|
b360bcb974 | ||
|
|
efa0b2af7e |
@@ -0,0 +1,178 @@
|
||||
# feature/container-storage-plan-u1 任务卡
|
||||
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
|
||||
|
||||
- 权威仓:ssh://git@192.168.3.2:2222/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 955a32de2bb49171348bad5f566cce935df19212
|
||||
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1(worktree);分支:feature/container-storage-plan-u1
|
||||
- 分支类型:child;initiative_id:feature-container-storage-plan;root_target:main;immediate_target:feature/container-storage-plan;accepting_requirements:false;parent_branch:feature/container-storage-plan;parent_head_sha:b360bcb9745e20d9d4f9f06c44a2936f47217ae4;parent_pr:无;parent_source:origin
|
||||
- 需求分流:免(父 initiative 子任务登记已列出 feature/container-storage-plan-u1;parent_head_sha=b360bcb9745e20d9d4f9f06c44a2936f47217ae4)
|
||||
|
||||
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
- 授权与交付终点:在父 initiative 授权范围内实施方案 U1(环境与数据归属合同),交付终点为合入父分支 feature/container-storage-plan。不含:生产数据盘点/访问、部署、生产迁移、容器改造实施(U2 起)。
|
||||
- 当前阶段:实施与自检
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
**目标(方案 U1)**:扩展现有实例配置与 manifest,绑定基座库、RDM 领域库、知识库三个逻辑数据库、持久文件根和环境身份(prod/test/verify);产出全部必需持久数据所有者的归属清单(类型、源版本、目录、引用形式、唯一写者;不含秘密与业务内容)。
|
||||
|
||||
**主要落点**:`scripts/svc_manifest.py`、`scripts/svc.sh`、`docs/svc.md`、`PROJECT.md`;新增环境 schema/示例建议置 `deploy/`。测试扩展 `tests/test_svc_identity.py`、`tests/test_svc_lifecycle_isolation.py`、`tests/test_svc_precheck.py`。
|
||||
|
||||
**完成条件**:配置、归属清单和迁移范围有唯一解释;明确需迁移的每个数据所有者;旧配置升级保留身份;错库/错根/foreign marker/缺失必需绑定拒绝;同名项目或冲突端口在修改前失败;在另一 checkout 调用仍定位同一资源。
|
||||
|
||||
**非目标**:不实现容器启停(U2)、迁移执行(U3)、备份恢复包(U4)、克隆隔离(U5);不改业务权限模型;不引入第二份 DSN/卷权威(KTD3:manifest 唯一权威)。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
|
||||
|
||||
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| container-storage-u1-env-data-ownership | manifest/svc 绑定三库+文件根+环境身份;数据归属清单 | ccceb3bf… | implementation | cursor,antigravity,pi-glm,pi,codex | 1 | cursor(auto) | failed-unknown | 无(失败保留) | 否 | /tmp/container-storage-u1-run-receipt.json |
|
||||
| container-storage-u1-env-data-ownership | 同上(采用 attempt1 补丁并验证) | ccceb3bf… | implementation | cursor,… | 2 | cursor(auto) | failed-unknown | 无(JSON 后带尾文解码失败,现场保留) | 否 | /tmp/container-storage-u1-run2-receipt.json |
|
||||
| container-storage-u1-env-data-ownership | 同上 | ccceb3bf… | implementation | cursor,… | 3 | cursor(auto) | failed-unknown | 无(CLI 启动即报 Cannot use this model: auto,模型列表为空;单测 CLI 正常,判瞬时故障) | 否 | /tmp/container-storage-u1-run3-receipt.json |
|
||||
| container-storage-u1-env-data-ownership | 同上 | ccceb3bf… | implementation | cursor,antigravity,… | 4 | cursor(auto) | failed-unknown | 无(工作完成且 80 测试通过,但终态 JSON 后再次附带尾文解码失败;cursor 累计 4 次尝试耗尽,转下一候选) | 否 | /tmp/container-storage-u1-run4-receipt.json |
|
||||
| container-storage-u1-env-data-ownership | — | ccceb3bf… | implementation | (run5 于尝试启动前被 host 终止并删除其私有运行目录,无工作产物;目的:避免 cursor 超上限重试) | 5a | — | cancelled | 无 | 否 | 无(无收据;host 披露于本行) |
|
||||
| container-storage-routing-promote-antigravity | AGENT-EXECUTION.md implementation 顺序临时调整 | ccceb3bf… | implementation | cursor,… | 1(第 2 次运行;第 1 次遇 cursor 模型列表瞬时为空 failed-unknown) | cursor(auto) | completed | apply(0c8bea37) | 否 | /tmp/container-storage-routing-receipt2.json |
|
||||
| container-storage-u1-env-data-ownership | 同上(采用 attempt1 补丁内容,经 antigravity 全新会话独立重做并验证) | ccceb3bf… | implementation | antigravity,pi-glm,pi,cursor,codex | 1 | agy(gemini-3.8-flash-high) | completed | apply(2d75d101) | 否 | /tmp/container-storage-u1-run7-receipt.json |
|
||||
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|
||||
|---|---|---|---|
|
||||
| 启动 | 955a32de2bb49171348bad5f566cce935df19212 | b360bcb9745e20d9d4f9f06c44a2936f47217ae4 | 2026-10-22T09:12:44Z |
|
||||
|
||||
## 证据(Evidence)
|
||||
|
||||
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:U1 完成。manifest/svc 扩展三库+文件根+环境身份绑定与错误拒绝;deploy/data-ownership.md 归属清单与 env-bindings.example.env 示例;文档同步。经 agent-exec transport ed39db43 进入分支(2d75d101)。
|
||||
- 验证:三套件 80 项 PASS(1 skip,SKIP 为环境缺 DSN 的既有用例);make test-affected 574 项 PASS(18 skip)。证据见上方两个导入收据(SELF)。容器真实运行验证属 U2,NOT_RUN。
|
||||
- 影响与剩余项:无阻塞。生产数据盘点需授权(方案 U1 生产部分推迟到 O1 前核实)。
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- Review 记录:REVIEW PASS(无阻塞项;N1—N8 非阻塞,报告 docs/superpowers/reviews/2026-10-22-container-storage-u1-review.md,transport 7f0f... 见 /tmp/container-storage-u1-review-receipt2.json)。审阅者:pi-glm 全新会话(fresh_session,非实施者);base 955a32de、head e1f08650;重放 80 项单测 OK
|
||||
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
|
||||
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
|
||||
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-22T09:11:55.612223+00:00",
|
||||
"command": "make precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan",
|
||||
"before_head": "b360bcb9745e20d9d4f9f06c44a2936f47217ae4",
|
||||
"after_head": "b360bcb9745e20d9d4f9f06c44a2936f47217ae4",
|
||||
"branch": "feature/container-storage-plan",
|
||||
"target_branch": "main",
|
||||
"target_head": "955a32de2bb49171348bad5f566cce935df19212",
|
||||
"root_target": "main",
|
||||
"immediate_target": "main",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan'",
|
||||
"[repo] branch=feature/container-storage-plan head=b360bcb974",
|
||||
"[remote] origin/main=955a32de2b ahead=2 behind=0",
|
||||
"[tree] clean submodules_drift=0 uninit=0",
|
||||
"[worktrees] linked=6",
|
||||
"[initiatives] local cards (not authoritative): feature/container-storage-plan",
|
||||
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
|
||||
"[facts] policy/facts.md 6 条事实均在有效期内",
|
||||
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
|
||||
"[R0] 955a32de2b(完整:955a32de2bb49171348bad5f566cce935df19212)",
|
||||
"[worktree] before-sync: recursive component checks passed",
|
||||
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan'"
|
||||
],
|
||||
"output_sha256": "58ef898d944057a44fb02e1fabb6acc2fd97943beed355977229d13711b2e6b2",
|
||||
"receipt_sha256": "59e8b2e7b7032c19207a19f9427486439ca15250333e83ddd162db739c5dc395",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/59e8b2e7b7032c19207a19f9427486439ca15250333e83ddd162db739c5dc395.json"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-22T10:41:41.286181+00:00",
|
||||
"command": "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1",
|
||||
"before_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
|
||||
"after_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
|
||||
"branch": "feature/container-storage-plan-u1",
|
||||
"target_branch": "main",
|
||||
"target_head": "98ee59c477785b2d5094fcbea3e67a4dc864c388",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/container-storage-plan",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"........................s.......................................................",
|
||||
"----------------------------------------------------------------------",
|
||||
"Ran 80 tests in 66.437s",
|
||||
"",
|
||||
"OK (skipped=1)"
|
||||
],
|
||||
"output_sha256": "1bc6f83d7e4c64a628d5f29bad07fc4e8cc2468937c66bda4bd9a5e00fc22587",
|
||||
"receipt_sha256": "bfe74991277ebe308f667532e9b21af8215f453db124afeab1fc3d2115adf67d",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/bfe74991277ebe308f667532e9b21af8215f453db124afeab1fc3d2115adf67d.json"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-22T10:42:53.576009+00:00",
|
||||
"command": "make test-affected",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1",
|
||||
"before_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
|
||||
"after_head": "2d75d101a04ce1d1c9fa1d66d9567894222399d5",
|
||||
"branch": "feature/container-storage-plan-u1",
|
||||
"target_branch": "main",
|
||||
"target_head": "98ee59c477785b2d5094fcbea3e67a4dc864c388",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/container-storage-plan",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"test_invalid_json_is_denied (test_write_guard.WriteGuardTests.test_invalid_json_is_denied) ... ok",
|
||||
"test_missing_parent_on_main_is_denied (test_write_guard.WriteGuardTests.test_missing_parent_on_main_is_denied) ... ok",
|
||||
"test_missing_unknown_and_malformed_write_input_are_denied (test_write_guard.WriteGuardTests.test_missing_unknown_and_malformed_write_input_are_denied) ... ok",
|
||||
"test_non_git_destination_is_explicitly_outside_scope (test_write_guard.WriteGuardTests.test_non_git_destination_is_explicitly_outside_scope) ... ok",
|
||||
"test_nonwrite_tools_are_outside_scope (test_write_guard.WriteGuardTests.test_nonwrite_tools_are_outside_scope) ... ok",
|
||||
"test_relative_path_uses_hook_cwd_not_process_cwd (test_write_guard.WriteGuardTests.test_relative_path_uses_hook_cwd_not_process_cwd) ... ok",
|
||||
"test_symlink_into_main_with_missing_parents_is_denied (test_write_guard.WriteGuardTests.test_symlink_into_main_with_missing_parents_is_denied) ... ok",
|
||||
"test_valid_multi_file_patch_on_feature_is_allowed (test_write_guard.WriteGuardTests.test_valid_multi_file_patch_on_feature_is_allowed) ... ok",
|
||||
"",
|
||||
"----------------------------------------------------------------------",
|
||||
"Ran 574 tests in 400.853s",
|
||||
"",
|
||||
"OK (skipped=18)",
|
||||
"make[2]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1'",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan-u1'"
|
||||
],
|
||||
"output_sha256": "d738cef7d9ea78bcc4624e0fe8eb87b651e88712fcd283bb26c21755168604e9",
|
||||
"receipt_sha256": "0565d5b327cb8d01e48deda867bd7369dd5728b5ad9024b51bbd6cb1a2fdf246",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/0565d5b327cb8d01e48deda867bd7369dd5728b5ad9024b51bbd6cb1a2fdf246.json"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,177 @@
|
||||
# feature/container-storage-plan 任务卡
|
||||
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:ssh://git@192.168.3.2:2222/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 955a32de2bb49171348bad5f566cce935df19212
|
||||
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-container-storage-plan(worktree);分支:feature/container-storage-plan
|
||||
- 分支类型:initiative;initiative_id:feature-container-storage-plan;root_target:main;immediate_target:main;accepting_requirements:true
|
||||
- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=53a3aac8b5638347861577e2ab6f487c182b364e44b1fb0b563df0fdb5834870;triage_receipt=/tmp/container-storage-plan-triage-20261022-b.json;receipt_sha256=31e6accb098e8572e3698e04e3dbafc340fd9ef300df7a09110f1cb249aabb44
|
||||
|
||||
- 授权与交付终点:按已审阅方案实施 U1—U6(M1—M3)并交付到 main;含各仓代码/测试/文档变更、PR/CI/合入。不含:生产迁移 O1、生产数据读取/写入、部署到生产、数据库大版本升级。
|
||||
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收
|
||||
- 当前阶段:实施与自检(M1/U1 起);当前增量:I1=U1
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 章程(charter)
|
||||
|
||||
**用途与目标**:按已审阅方案(`docs/superpowers/plans/2026-10-22-container-storage-plan.md`)把部署形态改造为:一个完整应用服务二进制、两个 PostgreSQL 容器(业务基座库 + RDM 领域库)、知识库 PostgreSQL/pgvector 容器(第三个逻辑库)、外置持久文件根;生产/测试/验证三套环境独立配置、数据、身份与凭据;形成可验证的完整备份恢复与环境克隆隔离流程(需求 R1—R8、决策 KTD1—KTD7 见方案)。
|
||||
|
||||
**接纳范围**:方案 Implementation Units U1—U6 及其测试;各仓仅做阻塞目标的最小接线。U1 环境与数据归属合同;U2 容器数据库与 svc 接线;U3 迁移与文件引用兼容;U4 组合备份与恢复新目标;U5 测试/验证克隆与环境选择;U6 单二进制与组合验收、运维交付。
|
||||
|
||||
**非目标**:生产迁移执行(方案 O1,另获窗口授权后单独安排);高可用平台、Kubernetes、托管云数据库、全量对象存储、应用微服务化;零停机/实时复制/PITR;删除既有 SQLite/原生 PG 路径;PG 大版本升级。
|
||||
|
||||
**分级测试策略**:L2→L1 用 `make test-affected` 最小直接检查;L1→main 最终窗口跑完整组合(`make test`,含 `test-pg`、`svc-realcombo`、`test-regulations-kb`);容器与文件恢复必须有真实临时环境验证,缺 DSN 的跳过不计通过。
|
||||
|
||||
**验收条件**:方案 Definition of Done——单二进制在目标环境启动并符合两容器三库边界;三环境独立可启停重建且隔离实测;完整备份恢复到新目录且原文/引用不变;真实迁移 fixture 对账通过;精确组件组合、测试证据、独立 Review、必需 CI 与规范文档齐备。
|
||||
|
||||
**追加领域规则**:manifest 是身份与期望态唯一权威,Compose 只作版本化模板(KTD3);普通启停/重建不隐式删卷、不隐式恢复旧备份、不在数据库失败时回退空 SQLite(R7);镜像按选定 digest/扩展版本逐项核对(KTD7);秘密不入仓不入日志(baseline 硬边界 2)。
|
||||
|
||||
**生命周期**:M1(U1→U2)→ M2(U3→U4)→ M3(U5→U6)→ M4 生产切换(O1 另行授权,不在本 initiative 内)。U1 完成后按实测数据规模细化后续工期;全部必需子任务合入后置 `accepting_requirements=false` 收口合入 main。
|
||||
|
||||
## 子任务登记
|
||||
|
||||
| 子任务分支 | 必需 | 状态 | PR |
|
||||
|---|---|---|---|
|
||||
| feature/container-storage-plan-u1 | 是 | 未开始 | — |
|
||||
| feature/container-storage-plan-u2 | 是 | 未开始 | — |
|
||||
| feature/container-storage-plan-u3 | 是 | 未开始 | — |
|
||||
| feature/container-storage-plan-u4 | 是 | 未开始 | — |
|
||||
| feature/container-storage-plan-u5 | 是 | 未开始 | — |
|
||||
| feature/container-storage-plan-u6 | 是 | 未开始 | — |
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
|
||||
|
||||
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||
|
||||
## 关联方向与规划
|
||||
|
||||
- 适用状态:适用(本 initiative 的方案与实施计划)
|
||||
- 主记录路径/版本:`docs/superpowers/plans/2026-10-22-container-storage-plan.md`(2026-10-22 审阅快照,含落库状态注记)
|
||||
- 对应里程碑:M1(U1→U2)→ M2(U3→U4)→ M3(U5→U6);M4/O1 生产切换不在本 initiative
|
||||
|
||||
## 需求分析
|
||||
|
||||
| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R1—R8 | 见方案 Requirements 表(R1 单二进制、R2 容器化、R3 环境绑定、R4 恢复可核对、R5 迁移覆盖、R6 克隆隔离、R7 无隐式破坏、R8 操作记录) | 符合(方案已核对现有 svc/manifest/迁移器/KB Compose 能力) | 高 | 见方案各单元完成条件与 Definition of Done | U1←无;U2←U1;U3←U1/U2;U4←U2/U3;U5←U4;U6←U1—U5 | 纳入本增量(U1—U6) |
|
||||
|
||||
## 方案与计划
|
||||
|
||||
- 方案引用:`docs/superpowers/plans/2026-10-22-container-storage-plan.md`(2026-10-22 审阅快照;Product Contract、KTD1—KTD7、High-Level Technical Design)
|
||||
- 实施计划引用:同上 Implementation Units U1—U6 与里程碑 M1—M3(M4/O1 不在本 initiative)
|
||||
- 测试计划引用:同上 Verification Contract 与各单元测试文件/场景清单
|
||||
|
||||
## 阶段记录
|
||||
|
||||
| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 |
|
||||
|---|---|---|---|---|---|
|
||||
| 需求拆解 | 方案 Requirements R1—R8 | 已完成 | 主模型(方案会话)+ triage NEW | 2026-10-22 | /tmp/container-storage-plan-triage-20261022-b.json |
|
||||
| 需求评估 | 方案 Problem Frame/边界假设 | 已完成 | 三视角文档审阅 + 主模型复核 | 2026-10-22 | /tmp/container-storage-plan-20261022/review.md |
|
||||
| 需求表确认 | R1—R8 纳入 U1—U6 | 已完成 | 主模型 | 2026-10-22 | 方案 Requirements 表 |
|
||||
| 方案设计 | KTD1—KTD7 | 已完成 | 方案会话(已审阅) | 2026-10-22 | plan.md |
|
||||
| 实施计划 | U1—U6 + M1—M4 | 已完成 | 方案会话(已审阅) | 2026-10-22 | plan.md Implementation Units |
|
||||
| 实施与自检 | 待定 | 待执行 | — | — | — |
|
||||
| 正式测试与验收 | 待定 | 待执行 | — | — | — |
|
||||
| 代码 Review | 待定 | 待执行 | — | — | — |
|
||||
| 合入 | 待定 | 待执行 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 待执行 | — | — | — |
|
||||
|
||||
## 探索试验与迭代
|
||||
|
||||
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|
||||
|---|---|---|---|
|
||||
| 启动 | 955a32de2bb49171348bad5f566cce935df19212 | 955a32de2bb49171348bad5f566cce935df19212 | 2026-10-22T09:09:21Z |
|
||||
|
||||
## 证据(Evidence)
|
||||
|
||||
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 交付调度
|
||||
|
||||
- 调度状态:未入队;调度记录:无
|
||||
- entry_id:无;attempt_id:无;窗口执行者:无
|
||||
- 固定候选:无(base/head、组件与相关配置引用实际尝试)
|
||||
- 调度依据:policy/delivery.md#合入调度
|
||||
- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
- 影响与剩余项:
|
||||
|
||||
## 规划进展与下一步
|
||||
|
||||
- 本次推进/剩余:待核对(依据实际成果更新适用方案/规划)
|
||||
- 首选下一任务:待核对(无适用规划、已完成或受阻时如实说明,不虚构任务)
|
||||
- 推荐依据:待填写
|
||||
- 前置条件/待确认:待核对
|
||||
- 预期产物/验收条件:待定义
|
||||
- 重复任务核对:待核对(已有/在途任务、PR 与规划记录)
|
||||
- 启动授权:未授权(建议不自动启动新任务,仍须明确授权)
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据)
|
||||
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
|
||||
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
|
||||
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-22T09:08:28.920468+00:00",
|
||||
"command": "make precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev",
|
||||
"before_head": "955a32de2bb49171348bad5f566cce935df19212",
|
||||
"after_head": "955a32de2bb49171348bad5f566cce935df19212",
|
||||
"branch": "main",
|
||||
"target_branch": "main",
|
||||
"target_head": "955a32de2bb49171348bad5f566cce935df19212",
|
||||
"root_target": "main",
|
||||
"immediate_target": "main",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev'",
|
||||
"[repo] branch=main head=955a32de2b",
|
||||
"[remote] origin/main=955a32de2b ahead=0 behind=0",
|
||||
"[tree] clean submodules_drift=0 uninit=0",
|
||||
"[worktrees] linked=5",
|
||||
"[initiatives] local cards (not authoritative): none",
|
||||
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
|
||||
"[facts] policy/facts.md 6 条事实均在有效期内",
|
||||
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
|
||||
"[R0] 955a32de2b(完整:955a32de2bb49171348bad5f566cce935df19212)",
|
||||
"[worktree] before-sync: recursive component checks passed",
|
||||
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev'"
|
||||
],
|
||||
"output_sha256": "ed0263b348ce265d081aab1faa0d1370f2a9478410fadfc3b9cec8a810c1295f",
|
||||
"receipt_sha256": "124a47e6bfbb43cbbbc398ad10bf42e3d448373a80affd297ed25c41d8b2b07a",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/124a47e6bfbb43cbbbc398ad10bf42e3d448373a80affd297ed25c41d8b2b07a.json"
|
||||
}
|
||||
```
|
||||
+3
-1
@@ -41,7 +41,9 @@ execution:
|
||||
max_attempts_per_profile: 3
|
||||
|
||||
roles:
|
||||
implementation: [cursor, antigravity, pi-glm, pi, codex]
|
||||
# cursor 在 U1 单元已耗尽 4 次尝试:1 次传输中断、2 次终态 JSON 后附带尾文、1 次模型列表瞬时为空。
|
||||
# 临时调整,交付前回退。
|
||||
implementation: [antigravity, pi-glm, pi, cursor, codex]
|
||||
repair: [cursor, antigravity, pi-glm, pi, codex]
|
||||
test: [pi-glm, pi, antigravity, cursor, codex]
|
||||
review: [pi-glm, pi, antigravity, cursor, codex]
|
||||
|
||||
+6
-1
@@ -2,7 +2,10 @@
|
||||
|
||||
- 用途:开发集成父仓(gitlink 权威);非分发单元。
|
||||
- 组合:`modules/`(产品组件)+ `deps/`(共享依赖);完整组件清单以 [`.gitmodules`](.gitmodules) 为准。
|
||||
- 部署:非容器;Releases 仓从本仓固定提交导出版本。
|
||||
- 部署:非容器为现行默认(`storage_mode=legacy`);容器化存储改造见
|
||||
[容器化存储方案](docs/superpowers/plans/2026-10-22-container-storage-plan.md),
|
||||
环境与数据归属合同(U1)见 [deploy/data-ownership.md](deploy/data-ownership.md)。
|
||||
Releases 仓从本仓固定提交导出版本。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -56,6 +59,8 @@
|
||||
[计划](modules/documents/docs/proposals/2026-10-12-work-companion-ui-plan.md) 覆盖任务摘要、对话/输入、窄屏和真实状态投影。
|
||||
- [服务重启与全量数据恢复](modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md):svc 启停、异常退出后的依赖、配置/数据身份、任务/HITL/副作用、文件与组合备份恢复;
|
||||
U8 规格见 [SQLite 实例守护](docs/recovery/u8-sqlite-instance-guard.md)。
|
||||
- [单一应用二进制与容器化数据库、知识库存储](docs/superpowers/plans/2026-10-22-container-storage-plan.md):两容器三库、外置文件根、环境独立绑定;
|
||||
U1 归属清单见 [deploy/data-ownership.md](deploy/data-ownership.md)。
|
||||
- [svc 统一部署与一次性登录 Key](modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md):
|
||||
二进制/源码统一部署、环境准备、无初始密码开户与账户恢复;规格见 `docs/svc-deploy/`,
|
||||
账户与恢复续审见 [续审记录](docs/svc-deploy/2026-10-12-account-durability-plan-review.md)。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 容器化存储:数据归属清单(U1)
|
||||
|
||||
权威设计:`docs/superpowers/plans/2026-10-22-container-storage-plan.md`(U1、KTD1—KTD4)。
|
||||
实例身份与期望态唯一权威:`runtime/<name>/manifest.json`(`scripts/svc_manifest.py`);
|
||||
本清单只登记**数据所有者、类型、引用形式与唯一写者**,不含秘密值与业务内容。
|
||||
|
||||
生产体量/源版本盘点需另行授权访问后补全;未核实项不得据此声明迁移完成。
|
||||
|
||||
## 归属总表
|
||||
|
||||
| 所有者 | 类型 | 引用形式(登记键) | 唯一写者 | 迁移范围 |
|
||||
|---|---|---|---|---|
|
||||
| 基座逻辑库 | PostgreSQL 逻辑库(账号/项目/消息/文档/设置等基座表族) | `base_dbref` 或 `base_dsn_ref`(绝对路径秘密文件)+ `base_role_ref` + `base_port`;身份锚 `identity.base_db_ref` | 本实例应用进程(经基座 DSN);引导/迁移角色与应用角色分离(U2) | 必需 |
|
||||
| RDM 领域库 | PostgreSQL 逻辑库(RDM 域表/项目文档树等) | `rdm_dbref` 或 `rdm_dsn_ref` + `rdm_role_ref` + `rdm_port`;锚 `identity.rdm_db_ref` | 本实例应用内 RDM 域(独立 DSN);非基座连接串 | 必需 |
|
||||
| 知识库 | PostgreSQL/pgvector 逻辑库(语料、索引投影) | `kb_dbref` 或 `kb_dsn_ref` + `kb_role_ref` + `kb_port`;锚 `identity.kb_db_ref` | 本实例知识库接入(应用内);索引可重建但语料库为权威 | 必需 |
|
||||
| 持久文件根 | 目录树(工件、RDM uploads、KB 原件及必要解析产物) | `files_root`(**绝对路径**,不相对 checkout/worktree 解析);锚 `identity.files_root` | 本实例应用进程;禁止他实例共用同一根 | 必需 |
|
||||
| 环境身份 | 枚举 | `env_class` ∈ `prod`\|`test`\|`verify`;锚 `identity.env_class` | 运维显式登记;克隆时换新(U5) | 必需(container) |
|
||||
| 实例身份 | 稳定 ID + 数据 marker | `instance_id`;`<data_root>/.agentx/data-marker.json` | `svc` manifest 治理;启动只校验不重建 | 必需 |
|
||||
| Compose 项目 | 容器项目名 | `compose_project`;同 runtime 内唯一 | 本环境容器驱动(U2);冲突在修改前拒绝 | container 必需 |
|
||||
|
||||
`storage_mode=legacy`(缺省):沿用既有 SQLite / 宿主 PG(`db`/`dbref`/`dsn`)路径,不要求三库与 `files_root`。
|
||||
`storage_mode=container`:上表 container 行全部必需;缺任一绑定、错库、错文件根、foreign marker、同名 Compose 或端口冲突 → **拒绝**,不静默建空库、不回退默认。
|
||||
|
||||
凭据只允许以受保护文件路径引用(`*_dsn_ref` / `*_role_ref`),明文不得入 manifest、日志或本清单。
|
||||
|
||||
## 配置形态
|
||||
|
||||
见同目录 [`env-bindings.example.env`](env-bindings.example.env)。写入实例:
|
||||
|
||||
```bash
|
||||
# 先写齐绑定键,最后打开 container 合同(缺一即拒)
|
||||
scripts/svc.sh <name> … # 经 svc_set_key / manifest set;Compose·端口冲突在修改前失败
|
||||
```
|
||||
|
||||
另一 checkout/worktree 只要指向同一绝对 `files_root` 与库引用,仍定位同一资源(路径不相对仓库根解析)。
|
||||
|
||||
## 与旧恢复清单关系
|
||||
|
||||
细粒度基座表/插件枚举仍见 [`docs/recovery/u1-data-ownership.md`](../docs/recovery/u1-data-ownership.md)。
|
||||
本文件是容器化三库+文件根的**合同级**归属;实施迁移(U3)时两者对照,必需所有者未覆盖则阻塞切换。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 容器化存储实例绑定示例(U1)——写入 runtime/<name> 经 manifest 权威登记。
|
||||
# 秘密只引用受保护文件路径,不在此填写明文密码/DSN 值。
|
||||
# 完整合同见 deploy/data-ownership.md;schema 键由 scripts/svc_manifest.py 校验。
|
||||
|
||||
# legacy(缺省)| container(三库+文件根+环境身份全部必需)
|
||||
storage_mode=container
|
||||
|
||||
# 环境身份:prod | test | verify(三选一)
|
||||
env_class=prod
|
||||
|
||||
# 持久文件根:必须是绝对路径(不相对代码 checkout/worktree 解析)
|
||||
files_root=/var/lib/agent-system/state/prod/files
|
||||
|
||||
# Compose 项目名:同机各环境唯一;冲突在修改前拒绝
|
||||
compose_project=agentx-prod
|
||||
|
||||
# 基座逻辑库:dbref=<pg实例>/<库名> 或 dsn_ref=<绝对路径秘密文件>(二选一)
|
||||
# base_dbref=pg-business/agent_base
|
||||
base_dsn_ref=/etc/agent-system/prod/base.dsn
|
||||
base_role_ref=/etc/agent-system/prod/base.role
|
||||
base_port=15432
|
||||
|
||||
# RDM 领域库
|
||||
rdm_dsn_ref=/etc/agent-system/prod/rdm.dsn
|
||||
rdm_role_ref=/etc/agent-system/prod/rdm.role
|
||||
rdm_port=15432
|
||||
|
||||
# 知识库(pgvector 容器独立端口)
|
||||
kb_dsn_ref=/etc/agent-system/prod/kb.dsn
|
||||
kb_role_ref=/etc/agent-system/prod/kb.role
|
||||
kb_port=15433
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
product_contract_source: ce-plan-bootstrap
|
||||
execution: code
|
||||
date: 2026-10-22
|
||||
title: 单一应用二进制与容器化数据库、知识库存储改造方案
|
||||
---
|
||||
|
||||
> **落库状态(2026-10-22)**:方案已获审阅并授权实施。任务以 L1 initiative
|
||||
> `feature/container-storage-plan` 重新登记(R0=955a32de2bb49171348bad5f566cce935df19212)。
|
||||
> 原始规划工作区(基线 9a80897)因子模块物化失败已保留现场并对账后回收:
|
||||
> 全部子模块 HEAD 均在远端分支上、无独有提交、无未跟踪文件,任务卡副本存于
|
||||
> `/tmp/container-storage-plan-20261022/card-auto-generated.md`。本文正文为审阅时快照,
|
||||
> 其中基线 9a80897 与工作区失败描述属历史记录;实施以本卡与各子任务卡为准。
|
||||
|
||||
# 单一应用二进制与容器化数据库、知识库存储改造方案
|
||||
|
||||
本文是待用户审阅的方案与实施计划,不代表业务实施、部署或迁移授权。应用二进制及数据库容器的总体方向来自本次会话;具体分工、步骤与验收为本方案建议。
|
||||
|
||||
计划归属 Agent-System-Dev;本文因新工作区子模块物化失败暂存仓外,尚未提交或合入。路径以各标明仓库的根目录为基准。后续正规落点建议为 Dev `docs/superpowers/plans/2026-10-22-container-storage-plan.md`。
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **目标**:运维人员能重建生产、测试、验证环境,把一个完整环境迁到新机器或新目录,并通过恢复验证确认账号、项目、文档、文件和知识库引用仍可用。
|
||||
- **手段**:一个应用服务二进制,两个 PostgreSQL 容器,三套逻辑数据库,外置持久文件;同一制品使用独立环境配置(KTD1—KTD4)。
|
||||
- **当前授权**:制定方案和计划。产品代码、容器部署、生产数据读取/复制、迁移切换均未在本轮执行;后续按用户明确授权范围实施。
|
||||
- **执行责任**:各仓遵循自身 AGENTS;Dev 范围的可独立实施单元按 AGENT-EXECUTION.md 分配全新外部 Agent 会话,host 管理范围、验证、独立 Review 和交付。任务进度放任务卡,不在本文伪装已完成。
|
||||
- **停止条件**:需要新增数据库类型、改变业务权限、无法完整迁移现有数据、需要丢弃新写入或扩大业务范围时停止相关动作,提交最小决策;不为容器化引入高可用平台、通用运维平台或应用微服务化。
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
保留前后端、Agent、RDM、知识库接入在一个应用进程内;数据库依赖使用 Compose。业务 PostgreSQL 容器分别承载基座库和 RDM 领域库,知识库 PostgreSQL/pgvector 容器承载语料与检索索引。生产、测试、验证环境分别建立,不通过临时修改生产连接串来“切环境”。
|
||||
|
||||
### Problem Frame
|
||||
|
||||
现有宿主数据库运维、应用快照、RDM 领域备份和知识库恢复各有入口,但尚未形成可核验的完整环境恢复闭环。容器化只改变数据库运行方式,必须同时处理身份绑定、数据库与文件的一致恢复点,以及复制环境后的副作用隔离。
|
||||
|
||||
### Requirements
|
||||
|
||||
| ID | 要求与可核验结果 |
|
||||
|---|---|
|
||||
| R1 | 完整应用服务只需一个可执行文件,包含前端资源、后端、Agent、RDM、知识库接入;运行机无需源码、Go 或 Node 构建环境。 |
|
||||
| R2 | 业务库和知识库存储容器化,明确持久卷、镜像版本、扩展版本及生命周期;容器重建后业务数据仍在。 |
|
||||
| R3 | 每环境独立绑定基座库、RDM 库、知识库、文件根、实例身份、端口和凭据;错误绑定拒绝启动。 |
|
||||
| R4 | 从备份恢复到新目录/主机后,账号、权限、业务记录、原文和历史引用可核对;成功备份必须是一个可解释的一致恢复点。 |
|
||||
| R5 | 从当前实际 SQLite/宿主 PG 迁移时覆盖所有必需持久数据所有者,保留业务标识、引用与原始内容,缺失或未知数据阻止切换。 |
|
||||
| R6 | 测试/验证克隆获得新实例身份及独立凭据,启动前隔离生产密钥、会话、自动任务和外部副作用;生产实例保持固定绑定。 |
|
||||
| R7 | 普通启停、重建、升级不隐式删卷、不执行数据库大版本升级、不恢复旧备份、不在数据库失败时回退到空 SQLite。 |
|
||||
| R8 | 形成可重复的组合验证和生产切换/回滚操作记录;实现完成、测试通过、生产迁移完成、业务验收通过分别记录。 |
|
||||
|
||||
### 边界与假设
|
||||
|
||||
- “单一二进制”按用户原话“最终的服务”解释为一个常驻应用服务。允许部署配置、脚本和离线迁移工具存在;不新增常驻 admin、KB HTTP 或前端服务。本期不为统一离线工具而重构全部 CLI。
|
||||
- 一期面向单机部署,每套环境独立;可在同机不同端口运行,也可迁到不同主机。宿主应用通过回环地址连接本机容器。
|
||||
- 知识库容器化指语料数据库及向量索引容器化;检索编排、权限与引用业务仍在应用二进制内。原始文档使用外置持久存储。
|
||||
- 允许维护窗口,先实现可验证的停写备份与迁移,不承诺零停机、实时复制、PITR 或自动故障切换。
|
||||
- 保留既有 SQLite/原生 PG 运行路径用于兼容与受控回退;本方案目标环境使用容器 PG,不顺带删除旧实现。
|
||||
- 公有知识库共享、跨环境共用只读卷、Kubernetes、托管云数据库、全量对象存储替换均不进入首期。
|
||||
- 生产停写窗口、源实例、机器容量、实际源版本与数据规模在 U1 核实;它们不妨碍方案审阅,但未核实前不能安排生产切换。规划不承诺未经测量的 RTO/RPO。
|
||||
|
||||
## Planning Contract
|
||||
|
||||
### 当前依据与可复用基础
|
||||
|
||||
本轮主仓基线 Dev `9a80897cb7e9108091d302e849f59662f21ce260`。以下是源码/文档核查,未探测实际生产实例,也未运行产品测试。
|
||||
|
||||
| 范围 | 已核对的依据 | 本次改造含义 |
|
||||
|---|---|---|
|
||||
| 单二进制 | Dev `Makefile`;Backend `web/embed.go`;RDM `main.go`、`scripts/build-product.sh` | 已有静态组合和前端嵌入,复用完整产品构建;不能用只有基座的二进制代替 RDM 产品。 |
|
||||
| 宿主 PG | Dev `scripts/svc.sh` 的 pg_bindir/db_create/pg_do_start/pg_do_stop/pg_run/pg_dump_run/db_backup/db_restore/db_clone | 容器支持需要完整后端分派,不能只替换启动命令;备份恢复继续使用已有安全边界。 |
|
||||
| 实例身份 | Dev `scripts/svc_manifest.py` 的 PROTECTED/db_anchor_of/reanchor;`tests/test_svc_identity.py` | manifest 已有权威身份;扩展三库和环境绑定,不另造第二份登记权威。 |
|
||||
| 基座迁移 | Backend `cmd/storage-migrate/main.go` | 已有 SQLite→PG 工具,但只迁 Backend;artifacts 参数只盘点不复制,不能据此声称迁了整个产品。 |
|
||||
| RDM 迁移 | RDM `main.go` 的 migrate-to-pg/migrate-check/backup-domain/restore-domain | 独立 RDM_DSN;RDM 领域库和 uploads 必须明确纳入。 |
|
||||
| KB Compose | KB-Regulations `deploy/compose.yaml`、`deploy/README.md` | 已有本地 pgvector/pgvector:pg16、回环端口、卷与扩展初始化模板;固定项目名 kbreg 不适合作为所有环境的全局名称,已有标签不等于生产版本批准。 |
|
||||
| 文件迁移 | Backend `internal/blobstore/blobstore.go`;KB-Regulations `migrations/0001_schema.sql`、`docs/operations/corpus-import.md` | 工件目录相关引用、source_assets.storage_uri 中 file 路径会影响异地恢复;不能只做 dump/restore。 |
|
||||
| 组合恢复 | Dev `scripts/svc.sh` 的 agent_backup_snapshot;`tests/integration/regulations-kb/README.md` | 应用快照不含全套数据库和原始 manifest;KB 测试可复用,但不证明全套外部文件已恢复。 |
|
||||
|
||||
RDM 研究对象为兄弟检出 `b14c84c6dcc945bd4d0a9ad446cf1ec980396491`,KB 为 `393555bec8d964f294f932616aa69ab833a06de1`;它们与 Dev `tests/integration/regulations-kb/components.pin` 的固定组合不同。本方案引用它们用于定位能力,正式实施前必须选定并验证精确组件组合,不能把研究检出当作已采纳版本。
|
||||
|
||||
已有恢复设计 `modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md` 是设计约束来源,本文沿用其停写一致恢复点、恢复新目标、旧写者隔离思路,不把历史执行状态当现状。
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
**KTD1.应用保持单一服务二进制。** 复用现有产品组合构建,配置和数据外置;容器承载持久数据库依赖,知识库插件不拆进程。落实 R1/R2。(session-settled: user-directed — chosen over containerizing the whole application: user requires the final service to remain a single binary)
|
||||
|
||||
**KTD2.两个 PG 容器、三个逻辑库。** 业务容器中分别设基座库与 RDM 库,知识库另用带 vector/pg_trgm 的 PG 容器。数据库使用独立应用角色;引导/迁移角色与应用权限分开,先核对现有启动迁移要求,再安排显式迁移阶段,不能直接收紧权限导致启动失败。落实 R2/R3/R5。
|
||||
|
||||
**KTD3.复用 svc 管理入口。** 新增窄的容器驱动和版本化环境配置,保留现有命令语义。manifest 是身份与期望态权威;Compose 文件是版本化模板,环境参数由同一配置解析生成。不得并存两份可独立修改的 DSN/卷权威。只管理明确归属本环境的容器,不启停外部数据库。落实 R3/R7。
|
||||
|
||||
**KTD4.稳定绝对数据根与环境独立资源。** 建议形态为 `state/<env>/` 与其外部 `backups/<env>/`,实际根由配置给出绝对路径;不相对代码 checkout/worktree 解析。Compose project、两套卷、各库、角色与端口都显式区分环境。凭据放受保护文件/secret 引用,日志和清单脱敏。文件树示意仅表达归属,不规定最终名称。
|
||||
|
||||
**KTD5.先备份完整恢复点,再追求不停机。** 关闭新写入口,排空或隔离在途任务,停止应用、定时任务、KB 导入/索引写者及迁移器;确认其他客户端不再写入后,对三库分别逻辑导出并复制受控文件。三次 dump 各自的快照不自动构成跨库一致性,靠整个窗口无写者保持一致。完成校验后才发布恢复包完成标记。落实 R4/R8。
|
||||
|
||||
**KTD6.恢复和克隆是不同操作。** 原实例恢复保留业务与实例身份,但须先停旧写者,绝不双开同一生产身份。测试克隆保留业务 ID 及引用关系,创建新的部署身份和守护绑定、数据库凭据;使生产登录会话/长期 API key 在副本失效,外部 provider 密钥不带入。后台任务与外发能力默认关闭,授权后逐项启用;不通过批量改业务 ID 实现隔离。落实 R3/R6。
|
||||
|
||||
**KTD7.迁移与升级分离。** 源 PG 采用同大版本兼容目标与匹配工具链,SQLite 迁移用已有逻辑迁移器;本次不顺带 PG 大版本升级或强制统一业务/KB PG 大版本。实施时固定经过验证的镜像 digest、平台与扩展版本。镜像官方文档说明不同主版本的 PGDATA/卷位置存在差异,配置按选定镜像逐项核对,不能把 KB pg16 的挂载路径盲用到另一主版本。初始化脚本仅负责新卷引导,既有库变更走显式迁移。落实 R2/R5/R7。
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
以下是关系和流程草图,新增配置/命令的最终命名由实现单元在既有接口中确定。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[浏览器] --> App[一个完整应用二进制]
|
||||
Profile[环境配置与实例 manifest] --> App
|
||||
Profile --> Compose[Compose 数据依赖]
|
||||
App --> Base[业务容器 / 基座库]
|
||||
App --> RDM[业务容器 / RDM 领域库]
|
||||
App --> KB[知识库容器 / 语料库与索引]
|
||||
App --> Files[持久文件根 / 工件 uploads 原件]
|
||||
Compose --> Base
|
||||
Compose --> RDM
|
||||
Compose --> KB
|
||||
```
|
||||
|
||||
```text
|
||||
state/<env>/
|
||||
instance/ manifest 与受保护配置、应用版本引用
|
||||
files/ 工件、RDM uploads、KB 原件及必要解析产物
|
||||
Docker 持久卷:<project>-business、<project>-knowledge(各环境独立)
|
||||
backups/<env>/<backup-id>/ 三库导出、文件、配置恢复材料、版本与校验清单
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[识别目标与锁定操作] --> B[预检配置/身份/镜像/资源]
|
||||
B --> C[启动两库容器]
|
||||
C --> D[认证/扩展/schema/三库身份检查]
|
||||
D --> E[按需执行显式迁移并核验]
|
||||
E --> F[启动应用]
|
||||
F --> G[业务就绪验证]
|
||||
D -->|失败| H[拒绝启动并保留现场]
|
||||
```
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Running
|
||||
Running --> Quiescing: 请求一致备份
|
||||
Quiescing --> Frozen: 写者已停且在途状态已记录
|
||||
Frozen --> Capturing: 三库与文件导出
|
||||
Capturing --> Verified: 完整性和清单校验
|
||||
Capturing --> Incomplete: 失败或中断
|
||||
Verified --> Running: 发布完成标记后恢复原实例
|
||||
Incomplete --> Running: 确认现场可安全恢复后解除停写
|
||||
```
|
||||
|
||||
恢复流程:读取完成清单 → 校验兼容版本与文件 hash → 选择空的新目标 → 建库/恢复角色权限及三库 → 还原文件与受控路径映射 → 核对实例模式(原实例/克隆)→ 禁止外部副作用下启动 → 登录、权限、文档、KB 引用验证 → 单独决定是否开放写入。任一步失败保持目标隔离,不覆盖旧系统。
|
||||
|
||||
### 完整恢复包合同
|
||||
|
||||
- 基座、RDM、KB 三库的逻辑导出及 schema/扩展版本;必需角色/权限用受控恢复定义记录,不能盲目导入生产全局超级用户。
|
||||
- 工件、uploads、KB 原件及必要解析产物;包含全部必需插件声明的数据。对对象存储记录不可变版本/校验值及可恢复获取方式;无法获取的必需对象阻止完整恢复声明。
|
||||
- 原始实例 manifest、部署配置、应用制品及插件/前端组合的 SHA、容器镜像 digest、平台信息;配置清单不含明文秘密,恢复秘密通过独立受保护介质或重新注入。仅含引用时必须验证引用在目标可解析,不能称恢复包独立可恢复。
|
||||
- 文件清单与 hash、数据所有者清单、停写时间和操作状态、恢复工具版本、校验结果及完成标记。备份与 runtime 卷分离,至少验证一份异机/独立存储副本可读取。
|
||||
- 原件路径变更仅调整定位信息,不改变内容 hash、语料修订、节点 ID、历史 excerpt 和审计封印。受保护审计内容若包含原路径,必须通过解析映射兼容,禁止重写封印记录。
|
||||
- 向量索引可重建,但 embedding 模型/维度和语料版本要记录;恢复测试不依赖付费重新嵌入,优先复用保存的向量重建索引。
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1.环境与数据归属合同
|
||||
|
||||
- **需求**:R1/R3/R5/R6,KTD1—KTD4。
|
||||
- **目标**:一份可解析配置确定完整实例资源,明确现有数据来源和迁移边界。
|
||||
- **主要文件**:Dev `scripts/svc_manifest.py`、`scripts/svc.sh`、`docs/svc.md`、`PROJECT.md`;新增环境 schema/示例置于 Dev `deploy/`(建议路径)。
|
||||
- **做法**:扩展现有 manifest 绑定基座/RDM/KB,版本化旧配置升级;保护环境命令不受调用者环境变量污染。列出各数据所有者、类型、源版本、目录、体量、表/对象清单、引用形式和唯一写者。不输出秘密或业务内容。生产盘点需先获得对应访问授权。
|
||||
- **测试文件**:扩展 `tests/test_svc_identity.py`、`tests/test_svc_lifecycle_isolation.py`、`tests/test_svc_precheck.py`。
|
||||
- **场景**:旧配置升级保留身份;错库/错根/foreign marker 拒绝;同名项目或冲突端口在修改前失败;在另一 checkout 调用仍定位同一资源;三库中任一必需绑定缺失不能静默新建空库。
|
||||
- **完成条件**:配置、归属清单和迁移范围有唯一解释;明确需迁移的每个数据所有者。依赖:无。
|
||||
|
||||
### U2.容器数据库与 svc 接线
|
||||
|
||||
- **需求**:R2/R3/R7,KTD2—KTD4/KTD7。依赖 U1。
|
||||
- **主要文件**:Dev `scripts/svc.sh`(后端分派)、新增 `scripts/svc_containers.sh` 和 `deploy/compose.storage.yaml`(建议路径);KB-Regulations `deploy/compose.yaml`、`deploy/initdb/01-extensions.sql`、`deploy/README.md` 作为复用来源,避免复制成两套互相漂移的模板。
|
||||
- **做法**:start/stop/status/SQL/dump/restore 覆盖同一容器驱动;记录明确所有权;卷重建、普通停止和数据销毁分别处理。应用仅连 loopback 发布端口。引导创建基座/RDM 独立库与角色,KB 创建扩展;后续变更使用显式迁移。启动顺序验证真实认证、扩展和身份,不能只依赖 pg_isready。
|
||||
- **测试文件**:新增 Dev `tests/test_svc_containers.py`;扩展 `tests/test_svc_precheck.py`、`tests/test_svc_realcombo.py`。
|
||||
- **场景**:prod/test/verify 同时运行互不冲突;停止/重建一套不影响其他套且不丢数据;缺 Docker/镜像/磁盘/凭据时在破坏性动作前失败;密码变更不误以为改 env 就改变已初始化库;容器意外停止时应用不得写入 SQLite。
|
||||
- **完成条件**:用固定制品及临时环境完成两容器三库启动、持久化、重建与失败路径验证。运行冒烟优先,不用大量模拟测试替代真实容器验证。
|
||||
|
||||
### U3.迁移与文件引用兼容
|
||||
|
||||
- **需求**:R4/R5/R7,KTD5—KTD7。依赖 U1/U2。
|
||||
- **主要文件**:Backend `cmd/storage-migrate/`、`internal/blobstore/blobstore.go`;RDM `main.go` 与其现有领域迁移实现;KB-Regulations 的 source_assets 读取/原件定位实现及 `docs/operations/corpus-import.md`。函数名在实施启动时定点核对,不另建并行迁移框架。
|
||||
- **做法**:基座和 RDM 分别复用现有迁移器;覆盖实际 schema 的新增表、外键、序列/标识和身份记录;未知表不能悄悄遗漏。已有 PG 走兼容逻辑恢复。文件迁移校验内容和所有权,新增受控资产根映射或兼容相对引用,维持旧引用可读取。禁止迁移时跨出允许根或追随外部软链复制未知文件。
|
||||
- **测试文件**:Backend `cmd/storage-migrate/main_test.go`、`integration_test.go`、`seeded_singletons_test.go` 与 `internal/blobstore/` 对应测试;RDM 现有领域迁移测试(实施前定位);KB-Regulations `tests/integration/recovery_pg_test.go`。
|
||||
- **场景**:SQLite 基座+RDM 迁入不同逻辑库;原 PG 同版本恢复;空库与真实结构 fixture;失败/重跑/非空目标拒绝;未知表拒绝;不同根目录恢复后读取旧工件与 file URI 原件;越界路径拒绝;历史 citation/审计 hash 不变。
|
||||
- **完成条件**:逐所有者对账,不仅比较行数,还核对 ID、关键引用、内容 hash、权限和新写入;不能完成的必需所有者阻止继续生产准备。
|
||||
|
||||
### U4.组合备份与恢复新目标
|
||||
|
||||
- **需求**:R4/R5/R8,KTD5/KTD7。依赖 U2/U3。
|
||||
- **主要文件**:Dev `scripts/svc.sh` 的 backup/restore 入口;新增组合恢复辅助实现(建议 `scripts/svc_bundle.py`);`docs/svc.md`、恢复操作说明;各组件只提供必要导出/校验接口。
|
||||
- **做法**:按恢复包合同采集全部数据,锁定同环境操作,停写并确认所有写者;失败包无完成标记。完整备份默认只恢复到空的新目标;应用制品和 schema 兼容预检先于开放写入。秘密处理有明确备份/重新注入路径及权限校验。
|
||||
- **测试文件**:扩展 `tests/test_svc_backup.py`;新增 `tests/test_svc_bundle.py`;扩展 `tests/ops/run_backup_drill.sh` 的不同目标根场景。
|
||||
- **场景**:三库与文件可恢复;备份中断、空间不足、错包、hash 损坏、缺原件、缺必要 secret、镜像不兼容拒绝;目标非空拒绝;不得覆盖生产;重新启动源实例不把未完成包发布为成功;保留原生产身份恢复时,目标接管前必须隔离旧写者;独立克隆从已完成的一致备份恢复,源实例可在备份完成后继续运行,但目标首次启动前必须完成新身份、独立凭据及副作用隔离。
|
||||
- **完成条件**:干净环境恢复完整产品并生成可复核清单;实测备份大小、停写时长和恢复用时。
|
||||
|
||||
### U5.测试/验证克隆与环境选择
|
||||
|
||||
- **需求**:R3/R6/R8,KTD4/KTD6。依赖 U4。
|
||||
- **主要文件**:Dev `scripts/svc.sh` 的 clone/启动接线、`scripts/svc_manifest.py`;Backend/RDM 现有身份、会话和任务控制入口仅作必要接线;测试数据 fixture 与说明。
|
||||
- **做法**:以明确目标环境创建新实例。业务 ID 不改,部署身份、守护与凭据换新;登录会话/API key 不在副本继续有效,账号密码处理按克隆用途明确。需要复制生产敏感数据时先落实访问范围和脱敏策略;常规自动验收优先合成数据。外部密钥、自动调度与恢复任务副作用默认禁用,并在首次启动前生效。
|
||||
- **测试文件**:扩展 `tests/test_svc_identity.py`、`tests/test_svc_lifecycle_isolation.py`;新增 `tests/test_svc_environment_clone.py`。
|
||||
- **场景**:克隆后生产仍正常;新身份不触发原守护冲突;旧 token 无法访问副本;待执行任务不自动发信/调用真实 provider/写生产目录;选择 test 不修改 prod 配置;操作摘要明确目标环境。
|
||||
- **完成条件**:三套环境同机并存,隔离由实际读写与副作用拒绝验证,不靠名称或配置声明。
|
||||
|
||||
### U6.单二进制与组合验收、运维交付
|
||||
|
||||
- **需求**:R1—R8,KTD1—KTD7。依赖 U1—U5。
|
||||
- **主要文件**:Dev `Makefile`、`tests/test_svc_realcombo.py`、`tests/integration/regulations-kb/run.sh`、`components.pin`、`PROJECT.md`、`docs/svc.md`;RDM `scripts/build-product.sh`;各仓必要 runbook/组合指针。
|
||||
- **做法**:固定完整产品与镜像组合,在没有源码及 Go/Node 的机器/隔离环境用同一应用制品启动。把恢复、克隆、数据库容器重启加入组合验证。必要管理操作优先复用同一产品已有入口,离线工具不成为常驻依赖。受影响仓独立验证后再推进 Dev 组合,不重做发布体系。
|
||||
- **场景**:UI/API、正常账号登录、角色边界、项目/会话、RDM 上传/文档原件读取、知识库检索、历史引用解析;两库容器依次重启;新目录恢复;test/verify 重建不影响 prod;客户端制品无 secret。
|
||||
- **完成条件**:满足 Definition of Done;输出生产切换操作单与回滚分界,实测停写时间供用户安排窗口。生产执行属于下面 O1,不由代码测试替代。
|
||||
|
||||
## Verification Contract
|
||||
|
||||
- 本轮是规划:产品测试、真实容器试验、迁移演练、生产盘点/部署均为 **NOT_RUN**;源码核查属于 SELF,不能提升为 GATE 或 REPLAY。
|
||||
- 实施每单元先运行其最小直接检查,容器与文件恢复必须有真实临时环境验证。禁止把缺 DSN 导致的跳过计作通过;真实测试仅指向显式临时环境。
|
||||
- Backend 真 PG 迁移测试使用 `AGENT_PG_TEST_DSN`;法规组合门禁使用 `KBREG_TEST_DATABASE_URL`、精确 RDM/KB pin 及既有 `make test-regulations-kb`。
|
||||
- Dev 治理脚本确有修改时运行 `make test-governance`;最终组合按项目要求执行 `make test`,并以当前 CI 要求核对 `test-pg`、`svc-realcombo` 等检查,不能凭旧清单声称全部通过。
|
||||
- 推进 Dev gitlink 前完成所需组合验证;命令原始输出经 `make evidence` 写任务卡。正式 Review 前重放最小集合、同步权威目标;合入信任来自精确 HEAD 的必需 CI 或独立 REPLAY。
|
||||
- 全量检查只在最终候选或有必要时运行;发现无关非阻塞问题不扩大本次任务。
|
||||
|
||||
## O1.生产迁移与回滚计划(另行授权执行)
|
||||
|
||||
1. **确认源与窗口**:记录实际源实例、三库/文件清单、schema、版本、容量、现有身份、备份位置及授权范围;确认恢复演练达标和可接受停写时间。
|
||||
2. **提前准备目标**:拉取固定镜像、准备空库/角色/持久目录、验证磁盘与网络;用演练副本验证完整应用制品。此阶段不改生产连接。
|
||||
3. **冻结生产写入**:暂停入口及所有后台/外部写者,排空或隔离在途工作,确认旧实例不会被 supervisor 自动拉起;记录已确认写入的对账点。
|
||||
4. **最终备份与迁移**:按 U3/U4 生成最终完整包并恢复目标;核对所有必需数据与文件,运行只读业务验证;保持外部副作用关闭。
|
||||
5. **受控切换**:以迁移操作更新目标绑定、身份和文件路径;先验证登录/权限/文档/知识库,再开放写入及经确认的后台任务。避免两个生产写者同时存在。
|
||||
6. **观察与保留**:保留旧数据及最终备份,只读保护并记录新系统写入开始时间;到达业务验收及约定保留期后才单独安排旧资源回收。
|
||||
|
||||
回滚分界必须写入操作单:
|
||||
|
||||
- **新目标尚无业务写入**:停止新实例,确认无新数据后恢复旧配置/旧写者;不可让双方同时写。
|
||||
- **新目标已有业务写入**:停止写入并保存新数据,禁止简单切回旧库;评估向前修复或经过验证的数据对账/回迁。未经确认不丢弃新写入。
|
||||
- **schema 已改变**:旧应用制品不一定兼容。代码回滚、数据库恢复和业务数据回迁分别验证,不能用“换回旧二进制”代替恢复方案。
|
||||
|
||||
## Definition of Done
|
||||
|
||||
代码交付完成须同时满足:
|
||||
|
||||
- 一个完整应用服务二进制在目标运行环境启动,业务/RDM/KB 数据存储符合两容器三逻辑库边界。
|
||||
- prod/test/verify 可独立启动、停止、重建与选择,配置/数据/身份/权限隔离已验证。
|
||||
- 完整备份可以恢复到不同根目录;原文可读、业务引用和历史法规引用不变;缺失数据时明确失败。
|
||||
- 真实迁移 fixture 对账通过,重启与容器重建不丢数据,失败不会回退空系统或启动外部副作用。
|
||||
- 精确组件组合、测试证据、独立 Review、所需 CI 及规范文档齐全;没有遗留试验实现进入最终 diff。
|
||||
- O1 的执行另记:代码完成不等于生产迁移完成,恢复演练也不等于业务验收。
|
||||
|
||||
## 顺序与建议里程碑
|
||||
|
||||
| 里程碑 | 工作 | 出口 |
|
||||
|---|---|---|
|
||||
| M1 最小可运行 | U1 → U2 | 同一应用连接两容器三库,test/verify 并存,持久化与错误绑定验证通过 |
|
||||
| M2 可迁移可恢复 | U3 → U4 | 完整数据恢复到新目录,原文和历史引用可核对 |
|
||||
| M3 可安全切环境 | U5 → U6 | 克隆隔离、完整产品组合验证、操作说明齐备 |
|
||||
| M4 生产落地 | O1 | 获得窗口授权后迁移、业务验收,旧数据按约定保留 |
|
||||
|
||||
U1 完成后再按实际 schema 覆盖、文件规模及演练用时细化工期;当前不把未测量的数据量换算为承诺日期。每个里程碑只处理阻塞目标的问题。
|
||||
|
||||
## 外部依据与待核验项
|
||||
|
||||
本轮尝试读取 Docker Compose、PostgreSQL 备份与 pgvector 官方文档;网络读取不完整。成功获取 Docker Official Image 的 PostgreSQL 说明(docker-library/docs 的 postgres/README.md),其中确认 PGDATA/卷位置随主版本变化、初始化参数仅对新数据目录生效、密码支持文件注入。这些仅作为镜像适配约束,未确定最终镜像 digest,也未验证某个部署平台。
|
||||
|
||||
实施 U2 前需针对所选精确版本再次核对 Compose/镜像/pgvector 官方资料,并做真实冒烟;失败则阻止该镜像采用,不阻止本方案的源码设计工作。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 独立 Review:容器化存储 U1(环境与数据归属合同)
|
||||
|
||||
- 日期:2026-10-22
|
||||
- 审阅对象:`feature/container-storage-plan-u1` 分支 HEAD
|
||||
- base:`955a32de2bb49171348bad5f566cce935df19212`
|
||||
- head:`e1f08650a12656611f27438f23fddbfab6a96299`(分支尖端;实施内容经 agent-exec transport 提交 `2d75d101a04ce1d1c9fa1d66d9567894222399d5`,另有路由调整 transport 提交 `0c8bea37` 与任务卡记录提交)
|
||||
- 送审 diff:`/tmp/container-storage-u1-review.diff`(已核对与 `git diff 955a32de..HEAD` 的代码部分逐字一致;仅 `.task/**` 任务卡不在 diff 内,属 host 直写豁免区)
|
||||
- 方案依据:`docs/superpowers/plans/2026-10-22-container-storage-plan.md` U1 小节、KTD1—KTD4
|
||||
- 审阅方式:全新会话独立审阅(非实施者);实际读代码核对绑定解析、拒绝路径与身份保留逻辑;未实施任何修复。
|
||||
|
||||
## 总体判定:REVIEW PASS
|
||||
|
||||
无阻塞项。U1 验收场景(旧配置升级保留身份、错库/错根/foreign marker 拒绝、同名项目或冲突端口修改前失败、另一 checkout 定位同一资源、缺必需绑定不静默新建空库)均有实现且被测试覆盖;KTD3 单一权威合同成立;无秘密入仓;测试 80/80 通过(1 项真实 PG 专项显式 NOT_RUN)。下列非阻塞项建议在 U2 及最终交付前处理。
|
||||
|
||||
## 阻塞项
|
||||
|
||||
无。
|
||||
|
||||
## 非阻塞项
|
||||
|
||||
| # | 级别 | 位置 | 发现 | 建议 |
|
||||
|---|---|---|---|---|
|
||||
| N1 | 建议 | `scripts/svc.sh` `svc_set_key`/`svc_del_key`(4674/4691 行起) | manifest `set` 因绑定校验或冲突被拒(exit 2/3/7)时,回退路径仍把该键写入派生视图 `svc.env`,整体操作"成功"返回。权威 manifest 未被污染(`resolve_storage_bindings` 只读 manifest),且两种漂移方向在启动时均 fail-closed(`svc.env=container`/`manifest=legacy` → `verify --require-storage` 触发 `完整存储合同要求 storage_mode=container` 拒启;反向漂移由 record_state 重建自愈),但"冲突在修改前失败"的语义被弱化为"权威拒绝+视图静默写入"。当前无任何调用方经 `svc_set_key` 写存储键(调用点仅 autostart/exec/dbref/dsn/conffile/addr/tags),风险处于潜伏状态。 | 存储键(STORAGE_KEYS 集合)在 manifest set 失败时硬失败,不走 legacy 单键回退。 |
|
||||
| N2 | 建议 | `scripts/svc.sh` `svc_manifest_verify`(1302 行) | container 分支门控用 `svc_get storage_mode`(读派生 `svc.env`)而非 manifest identity。manifest=container 而 svc.env 过期(manifest 与 svc.env 两次原子写之间的崩溃窗口,或绕过 svc 手改 manifest)时,该次启动跳过存储校验;下次 record_state 经 bulk-set 重新派生后自愈。窗口窄且可自愈,但与"manifest 为权威"的门控意图不一致。 | 门控改为读 manifest identity 的 `storage_mode`(或在检测到两处不一致时拒绝并提示重建派生视图)。 |
|
||||
| N3 | 建议 | `deploy/data-ownership.md`(持久文件根行) | 声明"禁止他实例共用同一根",但 `check_conflicts` 只扫描 compose 项目名与端口,不检测跨实例 files_root 重叠/嵌套;测试夹具(precheck `test_compose_project_conflict_refused`)中 b 的根位于 a 的根之下,同样未拦截。无 U1 验收场景要求此项,属文档强于实现。 | 收紧措辞为"登记层不拦截,容器挂载层(U2)负责隔离",或在 U2 增加跨实例根重叠预检。 |
|
||||
| N4 | 记录 | `scripts/svc_manifest.py` `check_conflicts` | 冲突扫描范围限定同一 runtime 目录(`RUNTIME="$ROOT/runtime"`,`--runtime` 显式传入)。同机跨 checkout 的多个 runtime 目录之间的 compose 项目名/端口冲突在 U1 登记层不可见,实际碰撞推迟到 U2 容器启动时由端口占用/compose 报错暴露。实现注释与文档均按"同一 runtime"表述,范围自洽;U2 场景"prod/test/verify 同时运行互不冲突"已承接此项。 | U2 落地容器驱动时复核跨 runtime 同机冲突的最终防线。 |
|
||||
| N5 | 建议 | `tests/` | 两个覆盖缺口:(a) 无 container 模式实例完整 start/stop 生命周期测试——`preserve_storage_keys`(record_state → bulk-set 写回时保住存储键)与 svc.sh 传 `--runtime` 的改动未被端到端 exercised,若该逻辑回归,绑定会在一次生命周期写回后被静默清掉;(b) `test_CS_U1_missing_binding_rejected` 只覆盖"全部缺失",未覆盖"三库中任一缺失"(如 base/rdm 齐备仅 kb 缺失)的逐槽拒绝。 | 各补一条测试:container 实例启停后锚点与 kv 绑定不变;逐槽缺失各断言一次。 |
|
||||
| N6 | 记录 | `AGENT-EXECUTION.md`(implementation 角色顺序) | 不在 U1 单元 allowed_paths 内,属越界文件变更;但它是独立登记的路由单元(任务卡行 `container-storage-routing-promote-antigravity`,transport `0c8bea37`,收据 `/tmp/container-storage-routing-receipt2.json`),披露完整,且变更注释自述"临时调整,交付前回退"。 | 按其自述在 U1 合入/交付前回退;不阻塞 U1 评审结论。 |
|
||||
| N7 | 记录 | `scripts/svc_manifest.py` | `slot_port_of` 只校验 `isdigit()`,接受 `0` 与超范围数值,未限 1—65535。 | 顺手项,U2 接真实容器端口发布前收紧即可。 |
|
||||
| N8 | 记录 | `scripts/svc_manifest.py` `cmd_verify` | container 启动路径把 `resolve_storage_bindings`(源自 manifest)的值回传给 `verify` 比对,本质是 identity↔kv 自一致性检查(可拦手改 manifest 的不一致),并非运行时"连到外宾库"检测;后者按方案属 U2(启动顺序真实认证/身份检查)与 U3(库内身份)范围。U1 的"错库拒绝"在绑定锚点层面成立(`base 库绑定漂移` 路径有测试),范围声明准确。 | 无需改动;U2 接线时保留本检查作为快速失败层。 |
|
||||
|
||||
## 核对点(实际读代码/跑测试核对)
|
||||
|
||||
1. **旧配置升级保留身份**:`build()` 对已带 storage 键的 kv 只做形态校验(`require_complete=(mode==container)`),不重建 instance_id;`test_CS_U1_legacy_upgrade_keeps_identity` 断言升级后 `instance_id`/`data_root` 不变、锚点落 identity。✅
|
||||
2. **身份保留/重锚**:`reanchor(write=False)` 预览仅用于冲突扫描、不落 marker;`mutate` 顺序为"校验 → 冲突扫描 → 重锚 → 落盘",冲突时不落盘;`slot_ref_of` 冲突(dbref 与 dsn_ref 同设)、相对路径 `*_dsn_ref`/`*_role_ref`/`files_root` 均拒绝;legacy→container 升级基座可回落既有 `dbref=` 专键,明文 `dsn=` 不进锚点(I5)。✅
|
||||
3. **拒绝路径**:container 缺任一必需绑定在 set(`validate_storage_kv` exit 3)与 verify(`--require-storage` + `require_complete=True`)双端拒绝;错文件根/错库绑定漂移 exit 6(测试断言"文件根"/"base 库绑定漂移");foreign marker 沿用 base 逻辑(T5b);compose 项目名/端口冲突 exit 7 且发生在落盘前(`check_conflicts` 在 `save` 之前)。✅
|
||||
4. **不静默新建空库**:U1 层无任何默认绑定生成;`resolve_storage_bindings` 缺绑定 exit 3 → svc.sh 拒绝;manifest 缺失时 verify exit 4、marker 缺失/损坏 exit 5,均阻塞启动。未发现静默回退空库路径。✅
|
||||
5. **另一 checkout 定位同一资源**:`files_root` 强制绝对路径并 normpath,锚点/解析不依赖 cwd 与仓库根;`test_CS_U1_other_checkout_keeps_absolute_files_root` 与 T6 从外部 cwd 调用验证。✅
|
||||
6. **调用方环境污染**:`env_config` 重置存储全局并 `unset AGENT_SYSTEM_FILES_ROOT/ENV_CLASS/COMPOSE_PROJECT/BASE_DB_REF/RDM_DB_REF/KB_DB_REF`;`test_caller_storage_env_does_not_reach_serve` 检查 serve 进程 environ 与 serve.env;三条棘轮测试守卫 env_config。✅
|
||||
7. **KTD3 单一权威**:`resolve_storage_bindings` 内嵌 python 直读 `manifest.json`(不读 svc.env、不读调用方 env);U1 未引入 compose 文件/第二份卷或 DSN 权威;凭据仅存受保护文件路径引用。`svc.env` 仍是派生只读视图(record_state 不产出存储键,`preserve_storage_keys` 自 manifest 旧 kv 保留)。✅
|
||||
8. **归属清单与契约文档**:`deploy/data-ownership.md` 覆盖基座/RDM/KB/文件根/环境身份/实例身份/Compose 项目七类所有者,含引用形式与唯一写者;体量/源版本/表清单明示"需另行授权访问后补全"(与方案"生产盘点需先获得对应访问授权"及诚实出口一致);无秘密值。`docs/svc.md` §10.1 键表与 `PROJECT.md` 指针已同步;链接目标 `docs/recovery/u1-data-ownership.md`、`docs/recovery/u1-identity-manifest.md` 均存在。✅
|
||||
9. **测试充分性**:三测试文件合计覆盖全部五条 U1 验收场景 + 调用方隔离 + 原子写崩溃窗口;缺口仅 N5 两项(均非验收场景直接要求)。`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck` → **80 tests, OK (skipped=1)**,唯一 skip 为真实 PG 专项显式 NOT_RUN(诚实标注)。✅
|
||||
10. **秘密与越界**:diff 无明文口令/DSN/token(示例 env 仅受保护路径,测试仅 fixture 值 `ref-only`/`x`);changed files 中除 `AGENT-EXECUTION.md`(见 N6,已另行登记)外全部落在 U1 主要文件/allowed_paths 内;`.task/**` 为 host 直写豁免。✅
|
||||
|
||||
## 复现命令
|
||||
|
||||
```bash
|
||||
# 1) 送审 diff 范围与实际 HEAD diff 一致(代码部分逐字相同)
|
||||
git diff 955a32de2bb49171348bad5f566cce935df19212 HEAD \
|
||||
-- AGENT-EXECUTION.md PROJECT.md deploy docs/superpowers/plans docs/svc.md scripts tests \
|
||||
| diff - /tmp/container-storage-u1-review.diff && echo IDENTICAL
|
||||
|
||||
# 2) 单元测试(本机实测:Ran 80 tests, OK (skipped=1),约 64s)
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \
|
||||
tests.test_svc_identity tests.test_svc_lifecycle_isolation tests.test_svc_precheck
|
||||
|
||||
# 3) 对象 SHA
|
||||
git rev-parse HEAD # e1f08650a12656611f27438f23fddbfab6a96299
|
||||
git rev-parse 955a32de # 955a32de2bb49171348bad5f566cce935df19212
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
U1 单元按方案完成且质量合格:绑定/身份权威唯一、拒绝路径完整且 fail-closed、验收场景全部落地并有测试背书。上述 N1—N8 均不阻塞本单元交付;N1/N2/N5 建议随 U2 或合入前收口,N6 的路由临时调整须按其自述在交付前回退。
|
||||
+12
@@ -866,9 +866,21 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
|
||||
| `dbref` | agent 托管数据库绑定 `<pg名>/<库名>`(启动时由实例登记重建 DSN;见 §4.5) |
|
||||
| `dsn` | agent 外部直连 DSN(受保护;未命中本机实例登记的直连存放于此) |
|
||||
| `conffile` | agent 配置文件绝对路径(`<name> config` 命令写入,跨重启保留;见 §4.6) |
|
||||
| `storage_mode` | `legacy`(缺省,SQLite/宿主 PG)/ `container`(三库+文件根合同;见 [deploy/data-ownership.md](../deploy/data-ownership.md)) |
|
||||
| `env_class` | 环境身份:`prod` / `test` / `verify`(container 必需) |
|
||||
| `files_root` | 持久文件根**绝对路径**(不相对 checkout 解析;container 必需) |
|
||||
| `compose_project` | Compose 项目名(同 runtime 唯一;冲突在修改前拒绝) |
|
||||
| `base_dbref` / `base_dsn_ref` / `base_role_ref` / `base_port` | 基座逻辑库绑定(dsn/role 仅为受保护文件路径引用) |
|
||||
| `rdm_dbref` / `rdm_dsn_ref` / `rdm_role_ref` / `rdm_port` | RDM 领域库绑定 |
|
||||
| `kb_dbref` / `kb_dsn_ref` / `kb_role_ref` / `kb_port` | 知识库绑定 |
|
||||
| `user` / `password` / `dbname` / `authmode` / `pgdata` | postgres 专属 |
|
||||
| `htdocs` | static 专属 |
|
||||
|
||||
容器存储绑定由 `manifest.json` 冻结为身份锚点(与 `data_root`/`db_*` 同级);旧配置
|
||||
升级保留 `instance_id`。`storage_mode=container` 时缺任一必需绑定、错库、错文件根、
|
||||
foreign marker、同名 Compose 或端口冲突均拒绝启动/修改,不静默建空库、不回退默认。
|
||||
示例键见 [`deploy/env-bindings.example.env`](../deploy/env-bindings.example.env)。
|
||||
|
||||
### 10.2 history.jsonl 事件
|
||||
|
||||
一行一个 JSON:`ts / event / env / kind / source / result / pid / addr / detail`。
|
||||
|
||||
+103
-10
@@ -1012,8 +1012,9 @@ record_state() {
|
||||
chmod 600 "$tmp"
|
||||
# U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链,
|
||||
# revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。
|
||||
# 存储绑定键(STORAGE_KEYS)由 manifest bulk-set 自旧 kv 保留,避免生命周期写回冲掉。
|
||||
if manifest_ok; then
|
||||
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then
|
||||
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set --runtime "$RUNTIME" < "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
@@ -1217,18 +1218,103 @@ svc_manifest_migrate() {
|
||||
c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)"
|
||||
return 0
|
||||
}
|
||||
# resolve_storage_bindings:从本实例 manifest 权威解析容器存储绑定(不读调用方环境)。
|
||||
# 结果全局:STORAGE_MODE ENV_CLASS FILES_ROOT COMPOSE_PROJECT
|
||||
# BASE_DB_REF RDM_DB_REF KB_DB_REF BASE_PORT RDM_PORT KB_PORT
|
||||
# storage_mode=container 且缺任一必需绑定时返回 1(拒绝静默默认/建空库);
|
||||
# legacy / 未配置返回 0 且 STORAGE_MODE=legacy。
|
||||
resolve_storage_bindings() {
|
||||
STORAGE_MODE=legacy; ENV_CLASS=""; FILES_ROOT=""; COMPOSE_PROJECT=""
|
||||
BASE_DB_REF=""; RDM_DB_REF=""; KB_DB_REF=""
|
||||
BASE_PORT=""; RDM_PORT=""; KB_PORT=""
|
||||
manifest_ok || return 0
|
||||
local out rc=0
|
||||
out="$(python3 - "$ENV_DIR" <<'PY' 2>&1
|
||||
import json, os, sys
|
||||
env_dir = sys.argv[1]
|
||||
path = os.path.join(env_dir, "manifest.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
m = json.load(f)
|
||||
ident = m.get("identity") or {}
|
||||
kv = m.get("kv") or {}
|
||||
mode = (kv.get("storage_mode") or ident.get("storage_mode") or "legacy").strip() or "legacy"
|
||||
print("MODE=%s" % mode)
|
||||
print("ENV_CLASS=%s" % (kv.get("env_class") or ident.get("env_class") or ""))
|
||||
print("FILES_ROOT=%s" % (kv.get("files_root") or ident.get("files_root") or ""))
|
||||
print("COMPOSE_PROJECT=%s" % (kv.get("compose_project") or ident.get("compose_project") or ""))
|
||||
print("BASE_DB_REF=%s" % (ident.get("base_db_ref") or ""))
|
||||
print("RDM_DB_REF=%s" % (ident.get("rdm_db_ref") or ""))
|
||||
print("KB_DB_REF=%s" % (ident.get("kb_db_ref") or ""))
|
||||
print("BASE_PORT=%s" % (kv.get("base_port") or ident.get("base_port") or ""))
|
||||
print("RDM_PORT=%s" % (kv.get("rdm_port") or ident.get("rdm_port") or ""))
|
||||
print("KB_PORT=%s" % (kv.get("kb_port") or ident.get("kb_port") or ""))
|
||||
if mode == "container":
|
||||
missing = []
|
||||
if (kv.get("env_class") or ident.get("env_class") or "") not in ("prod", "test", "verify"):
|
||||
missing.append("env_class")
|
||||
fr = kv.get("files_root") or ident.get("files_root") or ""
|
||||
if not fr or not os.path.isabs(fr):
|
||||
missing.append("files_root")
|
||||
if not (kv.get("compose_project") or ident.get("compose_project") or ""):
|
||||
missing.append("compose_project")
|
||||
for slot, key in (("base", "base_db_ref"), ("rdm", "rdm_db_ref"), ("kb", "kb_db_ref")):
|
||||
if not (ident.get(key) or ""):
|
||||
missing.append(slot + "_db")
|
||||
if not (kv.get(slot + "_port") or ident.get(slot + "_port") or ""):
|
||||
missing.append(slot + "_port")
|
||||
if not (kv.get(slot + "_role_ref") or ""):
|
||||
missing.append(slot + "_role_ref")
|
||||
if missing:
|
||||
print("MISSING=%s" % ",".join(missing), file=sys.stderr)
|
||||
sys.exit(3)
|
||||
PY
|
||||
)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
c_err "[$ENV_NAME] 存储绑定不完整或无效,拒绝(不静默新建空库/回退默认):$(printf '%s' "$out" | tail -1)"
|
||||
return 1
|
||||
fi
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
MODE=*) STORAGE_MODE="${line#MODE=}" ;;
|
||||
ENV_CLASS=*) ENV_CLASS="${line#ENV_CLASS=}" ;;
|
||||
FILES_ROOT=*) FILES_ROOT="${line#FILES_ROOT=}" ;;
|
||||
COMPOSE_PROJECT=*) COMPOSE_PROJECT="${line#COMPOSE_PROJECT=}" ;;
|
||||
BASE_DB_REF=*) BASE_DB_REF="${line#BASE_DB_REF=}" ;;
|
||||
RDM_DB_REF=*) RDM_DB_REF="${line#RDM_DB_REF=}" ;;
|
||||
KB_DB_REF=*) KB_DB_REF="${line#KB_DB_REF=}" ;;
|
||||
BASE_PORT=*) BASE_PORT="${line#BASE_PORT=}" ;;
|
||||
RDM_PORT=*) RDM_PORT="${line#RDM_PORT=}" ;;
|
||||
KB_PORT=*) KB_PORT="${line#KB_PORT=}" ;;
|
||||
esac
|
||||
done <<<"$out"
|
||||
return 0
|
||||
}
|
||||
|
||||
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
|
||||
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
|
||||
# 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate)
|
||||
# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备
|
||||
# identity 能力的产物更新时由候选门禁补齐)。
|
||||
# 顺序:文件 marker →(container)存储绑定完整/无漂移 → DB verify(缺/错即拦)。
|
||||
# ensure 只在领养(svc_manifest_migrate)与显式 init——普通启动不建身份,缺行即事件。
|
||||
svc_manifest_verify() {
|
||||
manifest_ok || return 0
|
||||
local out
|
||||
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \
|
||||
--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then
|
||||
local out verify_args storage_mode
|
||||
verify_args=(--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}")
|
||||
storage_mode="$(svc_get storage_mode)"
|
||||
if [ "$storage_mode" = container ]; then
|
||||
# 生效值只来自本实例登记(env_config 已剥离调用方污染);缺绑/错根/错库拒绝。
|
||||
resolve_storage_bindings || return 1
|
||||
verify_args+=(--require-storage
|
||||
--files-root "$FILES_ROOT" --env-class "$ENV_CLASS"
|
||||
--compose-project "$COMPOSE_PROJECT"
|
||||
--base-db-ref "$BASE_DB_REF" --rdm-db-ref "$RDM_DB_REF" --kb-db-ref "$KB_DB_REF")
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" check-conflicts --runtime "$RUNTIME" >/dev/null || {
|
||||
c_err "[$ENV_NAME] 存储绑定冲突,拒绝启动(Compose 项目名或端口与他实例冲突)"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify "${verify_args[@]}" 2>&1)"; then
|
||||
c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)"
|
||||
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
|
||||
c_err " 数据根/数据库/文件根绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5;容器存储见 deploy/data-ownership.md)"
|
||||
return 1
|
||||
fi
|
||||
# 文件校验通过后再碰库。fresh 允许库尚未建立;established 只验证;
|
||||
@@ -1704,12 +1790,18 @@ env_config() {
|
||||
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
|
||||
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
|
||||
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
|
||||
STORAGE_MODE=""; ENV_CLASS=""; FILES_ROOT=""; COMPOSE_PROJECT=""
|
||||
BASE_DB_REF=""; RDM_DB_REF=""; KB_DB_REF=""
|
||||
BASE_PORT=""; RDM_PORT=""; KB_PORT=""
|
||||
# U1:身份环境同样按实例重置——上一服务 verify 成功后导出的
|
||||
# AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析;
|
||||
# 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令
|
||||
# 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify
|
||||
# 写入的身份。
|
||||
# 容器存储 U1:调用方/上一服务的文件根、环境身份、Compose 与三库引用不得污染本实例。
|
||||
unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF
|
||||
unset AGENT_SYSTEM_FILES_ROOT AGENT_SYSTEM_ENV_CLASS AGENT_SYSTEM_COMPOSE_PROJECT
|
||||
unset AGENT_SYSTEM_BASE_DB_REF AGENT_SYSTEM_RDM_DB_REF AGENT_SYSTEM_KB_DB_REF
|
||||
local registered=""
|
||||
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
|
||||
SVC_KIND="${registered:-agent}"
|
||||
@@ -4578,10 +4670,11 @@ check_port_available() {
|
||||
|
||||
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键。manifest 治理下经 manifest
|
||||
# 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。
|
||||
# 存储绑定键变更经 --runtime 做 Compose/端口冲突预检(修改前失败)。
|
||||
svc_set_key() {
|
||||
local key="$1" val="$2" rest tmp
|
||||
if manifest_ok; then
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" set --runtime "$RUNTIME" "$key" "$val" && return 0
|
||||
c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写"
|
||||
fi
|
||||
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
|
||||
@@ -4598,7 +4691,7 @@ svc_set_key() {
|
||||
svc_del_key() {
|
||||
local key="$1"
|
||||
if manifest_ok; then
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0
|
||||
python3 "$MANIFEST_PY" "$ENV_DIR" del --runtime "$RUNTIME" "$key" && return 0
|
||||
c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写"
|
||||
fi
|
||||
[ -f "$ENV_DIR/svc.env" ] || return 0
|
||||
|
||||
+399
-36
@@ -1,16 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""svc 实例身份 manifest(U1):manifest.json 为启动权威,svc.env 为派生只读视图。
|
||||
|
||||
设计合同:docs/recovery/u1-identity-manifest.md(已批准 2026-10-12)。
|
||||
设计合同:docs/recovery/u1-identity-manifest.md(已批准 2026-10-12);
|
||||
容器化存储扩展:docs/superpowers/plans/2026-10-22-container-storage-plan.md U1/KTD1—KTD4。
|
||||
|
||||
落地说明:v1.0 以「identity 锚点(数据根/数据库身份,冻结)+ kv 权威键值映射 +
|
||||
desired 期望态」承载 KTD1/KTD2 的全部不变量;完整 typed 分区随 U2/U6 演进。
|
||||
desired 期望态」承载不变量;本文件在同一权威上扩展三库/文件根/环境身份绑定,
|
||||
不另造第二份登记权威。完整 typed 分区与容器启停属后续单元。
|
||||
|
||||
用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]
|
||||
用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify|check-conflicts> [参数]
|
||||
|
||||
不变量(对应设计 §1):
|
||||
不变量(对应设计 §1 + 容器存储 U1):
|
||||
I3 原子性 —— 整份 temp→fsync→rename→dirsync,任意时刻只有完整旧版或新版。
|
||||
I4 阻塞 —— manifest 存在时,数据 marker 缺失/身份不符即拒绝,不新建空系统。
|
||||
I5 机密 —— 本文件不新增任何机密;kv 沿袭 svc.env 既有内容(DSN 迁出属 U6)。
|
||||
I5 机密 —— 凭据只存受保护文件路径引用(*_dsn_ref / *_role_ref),不存明文。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -22,7 +25,27 @@ SCHEMA = 1
|
||||
MANIFEST = "manifest.json"
|
||||
DERIVED = "svc.env"
|
||||
MARKER_REL = os.path.join(".agentx", "data-marker.json")
|
||||
PROTECTED = ("db", "dbref", "dsn", "conffile") # 显式持久化写入可重锚;启动期漂移由 verify 拦截
|
||||
|
||||
# 显式持久化写入可重锚;启动期漂移由 verify 拦截。含容器存储绑定键。
|
||||
PROTECTED = (
|
||||
"db", "dbref", "dsn", "conffile",
|
||||
"storage_mode", "env_class", "files_root", "compose_project",
|
||||
"base_dbref", "base_dsn_ref", "base_role_ref", "base_port",
|
||||
"rdm_dbref", "rdm_dsn_ref", "rdm_role_ref", "rdm_port",
|
||||
"kb_dbref", "kb_dsn_ref", "kb_role_ref", "kb_port",
|
||||
)
|
||||
|
||||
# record_state/bulk-set 重建时须保留的存储绑定键(避免被生命周期写回冲掉)。
|
||||
STORAGE_KEYS = (
|
||||
"storage_mode", "env_class", "files_root", "compose_project",
|
||||
"base_dbref", "base_dsn_ref", "base_role_ref", "base_port",
|
||||
"rdm_dbref", "rdm_dsn_ref", "rdm_role_ref", "rdm_port",
|
||||
"kb_dbref", "kb_dsn_ref", "kb_role_ref", "kb_port",
|
||||
)
|
||||
|
||||
ENV_CLASSES = ("prod", "test", "verify")
|
||||
DB_SLOTS = ("base", "rdm", "kb")
|
||||
STORAGE_MODES = ("legacy", "container")
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
@@ -116,6 +139,144 @@ def db_anchor_of(kv):
|
||||
return "", ""
|
||||
|
||||
|
||||
def storage_mode_of(kv):
|
||||
mode = (kv.get("storage_mode") or "").strip() or "legacy"
|
||||
if mode not in STORAGE_MODES:
|
||||
die("storage_mode 仅允许 legacy|container,收到:%s" % mode, 2)
|
||||
return mode
|
||||
|
||||
|
||||
def files_root_of(kv):
|
||||
"""持久文件根:必须已是绝对路径;绝不相对 checkout/worktree 解析。"""
|
||||
fr = (kv.get("files_root") or "").strip()
|
||||
if not fr:
|
||||
return ""
|
||||
if not os.path.isabs(fr):
|
||||
die("files_root 必须是绝对路径(不相对 checkout 解析):%s" % fr, 2)
|
||||
return os.path.normpath(fr)
|
||||
|
||||
|
||||
def slot_ref_of(kv, slot):
|
||||
"""逻辑库引用锚点:优先 dbref,否则 dsn_ref(受保护文件路径);明文 dsn 禁止。"""
|
||||
dbref = (kv.get("%s_dbref" % slot) or "").strip()
|
||||
dsn_ref = (kv.get("%s_dsn_ref" % slot) or "").strip()
|
||||
if dbref and dsn_ref:
|
||||
die("%s 绑定冲突:不可同时设置 %s_dbref 与 %s_dsn_ref" % (slot, slot, slot), 2)
|
||||
if dbref:
|
||||
return "dbref:%s" % dbref
|
||||
if dsn_ref:
|
||||
if not os.path.isabs(dsn_ref):
|
||||
die("%s_dsn_ref 必须是绝对路径(秘密文件引用):%s" % (slot, dsn_ref), 2)
|
||||
return "dsn_ref:%s" % os.path.normpath(dsn_ref)
|
||||
# 基座可回落既有 dbref/dsn 专键(legacy→container 升级兼容,dsn 明文不进锚点)
|
||||
if slot == "base":
|
||||
legacy = (kv.get("dbref") or "").strip()
|
||||
if legacy:
|
||||
return "dbref:%s" % legacy
|
||||
# 明文 dsn= 不是引用;container 模式不得用其充当绑定
|
||||
return ""
|
||||
|
||||
|
||||
def slot_port_of(kv, slot):
|
||||
return (kv.get("%s_port" % slot) or "").strip()
|
||||
|
||||
|
||||
def slot_role_ref_of(kv, slot):
|
||||
role = (kv.get("%s_role_ref" % slot) or "").strip()
|
||||
if not role:
|
||||
return ""
|
||||
if not os.path.isabs(role):
|
||||
die("%s_role_ref 必须是绝对路径(角色秘密文件引用):%s" % (slot, role), 2)
|
||||
return os.path.normpath(role)
|
||||
|
||||
|
||||
def validate_storage_kv(kv, require_complete=None):
|
||||
"""校验存储绑定形态。container 模式缺任一必需绑定即拒绝(不静默默认/建空库)。"""
|
||||
mode = storage_mode_of(kv)
|
||||
if require_complete is None:
|
||||
require_complete = mode == "container"
|
||||
env_class = (kv.get("env_class") or "").strip()
|
||||
if env_class and env_class not in ENV_CLASSES:
|
||||
die("env_class 仅允许 prod|test|verify,收到:%s" % env_class, 2)
|
||||
files_root = files_root_of(kv) # 相对路径在此失败
|
||||
compose = (kv.get("compose_project") or "").strip()
|
||||
if compose and (("/" in compose) or (".." in compose) or (" " in compose)):
|
||||
die("compose_project 非法(禁止路径分隔/空格):%s" % compose, 2)
|
||||
for slot in DB_SLOTS:
|
||||
port = slot_port_of(kv, slot)
|
||||
if port and not port.isdigit():
|
||||
die("%s_port 必须是正整端口号,收到:%s" % (slot, port), 2)
|
||||
slot_ref_of(kv, slot) # 冲突/相对路径检查
|
||||
slot_role_ref_of(kv, slot)
|
||||
if not require_complete:
|
||||
return mode
|
||||
# container 完整合同
|
||||
if mode != "container":
|
||||
die("完整存储合同要求 storage_mode=container", 2)
|
||||
if env_class not in ENV_CLASSES:
|
||||
die("container 模式缺少必需绑定 env_class(prod|test|verify)", 3)
|
||||
if not files_root:
|
||||
die("container 模式缺少必需绑定 files_root(绝对路径)", 3)
|
||||
if not compose:
|
||||
die("container 模式缺少必需绑定 compose_project", 3)
|
||||
for slot in DB_SLOTS:
|
||||
ref = slot_ref_of(kv, slot)
|
||||
port = slot_port_of(kv, slot)
|
||||
role = slot_role_ref_of(kv, slot)
|
||||
if not ref:
|
||||
die("container 模式缺少必需绑定 %s 库(%s_dbref 或 %s_dsn_ref)"
|
||||
% (slot, slot, slot), 3)
|
||||
if not port:
|
||||
die("container 模式缺少必需绑定 %s_port" % slot, 3)
|
||||
if not role:
|
||||
die("container 模式缺少必需绑定 %s_role_ref(角色秘密文件路径)" % slot, 3)
|
||||
return mode
|
||||
|
||||
|
||||
def storage_anchor_of(kv):
|
||||
"""从 kv 计算冻结的存储身份锚点(写入 identity;legacy 为空串)。"""
|
||||
mode = storage_mode_of(kv)
|
||||
if mode == "legacy" and not any(kv.get(k) for k in STORAGE_KEYS if k != "storage_mode"):
|
||||
return {
|
||||
"storage_mode": "legacy",
|
||||
"env_class": "",
|
||||
"files_root": "",
|
||||
"compose_project": "",
|
||||
"base_db_ref": "",
|
||||
"base_port": "",
|
||||
"rdm_db_ref": "",
|
||||
"rdm_port": "",
|
||||
"kb_db_ref": "",
|
||||
"kb_port": "",
|
||||
}
|
||||
# 非完整 container 时仍记录已显式写入的锚点(供漂移检测);完整校验另走 validate
|
||||
if mode == "container":
|
||||
validate_storage_kv(kv, require_complete=True)
|
||||
else:
|
||||
validate_storage_kv(kv, require_complete=False)
|
||||
return {
|
||||
"storage_mode": mode,
|
||||
"env_class": (kv.get("env_class") or "").strip(),
|
||||
"files_root": files_root_of(kv),
|
||||
"compose_project": (kv.get("compose_project") or "").strip(),
|
||||
"base_db_ref": slot_ref_of(kv, "base"),
|
||||
"base_port": slot_port_of(kv, "base"),
|
||||
"rdm_db_ref": slot_ref_of(kv, "rdm"),
|
||||
"rdm_port": slot_port_of(kv, "rdm"),
|
||||
"kb_db_ref": slot_ref_of(kv, "kb"),
|
||||
"kb_port": slot_port_of(kv, "kb"),
|
||||
}
|
||||
|
||||
|
||||
def preserve_storage_keys(new_kv, prev_kv):
|
||||
"""bulk-set/生命周期写回时保留未出现在新写集中的存储绑定键。"""
|
||||
out = dict(new_kv)
|
||||
for k in STORAGE_KEYS:
|
||||
if k not in out and k in prev_kv:
|
||||
out[k] = prev_kv[k]
|
||||
return out
|
||||
|
||||
|
||||
def marker_path(data_root):
|
||||
return os.path.join(data_root, MARKER_REL)
|
||||
|
||||
@@ -140,13 +301,18 @@ def read_marker(data_root):
|
||||
return {"schema": -1}
|
||||
|
||||
|
||||
def reanchor(env_dir, m):
|
||||
"""按显式持久化写入重算身份锚点;数据根变化时在新根补写 marker(目录存在才写)。"""
|
||||
def reanchor(env_dir, m, write=True):
|
||||
"""按显式持久化写入重算身份锚点;数据根变化时在新根补写 marker(目录存在才写)。
|
||||
|
||||
write=False 仅预览身份(冲突扫描用),不落 marker。
|
||||
"""
|
||||
ident = dict(m["identity"])
|
||||
ident["data_root"] = data_root_of(m["kv"], env_dir)
|
||||
ident["db_mode"], ident["db_ref"] = db_anchor_of(m["kv"])
|
||||
for k, v in storage_anchor_of(m["kv"]).items():
|
||||
ident[k] = v
|
||||
if ident != m["identity"]:
|
||||
if os.path.isdir(ident["data_root"]):
|
||||
if write and os.path.isdir(ident["data_root"]):
|
||||
write_marker(ident["data_root"], m["instance_id"])
|
||||
m["identity"] = ident
|
||||
return m
|
||||
@@ -160,6 +326,9 @@ def build(env_dir, kv, allow_create):
|
||||
if not allow_create:
|
||||
die("数据根不存在且非显式 init,拒绝建立身份(防新建空系统掩盖丢数据):%s" % data_root, 3)
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
# 旧配置升级:若已带 storage 键则校验;纯 legacy 不强制 container 合同
|
||||
if storage_mode_of(kv) == "container" or any(kv.get(k) for k in STORAGE_KEYS):
|
||||
validate_storage_kv(kv, require_complete=(storage_mode_of(kv) == "container"))
|
||||
existing = read_marker(data_root)
|
||||
if existing is not None:
|
||||
if existing.get("instance_id") != kv.get("instance_id"):
|
||||
@@ -170,6 +339,14 @@ def build(env_dir, kv, allow_create):
|
||||
instance_id = kv.get("instance_id") or "svc-%s-%s" % (name, secrets.token_hex(6))
|
||||
write_marker(data_root, instance_id)
|
||||
db_mode, db_ref = db_anchor_of(kv)
|
||||
storage = storage_anchor_of(kv)
|
||||
identity = {
|
||||
"data_root": data_root,
|
||||
"db_mode": db_mode,
|
||||
"db_ref": db_ref,
|
||||
"db_marker": "fresh",
|
||||
}
|
||||
identity.update(storage)
|
||||
m = {
|
||||
"schema": SCHEMA,
|
||||
"revision": 1,
|
||||
@@ -177,18 +354,114 @@ def build(env_dir, kv, allow_create):
|
||||
"service_group": name,
|
||||
"created_at": now_iso(),
|
||||
"desired": "running" if kv.get("autostart") == "1" else "stopped",
|
||||
"identity": {
|
||||
"data_root": data_root,
|
||||
"db_mode": db_mode,
|
||||
"db_ref": db_ref,
|
||||
"db_marker": "fresh",
|
||||
},
|
||||
"identity": identity,
|
||||
"kv": {k: v for k, v in kv.items() if k != "autostart"},
|
||||
}
|
||||
save(env_dir, m)
|
||||
return m
|
||||
|
||||
|
||||
def runtime_dir_of(env_dir):
|
||||
return os.path.dirname(os.path.abspath(env_dir))
|
||||
|
||||
|
||||
def iter_sibling_manifests(env_dir, runtime_dir=None):
|
||||
rt = os.path.abspath(runtime_dir) if runtime_dir else runtime_dir_of(env_dir)
|
||||
self_abs = os.path.abspath(env_dir)
|
||||
if not os.path.isdir(rt):
|
||||
return
|
||||
for name in sorted(os.listdir(rt)):
|
||||
d = os.path.join(rt, name)
|
||||
if not os.path.isdir(d) or os.path.abspath(d) == self_abs:
|
||||
continue
|
||||
path = os.path.join(d, MANIFEST)
|
||||
if not os.path.isfile(path):
|
||||
# 兼容尚未 migrate 的 sibling:读 svc.env 中的存储键
|
||||
env_path = os.path.join(d, DERIVED)
|
||||
if not os.path.isfile(env_path):
|
||||
continue
|
||||
kv = {}
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
if line and "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
kv[k] = v
|
||||
yield name, {
|
||||
"instance_id": kv.get("instance_id", ""),
|
||||
"identity": storage_anchor_of(kv) if any(kv.get(k) for k in STORAGE_KEYS) else {},
|
||||
"kv": kv,
|
||||
}
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
yield name, json.load(f)
|
||||
except Exception: # noqa: BLE001 —— 损坏 sibling 不阻断本实例冲突扫描语义以外的操作
|
||||
continue
|
||||
|
||||
|
||||
def binding_ports_from(m_or_kv, is_manifest=True):
|
||||
if is_manifest:
|
||||
kv = m_or_kv.get("kv") or {}
|
||||
ident = m_or_kv.get("identity") or {}
|
||||
else:
|
||||
kv = m_or_kv
|
||||
ident = {}
|
||||
ports = set()
|
||||
for slot in DB_SLOTS:
|
||||
p = (kv.get("%s_port" % slot) or "").strip() or (ident.get("%s_port" % slot) or "").strip()
|
||||
if p:
|
||||
ports.add(p)
|
||||
return ports
|
||||
|
||||
|
||||
def compose_of(m_or_kv, is_manifest=True):
|
||||
if is_manifest:
|
||||
kv = m_or_kv.get("kv") or {}
|
||||
ident = m_or_kv.get("identity") or {}
|
||||
return (kv.get("compose_project") or ident.get("compose_project") or "").strip()
|
||||
return (m_or_kv.get("compose_project") or "").strip()
|
||||
|
||||
|
||||
def check_conflicts(env_dir, m, runtime_dir=None):
|
||||
"""同一 runtime 下 Compose 项目名或存储端口冲突 → 修改前失败。"""
|
||||
kv = m["kv"]
|
||||
ident = m.get("identity") or {}
|
||||
compose = (kv.get("compose_project") or ident.get("compose_project") or "").strip()
|
||||
ports = binding_ports_from(m, is_manifest=True)
|
||||
if not compose and not ports:
|
||||
return
|
||||
self_name = os.path.basename(os.path.abspath(env_dir))
|
||||
for name, other in iter_sibling_manifests(env_dir, runtime_dir):
|
||||
other_compose = compose_of(other, is_manifest=True)
|
||||
if compose and other_compose and compose == other_compose:
|
||||
die("Compose 项目名冲突:compose_project=%s 已被实例 %s 占用(修改前拒绝)"
|
||||
% (compose, name), 7)
|
||||
other_ports = binding_ports_from(other, is_manifest=True)
|
||||
clash = ports & other_ports
|
||||
if clash:
|
||||
die("存储端口冲突:port=%s 与实例 %s 冲突(修改前拒绝)"
|
||||
% (",".join(sorted(clash)), name), 7)
|
||||
# 亦与 sibling 的应用监听 addr 端口冲突(避免与已登记服务抢口)
|
||||
if ports:
|
||||
rt = os.path.abspath(runtime_dir) if runtime_dir else runtime_dir_of(env_dir)
|
||||
for name in sorted(os.listdir(rt)) if os.path.isdir(rt) else []:
|
||||
if name == self_name:
|
||||
continue
|
||||
env_path = os.path.join(rt, name, DERIVED)
|
||||
if not os.path.isfile(env_path):
|
||||
continue
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith("addr="):
|
||||
addr = line[5:].strip()
|
||||
ap = addr.rsplit(":", 1)[-1] if addr else ""
|
||||
if ap in ports:
|
||||
die("存储端口冲突:port=%s 与服务 %s 的 addr=%s 冲突(修改前拒绝)"
|
||||
% (ap, name, addr), 7)
|
||||
break
|
||||
|
||||
|
||||
def cmd_migrate(env_dir):
|
||||
if load(env_dir) is not None:
|
||||
print("manifest 已存在,幂等跳过")
|
||||
@@ -221,32 +494,41 @@ def cmd_status(env_dir):
|
||||
return 0
|
||||
|
||||
|
||||
def mutate(env_dir, fn):
|
||||
def mutate(env_dir, fn, runtime_dir=None):
|
||||
m = load(env_dir)
|
||||
if m is None:
|
||||
die("manifest 不存在:先 migrate/init", 3)
|
||||
before = json.dumps(m["identity"], sort_keys=True)
|
||||
fn(m)
|
||||
# 存储绑定变更:先形态校验,再冲突扫描,最后重锚——冲突时不落盘
|
||||
if any(k in m["kv"] for k in STORAGE_KEYS) or storage_mode_of(m["kv"]) == "container":
|
||||
validate_storage_kv(m["kv"], require_complete=(storage_mode_of(m["kv"]) == "container"))
|
||||
# 预览重锚后的 identity 以做冲突检测(不落 marker / 不写盘)
|
||||
preview = json.loads(json.dumps(m))
|
||||
preview = reanchor(env_dir, preview, write=False)
|
||||
check_conflicts(env_dir, preview, runtime_dir=runtime_dir)
|
||||
m["revision"] = int(m["revision"]) + 1
|
||||
m = reanchor(env_dir, m)
|
||||
m = reanchor(env_dir, m, write=True)
|
||||
save(env_dir, m)
|
||||
if before != json.dumps(m["identity"], sort_keys=True):
|
||||
print("[manifest] 身份锚点已随显式持久化写入更新(data=%s db=%s/%s)"
|
||||
% (m["identity"]["data_root"], m["identity"]["db_mode"], m["identity"]["db_ref"]))
|
||||
print("[manifest] 身份锚点已随显式持久化写入更新(data=%s db=%s/%s storage=%s files=%s)"
|
||||
% (m["identity"]["data_root"], m["identity"]["db_mode"], m["identity"]["db_ref"],
|
||||
m["identity"].get("storage_mode", "legacy"),
|
||||
m["identity"].get("files_root") or "-"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set(env_dir, key, value):
|
||||
return mutate(env_dir, lambda m: m["kv"].__setitem__(key, value))
|
||||
def cmd_set(env_dir, key, value, runtime_dir=None):
|
||||
return mutate(env_dir, lambda m: m["kv"].__setitem__(key, value), runtime_dir=runtime_dir)
|
||||
|
||||
|
||||
def cmd_del(env_dir, key):
|
||||
def cmd_del(env_dir, key, runtime_dir=None):
|
||||
def drop(m):
|
||||
m["kv"].pop(key, None)
|
||||
return mutate(env_dir, drop)
|
||||
return mutate(env_dir, drop, runtime_dir=runtime_dir)
|
||||
|
||||
|
||||
def cmd_bulk_set(env_dir):
|
||||
def cmd_bulk_set(env_dir, runtime_dir=None):
|
||||
kv = {}
|
||||
for line in sys.stdin.read().splitlines():
|
||||
if line and "=" in line and not line.startswith("#"):
|
||||
@@ -255,8 +537,8 @@ def cmd_bulk_set(env_dir):
|
||||
kv.pop("autostart", None) # desired 独占期望态
|
||||
|
||||
def replace(m):
|
||||
m["kv"] = kv
|
||||
return mutate(env_dir, replace)
|
||||
m["kv"] = preserve_storage_keys(kv, m["kv"])
|
||||
return mutate(env_dir, replace, runtime_dir=runtime_dir)
|
||||
|
||||
|
||||
def cmd_set_desired(env_dir, state):
|
||||
@@ -265,7 +547,20 @@ def cmd_set_desired(env_dir, state):
|
||||
return mutate(env_dir, lambda m: m.__setitem__("desired", state))
|
||||
|
||||
|
||||
def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None):
|
||||
def cmd_check_conflicts(env_dir, runtime_dir=None):
|
||||
m = load(env_dir)
|
||||
if m is None:
|
||||
die("manifest 不存在:先 migrate/init", 3)
|
||||
check_conflicts(env_dir, m, runtime_dir=runtime_dir)
|
||||
print("绑定冲突检查通过 instance_id=%s compose=%s"
|
||||
% (m["instance_id"], m["identity"].get("compose_project") or "-"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None,
|
||||
files_root=None, env_class=None, compose_project=None,
|
||||
base_db_ref=None, rdm_db_ref=None, kb_db_ref=None,
|
||||
require_storage=False):
|
||||
m = load(env_dir)
|
||||
if m is None:
|
||||
die("manifest 缺失:本实例已进入 manifest 治理,缺失即事件(先 init/人工处置,禁止静默重建)", 4)
|
||||
@@ -284,14 +579,51 @@ def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None):
|
||||
die("数据库模式漂移:生效 %s ≠ 锚定 %s" % (db_mode, ident["db_mode"]), 6)
|
||||
if db_mode and ident["db_mode"] and db_ref is not None and (db_ref or "") != ident["db_ref"]:
|
||||
die("数据库绑定漂移:生效 %s ≠ 锚定 %s(重绑须经显式登记命令)" % (db_ref or "<空>", ident["db_ref"]), 6)
|
||||
print("身份校验通过 instance_id=%s data=%s db=%s/%s"
|
||||
% (m["instance_id"], ident["data_root"], ident["db_mode"] or "-", ident["db_ref"] or "-"))
|
||||
|
||||
storage_mode = ident.get("storage_mode") or "legacy"
|
||||
if storage_mode == "container" or require_storage:
|
||||
# container:缺绑定/错根/错库一律拒绝,不回退默认、不建空库
|
||||
validate_storage_kv(m["kv"], require_complete=True)
|
||||
if files_root is not None:
|
||||
got = os.path.normpath(files_root) if os.path.isabs(files_root) else files_root
|
||||
if not os.path.isabs(files_root):
|
||||
die("文件根拒绝相对路径(不相对 checkout 解析):%s" % files_root, 6)
|
||||
if got != (ident.get("files_root") or ""):
|
||||
die("文件根漂移:生效 %s ≠ 锚定 %s" % (got, ident.get("files_root")), 6)
|
||||
if env_class is not None and env_class != (ident.get("env_class") or ""):
|
||||
die("环境身份漂移:生效 %s ≠ 锚定 %s" % (env_class, ident.get("env_class")), 6)
|
||||
if compose_project is not None and compose_project != (ident.get("compose_project") or ""):
|
||||
die("Compose 项目漂移:生效 %s ≠ 锚定 %s" % (compose_project, ident.get("compose_project")), 6)
|
||||
for slot, got in (("base", base_db_ref), ("rdm", rdm_db_ref), ("kb", kb_db_ref)):
|
||||
if got is None:
|
||||
continue
|
||||
want = ident.get("%s_db_ref" % slot) or ""
|
||||
if (got or "") != want:
|
||||
die("%s 库绑定漂移:生效 %s ≠ 锚定 %s(错库拒绝,不静默新建)"
|
||||
% (slot, got or "<空>", want or "<空>"), 6)
|
||||
|
||||
print("身份校验通过 instance_id=%s data=%s db=%s/%s storage=%s env=%s files=%s"
|
||||
% (m["instance_id"], ident["data_root"], ident["db_mode"] or "-", ident["db_ref"] or "-",
|
||||
storage_mode, ident.get("env_class") or "-", ident.get("files_root") or "-"))
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_runtime_flag(args):
|
||||
runtime_dir = None
|
||||
out = []
|
||||
it = iter(args)
|
||||
for a in it:
|
||||
if a == "--runtime":
|
||||
runtime_dir = next(it, None)
|
||||
else:
|
||||
out.append(a)
|
||||
return runtime_dir, out
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 3:
|
||||
die("用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]", 2)
|
||||
die("用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|"
|
||||
"set-desired|verify|check-conflicts> [参数]", 2)
|
||||
env_dir, cmd = argv[1], argv[2]
|
||||
args = argv[3:]
|
||||
if cmd == "migrate":
|
||||
@@ -300,16 +632,27 @@ def main(argv):
|
||||
return cmd_init(env_dir)
|
||||
if cmd == "status":
|
||||
return cmd_status(env_dir)
|
||||
if cmd == "set" and len(args) == 2:
|
||||
return cmd_set(env_dir, args[0], args[1])
|
||||
if cmd == "del" and len(args) == 1:
|
||||
return cmd_del(env_dir, args[0])
|
||||
if cmd == "set":
|
||||
runtime_dir, args = _parse_runtime_flag(args)
|
||||
if len(args) == 2:
|
||||
return cmd_set(env_dir, args[0], args[1], runtime_dir=runtime_dir)
|
||||
if cmd == "del":
|
||||
runtime_dir, args = _parse_runtime_flag(args)
|
||||
if len(args) == 1:
|
||||
return cmd_del(env_dir, args[0], runtime_dir=runtime_dir)
|
||||
if cmd == "bulk-set":
|
||||
return cmd_bulk_set(env_dir)
|
||||
runtime_dir, args = _parse_runtime_flag(args)
|
||||
return cmd_bulk_set(env_dir, runtime_dir=runtime_dir)
|
||||
if cmd == "set-desired" and len(args) == 1:
|
||||
return cmd_set_desired(env_dir, args[0])
|
||||
if cmd == "check-conflicts":
|
||||
runtime_dir, _ = _parse_runtime_flag(args)
|
||||
return cmd_check_conflicts(env_dir, runtime_dir=runtime_dir)
|
||||
if cmd == "verify":
|
||||
data_root = db_mode = db_ref = None
|
||||
files_root = env_class = compose_project = None
|
||||
base_db_ref = rdm_db_ref = kb_db_ref = None
|
||||
require_storage = False
|
||||
it = iter(args)
|
||||
for a in it:
|
||||
if a == "--data":
|
||||
@@ -318,7 +661,27 @@ def main(argv):
|
||||
db_mode = next(it, None)
|
||||
elif a == "--db-ref":
|
||||
db_ref = next(it, None)
|
||||
return cmd_verify(env_dir, data_root, db_mode, db_ref)
|
||||
elif a == "--files-root":
|
||||
files_root = next(it, None)
|
||||
elif a == "--env-class":
|
||||
env_class = next(it, None)
|
||||
elif a == "--compose-project":
|
||||
compose_project = next(it, None)
|
||||
elif a == "--base-db-ref":
|
||||
base_db_ref = next(it, None)
|
||||
elif a == "--rdm-db-ref":
|
||||
rdm_db_ref = next(it, None)
|
||||
elif a == "--kb-db-ref":
|
||||
kb_db_ref = next(it, None)
|
||||
elif a == "--require-storage":
|
||||
require_storage = True
|
||||
return cmd_verify(
|
||||
env_dir, data_root, db_mode, db_ref,
|
||||
files_root=files_root, env_class=env_class,
|
||||
compose_project=compose_project,
|
||||
base_db_ref=base_db_ref, rdm_db_ref=rdm_db_ref, kb_db_ref=kb_db_ref,
|
||||
require_storage=require_storage,
|
||||
)
|
||||
die("未知命令或参数不全:%s %s" % (cmd, " ".join(args)), 2)
|
||||
|
||||
|
||||
|
||||
@@ -500,6 +500,199 @@ class IdentityTests(tsr.SvcAutoRecoverTests):
|
||||
self.skipTest("真 PG 专项应由隔离套件执行(本桩文件不连真实 PG)")
|
||||
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)")
|
||||
|
||||
# ---- 容器存储 U1:环境与数据归属合同 ----
|
||||
|
||||
def _storage_secret_dir(self):
|
||||
d = self.root / "storage-secrets"
|
||||
d.mkdir(exist_ok=True)
|
||||
for slot in ("base", "rdm", "kb"):
|
||||
(d / ("%s.dsn" % slot)).write_text("ref-only\n", encoding="utf-8")
|
||||
(d / ("%s.role" % slot)).write_text("ref-only\n", encoding="utf-8")
|
||||
return d
|
||||
|
||||
def _apply_container_bindings(self, name, compose, ports, files_root, env_class="prod"):
|
||||
"""先写齐绑定键,最后打开 storage_mode=container(缺一即拒)。
|
||||
经 svc_manifest.py set(与 svc_set_key 同源权威)。"""
|
||||
sec = self._storage_secret_dir()
|
||||
items = [
|
||||
("env_class", env_class),
|
||||
("files_root", str(files_root)),
|
||||
("compose_project", compose),
|
||||
("base_dsn_ref", str(sec / "base.dsn")),
|
||||
("base_role_ref", str(sec / "base.role")),
|
||||
("base_port", ports[0]),
|
||||
("rdm_dsn_ref", str(sec / "rdm.dsn")),
|
||||
("rdm_role_ref", str(sec / "rdm.role")),
|
||||
("rdm_port", ports[1]),
|
||||
("kb_dsn_ref", str(sec / "kb.dsn")),
|
||||
("kb_role_ref", str(sec / "kb.role")),
|
||||
("kb_port", ports[2]),
|
||||
("storage_mode", "container"),
|
||||
]
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
env_dir = self.runtime(name)
|
||||
runtime = env_dir.parent
|
||||
for k, v in items:
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(env_dir), "set", "--runtime", str(runtime), k, str(v)],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertEqual(proc.returncode, 0, "%s=%s → %s" % (k, v, proc.stdout))
|
||||
|
||||
def test_CS_U1_legacy_upgrade_keeps_identity(self):
|
||||
"""旧配置升级到 container 绑定:instance_id / data_root 保留。"""
|
||||
self.seed_agent("cs1", 9471)
|
||||
self.start_ok("cs1")
|
||||
m0 = self.manifest("cs1")
|
||||
iid, data = m0["instance_id"], m0["identity"]["data_root"]
|
||||
self.svc("cs1", "stop")
|
||||
files = self.root / "files-cs1"
|
||||
files.mkdir()
|
||||
self._apply_container_bindings("cs1", "svc-cs1", ("25432", "25433", "25434"), files)
|
||||
m1 = self.manifest("cs1")
|
||||
self.assertEqual(m1["instance_id"], iid)
|
||||
self.assertEqual(m1["identity"]["data_root"], data)
|
||||
self.assertEqual(m1["identity"]["storage_mode"], "container")
|
||||
self.assertEqual(m1["identity"]["env_class"], "prod")
|
||||
self.assertEqual(m1["identity"]["files_root"], str(files))
|
||||
self.assertTrue(m1["identity"]["base_db_ref"].startswith("dsn_ref:"))
|
||||
self.assertTrue(m1["identity"]["rdm_db_ref"].startswith("dsn_ref:"))
|
||||
self.assertTrue(m1["identity"]["kb_db_ref"].startswith("dsn_ref:"))
|
||||
|
||||
def test_CS_U1_relative_files_root_rejected(self):
|
||||
self.seed_agent("cs2", 9472)
|
||||
self.start_ok("cs2")
|
||||
self.svc("cs2", "stop")
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
env_dir = self.runtime("cs2")
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(env_dir), "set", "--runtime", str(env_dir.parent),
|
||||
"files_root", "relative/not-abs"],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertNotEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertIn("绝对路径", proc.stdout)
|
||||
self.assertNotEqual(self.manifest("cs2")["identity"].get("files_root"),
|
||||
"relative/not-abs")
|
||||
|
||||
def test_CS_U1_missing_binding_rejected(self):
|
||||
"""container 模式缺任一必需绑定不得开启(不静默默认)。"""
|
||||
self.seed_agent("cs3", 9473)
|
||||
self.start_ok("cs3")
|
||||
self.svc("cs3", "stop")
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
env_dir = self.runtime("cs3")
|
||||
rt = str(env_dir.parent)
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(env_dir), "set", "--runtime", rt,
|
||||
"storage_mode", "container"],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertNotEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertIn("缺少必需绑定", proc.stdout)
|
||||
self.assertNotEqual(self.manifest("cs3")["kv"].get("storage_mode"), "container")
|
||||
|
||||
def test_CS_U1_wrong_files_root_and_db_verify_rejected(self):
|
||||
self.seed_agent("cs4", 9474)
|
||||
self.start_ok("cs4")
|
||||
self.svc("cs4", "stop")
|
||||
files = self.root / "files-cs4"
|
||||
files.mkdir()
|
||||
self._apply_container_bindings("cs4", "svc-cs4", ("25532", "25533", "25534"), files)
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
env_dir = self.runtime("cs4")
|
||||
bad_files = subprocess.run(
|
||||
["python3", str(man), str(env_dir), "verify", "--require-storage",
|
||||
"--files-root", str(self.root / "other-files")],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertNotEqual(bad_files.returncode, 0, bad_files.stdout)
|
||||
self.assertIn("文件根", bad_files.stdout)
|
||||
bad_db = subprocess.run(
|
||||
["python3", str(man), str(env_dir), "verify", "--require-storage",
|
||||
"--files-root", str(files),
|
||||
"--base-db-ref", "dbref:foreign/x"],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertNotEqual(bad_db.returncode, 0, bad_db.stdout)
|
||||
self.assertIn("base 库绑定漂移", bad_db.stdout)
|
||||
|
||||
def test_CS_U1_compose_and_port_conflict_before_modify(self):
|
||||
self.seed_agent("cs5a", 9475)
|
||||
self.seed_agent("cs5b", 9476)
|
||||
self.start_ok("cs5a")
|
||||
self.start_ok("cs5b")
|
||||
self.svc("cs5a", "stop")
|
||||
self.svc("cs5b", "stop")
|
||||
files_a = self.root / "files-cs5a"
|
||||
files_b = self.root / "files-cs5b"
|
||||
files_a.mkdir()
|
||||
files_b.mkdir()
|
||||
self._apply_container_bindings("cs5a", "svc-shared", ("25632", "25633", "25634"), files_a)
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
env_b = self.runtime("cs5b")
|
||||
rt = str(env_b.parent)
|
||||
# 同 compose 名:修改前失败
|
||||
sec = self._storage_secret_dir()
|
||||
for k, v in (("env_class", "test"), ("files_root", str(files_b)),
|
||||
("compose_project", "svc-shared")):
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(env_b), "set", "--runtime", rt, k, v],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
if k == "compose_project":
|
||||
self.assertNotEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertIn("Compose 项目名冲突", proc.stdout)
|
||||
else:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
# 端口冲突
|
||||
self.seed_agent("cs5c", 9477)
|
||||
self.start_ok("cs5c")
|
||||
self.svc("cs5c", "stop")
|
||||
files_c = self.root / "files-cs5c"
|
||||
files_c.mkdir()
|
||||
env_c = self.runtime("cs5c")
|
||||
for k, v in (
|
||||
("env_class", "verify"),
|
||||
("files_root", str(files_c)),
|
||||
("compose_project", "svc-cs5c"),
|
||||
("base_dsn_ref", str(sec / "base.dsn")),
|
||||
("base_role_ref", str(sec / "base.role")),
|
||||
("base_port", "25632"),
|
||||
):
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(env_c), "set", "--runtime", rt, k, str(v)],
|
||||
cwd=str(self.root), env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
if k == "base_port":
|
||||
self.assertNotEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertIn("端口冲突", proc.stdout)
|
||||
else:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
|
||||
def test_CS_U1_other_checkout_keeps_absolute_files_root(self):
|
||||
"""另一 cwd/checkout 调用仍定位同一绝对 files_root。"""
|
||||
self.seed_agent("cs6", 9478)
|
||||
self.start_ok("cs6")
|
||||
self.svc("cs6", "stop")
|
||||
files = self.root / "files-cs6"
|
||||
files.mkdir()
|
||||
self._apply_container_bindings("cs6", "svc-cs6", ("25732", "25733", "25734"), files)
|
||||
iid = self.manifest("cs6")["instance_id"]
|
||||
fr = self.manifest("cs6")["identity"]["files_root"]
|
||||
elsewhere = tempfile.mkdtemp(prefix="svc-cs-elsewhere-")
|
||||
self.addCleanup(shutil.rmtree, elsewhere, True)
|
||||
man = self.root / "scripts" / "svc_manifest.py"
|
||||
proc = subprocess.run(
|
||||
["python3", str(man), str(self.runtime("cs6")), "status"],
|
||||
cwd=elsewhere, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
payload = json.loads(proc.stdout.strip())
|
||||
self.assertEqual(payload["instance_id"], iid)
|
||||
self.assertEqual(payload["identity"]["files_root"], fr)
|
||||
self.assertTrue(os.path.isabs(fr), fr)
|
||||
|
||||
|
||||
class AtomicWriteTests(unittest.TestCase):
|
||||
"""崩溃窗口单元级:os.replace 失败时原文件必须完好(I3 半写不可能)。"""
|
||||
|
||||
@@ -52,3 +52,41 @@ class LifecycleIsolationTests(tsr.SvcAutoRecoverTests):
|
||||
self.assertEqual(again["identity"]["data_root"], data)
|
||||
self.assertEqual(again["identity"].get("db_marker"), "established")
|
||||
self.svc("iso2", "stop")
|
||||
|
||||
def test_caller_storage_env_does_not_reach_serve(self):
|
||||
"""调用方 AGENT_SYSTEM_FILES_ROOT / ENV_CLASS / COMPOSE 不得进入 serve。"""
|
||||
self.seed_agent("iso3", 9463)
|
||||
env = dict(self.env)
|
||||
env["AGENT_SYSTEM_FILES_ROOT"] = "/tmp/leak-files-root"
|
||||
env["AGENT_SYSTEM_ENV_CLASS"] = "prod"
|
||||
env["AGENT_SYSTEM_COMPOSE_PROJECT"] = "leak-compose"
|
||||
env["AGENT_SYSTEM_BASE_DB_REF"] = "dbref:leak/base"
|
||||
env["AGENT_SYSTEM_RDM_DB_REF"] = "dbref:leak/rdm"
|
||||
env["AGENT_SYSTEM_KB_DB_REF"] = "dbref:leak/kb"
|
||||
self.svc("iso3", "start", env=env)
|
||||
pid = self.pid_of("iso3")
|
||||
self.assertIsNotNone(pid)
|
||||
raw = Path("/proc/%d/environ" % pid).read_bytes()
|
||||
self.assertNotIn(b"/tmp/leak-files-root", raw)
|
||||
self.assertNotIn(b"leak-compose", raw)
|
||||
self.assertNotIn(b"dbref:leak/base", raw)
|
||||
self.assertNotIn(b"dbref:leak/rdm", raw)
|
||||
self.assertNotIn(b"dbref:leak/kb", raw)
|
||||
serve_env = self.runtime("iso3") / "serve.env"
|
||||
self.assertTrue(serve_env.is_file())
|
||||
text = serve_env.read_text()
|
||||
self.assertNotIn("AGENT_SYSTEM_FILES_ROOT", text)
|
||||
self.assertNotIn("leak-files-root", text)
|
||||
self.assertNotIn("leak-compose", text)
|
||||
self.svc("iso3", "stop")
|
||||
|
||||
def test_env_config_resets_storage_globals(self):
|
||||
"""棘轮:env_config 必须清空存储绑定全局与调用方身份环境。"""
|
||||
import subprocess
|
||||
out = subprocess.run(
|
||||
["bash", "-c",
|
||||
'source "$1"; declare -f env_config | grep -c \'AGENT_SYSTEM_FILES_ROOT\'',
|
||||
"x", str(SOURCE / "scripts" / "svc.sh")],
|
||||
capture_output=True, text=True, cwd=str(SOURCE), timeout=30)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
self.assertGreaterEqual(int(out.stdout.strip()), 1)
|
||||
|
||||
@@ -246,6 +246,198 @@ class ResolverStateIsolation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
def test_env_config_resets_storage_identity_env(self):
|
||||
# 棘轮:env_config 必须剥离调用方 FILES_ROOT / ENV_CLASS / COMPOSE(容器存储 U1)。
|
||||
out = _bash(
|
||||
'source "$1"; declare -f env_config | '
|
||||
'grep -c \'unset AGENT_SYSTEM_FILES_ROOT AGENT_SYSTEM_ENV_CLASS AGENT_SYSTEM_COMPOSE_PROJECT\''
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
|
||||
class StorageBindingsPrecheck(unittest.TestCase):
|
||||
"""容器存储 U1:resolve_storage_bindings 与冲突预检(不启真实服务)。"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.rt = Path(self._tmp.name) / "runtime"
|
||||
self.rt.mkdir()
|
||||
self.sec = Path(self._tmp.name) / "secrets"
|
||||
self.sec.mkdir()
|
||||
self.files = Path(self._tmp.name) / "files"
|
||||
self.files.mkdir()
|
||||
for slot in ("base", "rdm", "kb"):
|
||||
(self.sec / ("%s.dsn" % slot)).write_text("x\n")
|
||||
(self.sec / ("%s.role" % slot)).write_text("y\n")
|
||||
# 复制 manifest 脚本到可读路径(与仓内一致)
|
||||
self.man = REPO / "scripts" / "svc_manifest.py"
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _init_agent(self, name):
|
||||
env = self.rt / name
|
||||
data = env / "data"
|
||||
data.mkdir(parents=True)
|
||||
(env / "svc.env").write_text(
|
||||
"kind=agent\nenv=%s\naddr=127.0.0.1:18080\n"
|
||||
"exec=--addr 127.0.0.1:18080 --data %s\n" % (name, data),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["python3", str(self.man), str(env), "migrate"],
|
||||
capture_output=True, text=True, cwd=str(REPO), timeout=30)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
return env
|
||||
|
||||
def _set(self, env, key, value):
|
||||
proc = subprocess.run(
|
||||
["python3", str(self.man), str(env), "set",
|
||||
"--runtime", str(self.rt), key, str(value)],
|
||||
capture_output=True, text=True, cwd=str(REPO), timeout=30)
|
||||
return proc
|
||||
|
||||
def test_resolve_storage_bindings_legacy_ok(self):
|
||||
env = self._init_agent("leg")
|
||||
out = _bash(
|
||||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=leg; ENV_DIR="$2"; '
|
||||
'if resolve_storage_bindings; then echo rc=ok; else echo rc=fail; fi; '
|
||||
'printf "MODE=%s\\n" "$STORAGE_MODE"',
|
||||
str(self.rt), str(env),
|
||||
)
|
||||
self.assertIn("rc=ok", out)
|
||||
self.assertIn("MODE=legacy", out)
|
||||
|
||||
def test_resolve_incomplete_container_refuses(self):
|
||||
env = self._init_agent("inc")
|
||||
# 只写 storage_mode 会被 manifest 拒绝;直接改 kv 模拟半套后 resolve 拒绝
|
||||
proc = self._set(env, "env_class", "prod")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
# 手工把 storage_mode 写成 container(绕过 mutate 完整校验)以测 resolve
|
||||
import json
|
||||
mp = env / "manifest.json"
|
||||
m = json.loads(mp.read_text(encoding="utf-8"))
|
||||
m["kv"]["storage_mode"] = "container"
|
||||
m["identity"]["storage_mode"] = "container"
|
||||
mp.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
(env / "svc.env").write_text(
|
||||
(env / "svc.env").read_text(encoding="utf-8") + "storage_mode=container\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
_bash(
|
||||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=inc; ENV_DIR="$2"; '
|
||||
'resolve_storage_bindings',
|
||||
str(self.rt), str(env),
|
||||
)
|
||||
self.fail("incomplete container must refuse")
|
||||
except AssertionError as e:
|
||||
self.assertIn("存储绑定不完整", str(e))
|
||||
|
||||
def test_relative_files_root_refused_on_set(self):
|
||||
env = self._init_agent("rel")
|
||||
proc = self._set(env, "files_root", "not/absolute")
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("绝对路径", proc.stdout + proc.stderr)
|
||||
|
||||
def test_compose_project_conflict_refused(self):
|
||||
a = self._init_agent("ca")
|
||||
b = self._init_agent("cb")
|
||||
for k, v in (
|
||||
("env_class", "prod"),
|
||||
("files_root", str(self.files)),
|
||||
("compose_project", "same-proj"),
|
||||
("base_dsn_ref", str(self.sec / "base.dsn")),
|
||||
("base_role_ref", str(self.sec / "base.role")),
|
||||
("base_port", "26001"),
|
||||
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
|
||||
("rdm_role_ref", str(self.sec / "rdm.role")),
|
||||
("rdm_port", "26002"),
|
||||
("kb_dsn_ref", str(self.sec / "kb.dsn")),
|
||||
("kb_role_ref", str(self.sec / "kb.role")),
|
||||
("kb_port", "26003"),
|
||||
("storage_mode", "container"),
|
||||
):
|
||||
proc = self._set(a, k, v)
|
||||
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
|
||||
(self.files / "b").mkdir(exist_ok=True)
|
||||
proc = self._set(b, "env_class", "test")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
proc = self._set(b, "files_root", str(self.files / "b"))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
proc = self._set(b, "compose_project", "same-proj")
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("Compose 项目名冲突", proc.stdout + proc.stderr)
|
||||
|
||||
def test_storage_port_conflict_refused(self):
|
||||
a = self._init_agent("pa")
|
||||
b = self._init_agent("pb")
|
||||
for k, v in (
|
||||
("env_class", "prod"),
|
||||
("files_root", str(self.files / "pa")),
|
||||
("compose_project", "proj-pa"),
|
||||
("base_dsn_ref", str(self.sec / "base.dsn")),
|
||||
("base_role_ref", str(self.sec / "base.role")),
|
||||
("base_port", "26101"),
|
||||
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
|
||||
("rdm_role_ref", str(self.sec / "rdm.role")),
|
||||
("rdm_port", "26102"),
|
||||
("kb_dsn_ref", str(self.sec / "kb.dsn")),
|
||||
("kb_role_ref", str(self.sec / "kb.role")),
|
||||
("kb_port", "26103"),
|
||||
("storage_mode", "container"),
|
||||
):
|
||||
(self.files / "pa").mkdir(exist_ok=True)
|
||||
proc = self._set(a, k, v)
|
||||
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
|
||||
(self.files / "pb").mkdir(exist_ok=True)
|
||||
for k, v in (
|
||||
("env_class", "test"),
|
||||
("files_root", str(self.files / "pb")),
|
||||
("compose_project", "proj-pb"),
|
||||
("base_dsn_ref", str(self.sec / "base.dsn")),
|
||||
("base_role_ref", str(self.sec / "base.role")),
|
||||
("base_port", "26101"),
|
||||
):
|
||||
proc = self._set(b, k, v)
|
||||
if k == "base_port":
|
||||
self.assertNotEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("端口冲突", proc.stdout + proc.stderr)
|
||||
else:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
|
||||
def test_resolve_complete_container_ok(self):
|
||||
env = self._init_agent("okc")
|
||||
(self.files / "okc").mkdir(exist_ok=True)
|
||||
for k, v in (
|
||||
("env_class", "verify"),
|
||||
("files_root", str(self.files / "okc")),
|
||||
("compose_project", "proj-okc"),
|
||||
("base_dsn_ref", str(self.sec / "base.dsn")),
|
||||
("base_role_ref", str(self.sec / "base.role")),
|
||||
("base_port", "26201"),
|
||||
("rdm_dsn_ref", str(self.sec / "rdm.dsn")),
|
||||
("rdm_role_ref", str(self.sec / "rdm.role")),
|
||||
("rdm_port", "26202"),
|
||||
("kb_dsn_ref", str(self.sec / "kb.dsn")),
|
||||
("kb_role_ref", str(self.sec / "kb.role")),
|
||||
("kb_port", "26203"),
|
||||
("storage_mode", "container"),
|
||||
):
|
||||
proc = self._set(env, k, v)
|
||||
self.assertEqual(proc.returncode, 0, "%s: %s" % (k, proc.stdout + proc.stderr))
|
||||
out = _bash(
|
||||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=okc; ENV_DIR="$2"; '
|
||||
'if resolve_storage_bindings; then echo rc=ok; else echo rc=fail; fi; '
|
||||
'printf "MODE=%s\\nENV=%s\\nFILES=%s\\nCOMPOSE=%s\\n" '
|
||||
'"$STORAGE_MODE" "$ENV_CLASS" "$FILES_ROOT" "$COMPOSE_PROJECT"',
|
||||
str(self.rt), str(env),
|
||||
)
|
||||
self.assertIn("rc=ok", out)
|
||||
self.assertIn("MODE=container", out)
|
||||
self.assertIn("ENV=verify", out)
|
||||
self.assertIn("COMPOSE=proj-okc", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user