Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54fcdbeed | ||
|
|
1dd2921674 |
@@ -1,139 +0,0 @@
|
||||
---
|
||||
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
|
||||
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /cleanup — 垃圾代码审查与清理
|
||||
|
||||
分析当前工作区(git diff)中的未提交代码,找出并修复常见垃圾代码,**不得改变任何运行逻辑**。
|
||||
|
||||
## 检查范围
|
||||
|
||||
若 `$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
|
||||
|
||||
## 节省上下文规则
|
||||
|
||||
优先用确定性的 CLI 检查缩小范围,不要一上来把完整文件或大 diff 读入上下文:
|
||||
|
||||
```bash
|
||||
git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
git diff --check
|
||||
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
|
||||
```
|
||||
|
||||
只有 focused diff 不足以安全判断或修改时,才读取完整文件。
|
||||
|
||||
## 审查清单
|
||||
|
||||
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
|
||||
|
||||
### 1. 重复逻辑 (Duplicate Logic)
|
||||
- 完全相同或高度相似的代码块在多处出现
|
||||
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
|
||||
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
|
||||
|
||||
### 2. Magic Numbers / Magic Strings
|
||||
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
|
||||
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
|
||||
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
|
||||
|
||||
### 3. 命名问题
|
||||
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`)
|
||||
- 命名与实际用途不符
|
||||
- 同一概念在不同地方用不同名字表达
|
||||
|
||||
### 4. 死代码 / 无效代码
|
||||
- 注释掉的旧代码块(3行以上)
|
||||
- 声明后从未使用的变量/参数/导入
|
||||
- 永远不会执行的条件分支
|
||||
|
||||
### 5. 代码风格问题
|
||||
- 尾部空白字符(trailing whitespace)
|
||||
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
|
||||
- 空行使用不一致(连续多个空行等)
|
||||
|
||||
### 6. 其他常见问题
|
||||
- 私有辅助函数应被 export 但没有,导致调用方重复实现
|
||||
- 类型/接口重复定义
|
||||
- 过于冗长的条件表达式可以简化(不改逻辑)
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 获取待检查文件列表
|
||||
|
||||
```bash
|
||||
# 无参数时:获取所有未提交修改
|
||||
git diff HEAD --name-only
|
||||
|
||||
# 有参数时:用 $ARGUMENTS 过滤
|
||||
```
|
||||
|
||||
### Step 2 — 逐文件阅读并分析
|
||||
|
||||
先从 focused diff 开始:
|
||||
|
||||
```bash
|
||||
git diff --unified=0 HEAD -- <file>
|
||||
```
|
||||
|
||||
用 `rg`、`git diff --check`、编译器或 linter 输出确认确定性问题。只有需要上下文时才用 Read 读取完整文件。对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式。
|
||||
|
||||
### Step 3 — 报告问题清单
|
||||
|
||||
在修改前,先以列表形式输出所有发现的问题:
|
||||
|
||||
```
|
||||
发现 N 个问题:
|
||||
|
||||
[文件] js/foo.js
|
||||
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
|
||||
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
|
||||
|
||||
[文件] js/bar.js
|
||||
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
|
||||
...
|
||||
```
|
||||
|
||||
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
|
||||
|
||||
### Step 4 — 执行修复
|
||||
|
||||
对每个问题,使用 Edit 工具进行**最小化修改**:
|
||||
|
||||
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
|
||||
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
|
||||
- **命名问题**:重命名变量,更新所有使用处
|
||||
- **死代码**:直接删除
|
||||
- **尾部空白/风格**:修正
|
||||
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
|
||||
|
||||
**修复原则:**
|
||||
- 只改在审查清单中发现的问题,不做额外优化
|
||||
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
|
||||
- 改完后用 `grep` 验证旧的坏代码已消失
|
||||
- 优先做精确补丁;只有仓库已有对应格式化流程时,才运行格式化工具
|
||||
|
||||
### Step 5 — 输出总结
|
||||
|
||||
```
|
||||
清理完成:
|
||||
|
||||
修复了 N 个问题:
|
||||
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
|
||||
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 14(4处引用)
|
||||
✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现
|
||||
...
|
||||
|
||||
未修改的问题(需人工确认):
|
||||
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
|
||||
```
|
||||
|
||||
## 约束
|
||||
|
||||
- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export)
|
||||
- **禁止**添加新功能、新抽象、新参数
|
||||
- **禁止**修改注释内容(只删除注释掉的死代码)
|
||||
- **禁止**修改测试文件逻辑
|
||||
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
description: Create or update repository documentation from current code changes
|
||||
argument-hint: Optional: topic to document, or leave empty to infer from git diff
|
||||
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# /docs — Documentation Workflow
|
||||
|
||||
## Goal
|
||||
|
||||
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
|
||||
|
||||
## Repository Rules
|
||||
|
||||
Before deciding scope, check whether the repository has a documentation rules file:
|
||||
|
||||
```bash
|
||||
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
|
||||
```
|
||||
|
||||
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Understand The Change
|
||||
|
||||
```bash
|
||||
git diff HEAD --stat
|
||||
git diff HEAD --name-only
|
||||
git log --oneline -10
|
||||
rg --files docs
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
|
||||
|
||||
```bash
|
||||
git diff HEAD -- <path>
|
||||
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||
```
|
||||
|
||||
### Step 2 — Decide Scope
|
||||
|
||||
- Prefer updating an existing relevant document over creating a duplicate.
|
||||
- Use one document for one coherent topic.
|
||||
- Split documents only when the change crosses meaningful domains.
|
||||
- Keep filenames lowercase and hyphenated.
|
||||
- Apply the repository-specific rules file before writing.
|
||||
|
||||
#### Document Audience Routing (Planet)
|
||||
|
||||
In this repository, classify the action's performer before picking a target file:
|
||||
|
||||
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
|
||||
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
|
||||
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
|
||||
|
||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||
|
||||
### Step 3 — Write
|
||||
|
||||
Explain:
|
||||
|
||||
- Background/problem: what was wrong or missing before.
|
||||
- Core design decisions and rationale.
|
||||
- Operational or user-facing impact.
|
||||
- Relevant code paths, only when useful for future maintainers.
|
||||
|
||||
Style:
|
||||
|
||||
- Follow the repository’s existing language and heading conventions.
|
||||
- Use fenced code blocks with language tags.
|
||||
- Prefer tables for comparisons or parameter lists.
|
||||
- Keep snippets concise and relevant.
|
||||
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
- Read the completed docs once for clarity and stale statements.
|
||||
- Verify referenced paths exist with `test -e` or `rg --files`.
|
||||
- Run applicable checks from `docs/documentation-coverage-rules.md`.
|
||||
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
|
||||
|
||||
### Step 5 — Report
|
||||
|
||||
Summarize changed docs and verification:
|
||||
|
||||
```md
|
||||
Updated:
|
||||
- path/to/doc.md — what changed
|
||||
|
||||
Verified:
|
||||
- checks that passed
|
||||
- checks that could not be run, if any
|
||||
```
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Do not leave placeholder docs.
|
||||
- Do not duplicate bilingual files byte-for-byte.
|
||||
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
|
||||
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
|
||||
- Keep docs maintainable and concise.
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
|
||||
argument-hint: 建议填写任务目标;若同时给出成功标准更好
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /goal-driven — 目标驱动执行模式
|
||||
|
||||
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 长周期实现任务
|
||||
- 高复杂度工程任务
|
||||
- 可被明确验收的研究、实现、迁移、验证类工作
|
||||
|
||||
不适用场景:
|
||||
|
||||
- 纯脑暴
|
||||
- 无法定义成功标准的模糊任务
|
||||
- 很小的一次性修改
|
||||
|
||||
## 输入要求
|
||||
|
||||
若 `$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
|
||||
|
||||
启动时先输出:
|
||||
|
||||
```md
|
||||
Goal
|
||||
- ...
|
||||
|
||||
Criteria for success
|
||||
- ...
|
||||
|
||||
Plan
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Verification
|
||||
- ...
|
||||
```
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 先把任务固化为两个核心块:
|
||||
- `Goal`
|
||||
- `Criteria for success`
|
||||
|
||||
2. 成功标准必须尽量客观,可验证,可落地。
|
||||
优先写成:
|
||||
- 需要交付什么
|
||||
- 需要通过哪些测试或验证
|
||||
- 如何判断结果真的完成
|
||||
|
||||
3. 进入持续执行循环:
|
||||
- 完成一个阶段
|
||||
- 检查当前结果是否满足成功标准
|
||||
- 若未满足,明确剩余差距并继续推进
|
||||
|
||||
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
|
||||
|
||||
5. 如果验证失败:
|
||||
- 明确指出哪条成功标准没满足
|
||||
- 继续工作,不要把阶段性进展误判为完成
|
||||
|
||||
6. 只有在以下情况之一才能停止:
|
||||
- 成功标准已满足
|
||||
- 用户明确要求停止
|
||||
|
||||
## 执行风格
|
||||
|
||||
- 重证据,轻口头判断
|
||||
- 优先使用确定性工具证据:`rg`、`git diff --stat`、`git diff -- <path>`、测试、构建、lint、`curl`、数据库查询等能直接证明成功标准的方式
|
||||
- 不把大段命令输出粘进回复;保留在工具调用里,回复只总结关键证据
|
||||
- 重验收,轻自我感觉
|
||||
- 优先用测试、日志、产物、对比结果来证明完成
|
||||
- 对长期任务保持“未达标就继续”的节奏
|
||||
|
||||
## 简版模板
|
||||
|
||||
```md
|
||||
Goal: [[[[[在此填写最终目标]]]]]
|
||||
|
||||
Criteria for success: [[[[[在此填写成功标准]]]]]
|
||||
|
||||
循环执行:
|
||||
1. 推进任务
|
||||
2. 检查是否满足成功标准
|
||||
3. 若未满足,继续工作
|
||||
4. 直到满足标准或用户明确停止
|
||||
```
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog,运行验证,commit 并 push
|
||||
argument-hint: 可选:feature | bugfix | 或直接描述本次发布内容
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# /release — Planet 发版工作流
|
||||
|
||||
## 版本号规则
|
||||
|
||||
| 变更类型 | 版本跳动 | 适用场景 |
|
||||
|---------|---------|---------|
|
||||
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
|
||||
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
|
||||
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
|
||||
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
|
||||
|
||||
意图混合时以用户明确描述为准;bugfix + 小 feature 混合默认判定为 `improvement`(`+0.0.1`)。
|
||||
|
||||
## 必须同步更新的文件
|
||||
|
||||
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
|
||||
|
||||
- `VERSION`
|
||||
- `frontend/package.json`(`"version"` 字段)
|
||||
- `pyproject.toml`(`version =` 字段)
|
||||
- `uv.lock`(**不要手动编辑**,通过 `uv lock` 重新生成)
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/version-history.md`
|
||||
|
||||
## 节省上下文规则
|
||||
|
||||
发版判断应以确定性 CLI 证据为主,优先使用紧凑命令和定点读取:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat HEAD
|
||||
git diff --name-only HEAD
|
||||
rg -n "version|^## |^Released:|当前开发版本|current" VERSION frontend/package.json pyproject.toml docs/CHANGELOG.md docs/version-history.md
|
||||
```
|
||||
|
||||
除非需要判断某个代码变更是否属于本次发版,否则不要读取完整 diff。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 环境检查
|
||||
|
||||
```bash
|
||||
git branch --show-current # 确认在 dev 分支
|
||||
git status --short # 检查是否有无关的未暂存修改
|
||||
cat VERSION # 读取当前版本
|
||||
```
|
||||
|
||||
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
|
||||
|
||||
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
|
||||
|
||||
### Step 2 — 确定发版类型与新版本号
|
||||
|
||||
- 若 `$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
|
||||
- 否则根据 `git diff --stat HEAD`、`git diff --name-only HEAD`、必要的 focused diff 和 `git log` 推断
|
||||
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`)
|
||||
- **先输出发版计划供用户确认**:
|
||||
|
||||
```
|
||||
发版计划:
|
||||
类型:bugfix
|
||||
版本:0.26.2 → 0.26.3
|
||||
分支:dev
|
||||
将更新:VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
|
||||
```
|
||||
|
||||
### Step 3 — 更新版本号文件
|
||||
|
||||
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
|
||||
|
||||
1. `VERSION` — 直接替换全部内容为新版本号
|
||||
2. `frontend/package.json` — 替换 `"version": "x.x.x"` 行
|
||||
3. `pyproject.toml` — 替换 `version = "x.x.x"` 行
|
||||
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
|
||||
|
||||
### Step 4 — 更新 CHANGELOG.md
|
||||
|
||||
在文件顶部插入新条目,格式:
|
||||
|
||||
```markdown
|
||||
## [x.x.x] — YYYY-MM-DD
|
||||
|
||||
### ✨ Features / 🐛 Fixes / 🔧 Improvements
|
||||
- ...(只列高信号条目,最多 5 条)
|
||||
- ...
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
日期使用 `date +%Y-%m-%d` 获取今天的日期。
|
||||
|
||||
### Step 5 — 更新 docs/version-history.md
|
||||
|
||||
- 更新文件头部的"当前开发版本"字段
|
||||
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
|
||||
|
||||
### Step 6 — 验证
|
||||
|
||||
针对本次变更范围做最小验证:
|
||||
|
||||
- Python 文件有修改:先用 `git diff --name-only HEAD -- '*.py'` 列出,再运行 `python3 -m py_compile <changed_files>`
|
||||
- Frontend 文件有修改:先用 `git diff --name-only HEAD -- frontend` 判断范围,再运行项目标准检查(若无则跳过并说明)
|
||||
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
|
||||
|
||||
```bash
|
||||
cat VERSION
|
||||
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
|
||||
```
|
||||
|
||||
### Step 7 — 提交前预览
|
||||
|
||||
展示将要提交的文件列表:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD
|
||||
```
|
||||
|
||||
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
|
||||
|
||||
### Step 8 — Commit & Push(用户确认后)
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
|
||||
# 若有代码变更也一并 stage
|
||||
git add <code_files>
|
||||
|
||||
git commit -m "release: bump version to x.x.x"
|
||||
git tag vx.x.x
|
||||
git push origin dev
|
||||
git push origin vx.x.x
|
||||
```
|
||||
|
||||
commit message 固定格式:`release: bump version to x.x.x`
|
||||
|
||||
### Step 9 — 完成确认
|
||||
|
||||
输出摘要:
|
||||
|
||||
```
|
||||
✓ 版本号已更新:0.26.2 → 0.26.3
|
||||
✓ CHANGELOG 已更新
|
||||
✓ version-history 已更新
|
||||
✓ uv.lock 已重新生成
|
||||
✓ 验证通过
|
||||
✓ commit: release: bump version to 0.26.3
|
||||
✓ tag: v0.26.3
|
||||
✓ 已 push 到 origin/dev
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
|
||||
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
|
||||
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行
|
||||
302
AGENTS.md
302
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
|
||||
**Planet agent harness. Defines behavior for coding agents working in this repository.**
|
||||
|
||||
---
|
||||
|
||||
@@ -10,26 +10,32 @@ This file is the single authoritative agent guide for the Planet repository.
|
||||
The older lowercase `agents.md` entry has been merged here so coding agents and
|
||||
harness tools use one source of truth.
|
||||
|
||||
### Read First
|
||||
### Source Of Truth
|
||||
|
||||
- `rules.md` is the mandatory repository rule source. Always load `core`,
|
||||
`security`, and `workflow`; load only task-relevant modules after that.
|
||||
- `AGENTS.md` defines the local agent operating mode and evidence gates.
|
||||
- `project_context.md` is background, not a rule source. Prefer newer
|
||||
implementation docs when it disagrees with current code.
|
||||
- `.codex/skills/` is the active specialized workflow layer for cleanup, docs,
|
||||
goal-driven work, and release.
|
||||
- Do not duplicate long workflow text across harness files. Durable constraints
|
||||
belong in `rules.md`; task procedures belong in skills or scripts.
|
||||
|
||||
Read these files before changing code:
|
||||
|
||||
1. `rules.md` - mandatory repository rules. Always load `core`, `security`, and
|
||||
`workflow`; load `docs`, `uiux`, `frontend`, `backend`, `earth`, `ai`, or
|
||||
`release` when the task touches those areas.
|
||||
2. `AGENTS.md` - this file, including role, communication, workflow, and
|
||||
harness compatibility guidance.
|
||||
3. `project_context.md` - static project background. Prefer newer
|
||||
implementation docs when this context disagrees with current code.
|
||||
4. `README.md` - current architecture, startup, and toolchain summary.
|
||||
5. `docs/HARNESS.md` - harness workflow, conflict policy, and validation tiers.
|
||||
6. `CODEMAP.md` - codebase entry points, ownership boundaries, and deeper docs.
|
||||
1. `rules.md`
|
||||
2. `AGENTS.md`
|
||||
3. `project_context.md`
|
||||
4. `README.md`
|
||||
5. `docs/HARNESS.md`
|
||||
6. `CODEMAP.md`
|
||||
|
||||
For documentation work, also read `docs/documentation-coverage-rules.md`.
|
||||
|
||||
### Start Safely
|
||||
|
||||
Before editing:
|
||||
Before broad edits:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
@@ -45,11 +51,6 @@ git diff --name-only HEAD
|
||||
git diff --unified=0 HEAD -- <path>
|
||||
```
|
||||
|
||||
When the user provides screenshots or images, inspect the actual image before
|
||||
making visual claims. If the referenced path is missing, search alternate
|
||||
attachment/worktree/local locations or ask for the file; never guess the image
|
||||
content from text, filenames, or memory.
|
||||
|
||||
Preserve user changes already present in the worktree.
|
||||
|
||||
### Validation
|
||||
@@ -66,8 +67,9 @@ Full local validation:
|
||||
scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
`validate.sh` includes the quick check and the frontend Bun build. Docker image
|
||||
smoke builds are intentionally opt-in:
|
||||
`validate.sh` includes quick checks, frontend Bun build, and frontend smoke
|
||||
unless disabled by its documented environment flags. Docker image smoke builds
|
||||
are intentionally opt-in:
|
||||
|
||||
```bash
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
@@ -108,229 +110,71 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
## Operating Mode
|
||||
|
||||
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
|
||||
|
||||
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
|
||||
- Python FastAPI backend
|
||||
- React admin console, public Docs UI, and browser Web Earth shell
|
||||
- AI Provider model gateway
|
||||
- Multi-source data collection
|
||||
- Future physical display directions such as UE5 / Cesium remain optional
|
||||
roadmap work, not the active local development loop
|
||||
- Default to acting directly when the user gives a clear task.
|
||||
- Ask before acting only when the missing decision is risky, cannot be
|
||||
discovered from repository context, and no conservative assumption is safe.
|
||||
- Read relevant files before editing.
|
||||
- Prefer focused CLI evidence: `rg`, `git diff --stat`, `git diff --name-only`,
|
||||
focused file reads, tests, builds, linters, and harness scripts.
|
||||
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
|
||||
release work, and documentation unless the task requires it.
|
||||
|
||||
---
|
||||
|
||||
## Communication Style
|
||||
## Evidence Gates
|
||||
|
||||
### Tone
|
||||
- **Professional but concise**
|
||||
- Technical accuracy with clarity
|
||||
- No unnecessary verbosity
|
||||
- Use code comments sparingly (explain **why**, not **what**)
|
||||
|
||||
### When Responding
|
||||
1. **Answer directly** - 1-3 sentences for simple questions
|
||||
2. **Use code blocks** for all code snippets
|
||||
3. **Include file:line_number** references when discussing code
|
||||
4. **Never** start with "I am an AI assistant" or similar phrases
|
||||
5. **Never** add unnecessary preambles/postambles
|
||||
|
||||
### Examples
|
||||
|
||||
**Good:**
|
||||
```
|
||||
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
|
||||
```
|
||||
- Visual inputs are blocking evidence. If the user provides a screenshot, image,
|
||||
mock, browser capture, or visual reference, obtain evidence from the artifact
|
||||
before interpreting intent or editing code.
|
||||
- Path resolution is part of the task. If the path cannot be opened, first try
|
||||
reasonable local equivalents such as WSL/Windows path conversion,
|
||||
workspace-relative lookup, absolute paths, and attached-file locations.
|
||||
- Never guess from prompt text, filenames, previous context, logs, OCR, or
|
||||
memory when a visual artifact was provided but cannot be accessed.
|
||||
- OCR is acceptable evidence for text-only visual questions or non-multimodal
|
||||
environments; state that OCR was used as the fallback. Layout, color, spacing,
|
||||
pixel, and rendering issues need real visual inspection or a clear limitation
|
||||
note.
|
||||
- If a visual artifact still cannot be inspected, say so and pause that
|
||||
visual-dependent part of the work.
|
||||
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
|
||||
diff, direct file check, or harness result.
|
||||
- For UI and rendering changes, verify the rendered result when local tooling
|
||||
allows it.
|
||||
|
||||
---
|
||||
|
||||
## Operational Mode
|
||||
## Communication
|
||||
|
||||
### Plan Mode (default for complex tasks)
|
||||
- Analyze requirements
|
||||
- Propose architecture
|
||||
- Confirm with user before execution
|
||||
- **DO NOT** write code until approved
|
||||
|
||||
### Build Mode (after user approval)
|
||||
- Execute the approved plan
|
||||
- Write code, run commands
|
||||
- Verify results
|
||||
- Report completion concisely
|
||||
|
||||
### Read-Only Mode
|
||||
- Analyze code
|
||||
- Explain functionality
|
||||
- Answer questions
|
||||
- **DO NOT** modify files
|
||||
- Match the user's language. Use Chinese for Chinese requests unless the user
|
||||
asks otherwise.
|
||||
- Keep updates short and specific: what is being inspected, edited, or verified.
|
||||
- Final responses should summarize changed files and verification, with blockers
|
||||
stated plainly.
|
||||
- Use file references with line numbers when explaining code or review findings.
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
## Quality Bar
|
||||
|
||||
### When to Ask Before Acting
|
||||
- Unclear requirements
|
||||
- Multiple implementation approaches
|
||||
- Architecture changes
|
||||
- Dependency additions
|
||||
- Anything that could break existing functionality
|
||||
|
||||
### When to Act Directly
|
||||
- Clear, approved requirements
|
||||
- Routine tasks (linting, formatting, running tests)
|
||||
- Following established patterns
|
||||
- Fixing obvious bugs
|
||||
|
||||
### When to Refuse
|
||||
- Malicious code requests
|
||||
- Security violations (secrets, credentials)
|
||||
- Anything that violates `rules.md`
|
||||
- Prefer existing project patterns over new abstractions.
|
||||
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
|
||||
a stable path exists.
|
||||
- Centralize prompts, constants, defaults, and shared request/response handling.
|
||||
- Do not add secrets, generated runtime output, or local environment files.
|
||||
- Frontend commands use Bun only. Do not use `npm`, `pnpm`, or `yarn`.
|
||||
- Run the smallest relevant verification for the changed scope and report
|
||||
anything skipped.
|
||||
|
||||
---
|
||||
|
||||
## Working Principles
|
||||
## Prohibited
|
||||
|
||||
### 1. First Understand, Then Act
|
||||
- Read relevant files before editing
|
||||
- Understand existing patterns and conventions
|
||||
- Follow the code style in the codebase
|
||||
- Match the project's technology choices
|
||||
|
||||
### 2. Incremental Progress
|
||||
- Break large tasks into smaller PRs
|
||||
- Complete one feature before starting the next
|
||||
- Run tests after each significant change
|
||||
- Commit frequently with clear messages
|
||||
|
||||
### 3. Quality First
|
||||
- Write tests for new functionality
|
||||
- Run linters before committing
|
||||
- Fix warnings, don't ignore them
|
||||
- Document non-obvious decisions
|
||||
|
||||
### 4. Communication Clarity
|
||||
- Use precise technical language
|
||||
- Show relevant code, not explanations
|
||||
- Report errors with context
|
||||
- Confirm understanding of requirements
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
Before marking a task complete:
|
||||
|
||||
- [ ] Code follows `rules.md` style guidelines
|
||||
- [ ] Type hints are correct and complete
|
||||
- [ ] Error handling is proper (no silent failures)
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Linting passes
|
||||
- [ ] No TODO comments left behind
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] Commit message is clear
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Feature Development
|
||||
```
|
||||
1. Understand requirements
|
||||
2. Check existing patterns in codebase
|
||||
3. Design solution (brief mental model)
|
||||
4. Write code following rules.md
|
||||
5. Write/run tests
|
||||
6. Lint and format
|
||||
7. Commit with clear message
|
||||
8. Report completion
|
||||
```
|
||||
|
||||
### Bug Fix
|
||||
```
|
||||
1. Reproduce the bug (write failing test)
|
||||
2. Locate the source
|
||||
3. Fix the issue
|
||||
4. Verify test passes
|
||||
5. Check for regressions
|
||||
6. Commit fix
|
||||
```
|
||||
|
||||
### Refactoring
|
||||
```
|
||||
1. Understand current behavior
|
||||
2. Design target state
|
||||
3. Make incremental changes
|
||||
4. Preserve tests
|
||||
5. Verify functionality
|
||||
6. Clean up dead code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### WebSocket Services
|
||||
- Implement heartbeat mechanism (30-second intervals)
|
||||
- Handle disconnection gracefully
|
||||
- Include camera position in control frames
|
||||
- Support both update and full sync modes
|
||||
|
||||
### Data Collectors
|
||||
- Inherit from BaseCollector
|
||||
- Implement fetch() and transform() methods
|
||||
- Support incremental updates
|
||||
- Handle API changes gracefully
|
||||
|
||||
### UE5 Integration
|
||||
- Communicate via WebSocket
|
||||
- Send data frames at configurable intervals (default 5 min)
|
||||
- Support auto-cruise and manual modes
|
||||
- Optimize for 4K@120Hz rendering
|
||||
|
||||
### Multi-User Security
|
||||
- JWT tokens with 15-minute expiration
|
||||
- Redis token blacklist for logout
|
||||
- Role-based access control (RBAC)
|
||||
- Audit logging for all actions
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
### When Writing Code
|
||||
```python
|
||||
# File: backend/app/services/collectors/top500.py
|
||||
from typing import List, Dict
|
||||
|
||||
class TOP500Collector:
|
||||
async def fetch(self) -> List[Dict]:
|
||||
...
|
||||
```
|
||||
|
||||
### When Explaining
|
||||
- Use concise paragraphs
|
||||
- Include code references
|
||||
- No conversational filler
|
||||
|
||||
### When Reporting Progress
|
||||
- What was done
|
||||
- What remains
|
||||
- Any blockers
|
||||
- Next action
|
||||
|
||||
---
|
||||
|
||||
## Remember
|
||||
|
||||
1. **Rules are hard constraints** - follow `rules.md` absolutely
|
||||
2. **Context provides understanding** - use `project_context.md` for background
|
||||
3. **Role defines behavior** - follow `AGENTS.md` for how to work
|
||||
4. **Quality over speed** - Enterprise systems require precision
|
||||
5. **Communicate clearly** - Precision in, precision out
|
||||
- Do not skip visual evidence handling when a visual artifact was provided.
|
||||
- Do not preserve obsolete harness files just because they already exist.
|
||||
- Do not invent behavior not present in code, docs, or verified external
|
||||
sources.
|
||||
- Do not rewrite unrelated files during cleanup.
|
||||
- Do not mark a task complete without checking concrete success criteria.
|
||||
|
||||
@@ -168,10 +168,12 @@
|
||||
|
||||
## 快速启动
|
||||
|
||||
入口需要先具备 `zsh`、`curl` 和可访问的软件源。Ubuntu / Ubuntu WSL 上,`init` 会自动检测并补装 Docker Engine、Compose v2 和 Buildx,启动 Docker 服务并配置当前用户的访问权限;需要系统权限时会提示输入 sudo 密码。其他系统请先准备可用的 Docker 环境。
|
||||
|
||||
```bash
|
||||
# 新机器或空项目首次初始化
|
||||
./planet.sh init
|
||||
# 会自动安装/检查 uv、bun,同步 Python/前端依赖
|
||||
# 会先准备 Docker / Compose / Buildx,再安装/检查 uv、bun 并同步 Python/前端依赖
|
||||
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
|
||||
# 会启动 PostgreSQL/Redis,并创建表、默认数据源和本地默认用户
|
||||
|
||||
|
||||
@@ -8,6 +8,39 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.74.3] — 2026-09-13
|
||||
|
||||
Released: 2026-09-13
|
||||
|
||||
### Highlights
|
||||
- Ubuntu / WSL 新机器初始化会自动检测并准备 Docker Engine、Compose v2、Buildx 和当前用户权限,减少手工安装步骤。
|
||||
- 数据库初始化先核对容器端口与后端真实连接,连接和认证通过后才创建表和默认数据。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 区分 Docker CLI 缺失、服务未安装、daemon 不可用及 socket 权限不足,修正未安装 Docker 时误提示启动 socket 的诊断。
|
||||
- 自动补齐缺失的 Docker 依赖并启动本地服务,以原用户身份刷新 Docker 组权限;保留参数和 PATH,不依赖 sg。
|
||||
- 通过 Compose 同步已有 PostgreSQL / Redis 容器配置,保留端口冲突等具体错误;端口映射异常时最多保留数据卷重建一次 PostgreSQL。
|
||||
- 新增后端数据库只读连接检查,对认证、库名和网络失败给出不含密码或完整连接串的诊断。
|
||||
- 将 Docker 与数据库启动隔离回归测试接入快速检查,并同步 README、harness 和中英文运维说明。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.2] — 2026-07-01
|
||||
|
||||
Released: 2026-07-01
|
||||
|
||||
### Highlights
|
||||
- 收敛 agent harness 到 `rules.md`、`AGENTS.md`、`docs/HARNESS.md` 和 `.codex/skills/`,删除重复维护的旧 Claude command 入口。
|
||||
- 强化视觉证据规则:截图或视觉引用路径打不开时必须先处理 WSL/Windows 路径、相对路径和附件位置,而不是跳过后猜测。
|
||||
- 明确 OCR 可作为文本类视觉证据或非多模态环境 fallback,同时要求布局、颜色、像素和渲染类问题保留真实视觉验证或明确限制说明。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- `AGENTS.md` 替换旧 opencode/默认 Plan Mode 内容,保留最新单一入口和 harness 验证说明。
|
||||
- `rules.md` 与 `docs/HARNESS.md` 同步 Visual Evidence Gate,补齐路径解析、访问失败报告和 OCR fallback 边界。
|
||||
- 删除 `.claude/commands/*` 中与 `.codex/skills/*` 重复的旧 cleanup/docs/goal-driven/release 入口,并更新文档受众计划中的旧路径引用。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.1] — 2026-06-30
|
||||
|
||||
Released: 2026-06-30
|
||||
|
||||
@@ -135,11 +135,18 @@ PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
|
||||
- Treat user-provided screenshots and images as primary visual evidence. If a
|
||||
screenshot contradicts written text, inspect the image first and explicitly
|
||||
call out the mismatch before deciding what to change.
|
||||
- If a referenced screenshot path cannot be opened, do not infer image content
|
||||
from the filename, alt text, surrounding prose, or memory. Search the current
|
||||
thread attachments, repository, and obvious local attachment/download
|
||||
locations; if the image still cannot be found, ask for the file or report the
|
||||
blocker instead of guessing.
|
||||
- Path resolution is part of visual evidence handling. If a referenced
|
||||
screenshot path cannot be opened, try reasonable local equivalents first:
|
||||
WSL/Windows path conversion, workspace-relative paths, absolute paths, current
|
||||
thread attachments, repository files, and obvious local attachment/download
|
||||
locations.
|
||||
- If the image still cannot be found or opened, report the exact path/access
|
||||
blocker instead of guessing. Do not infer image content from the filename, alt
|
||||
text, surrounding prose, logs, or memory.
|
||||
- OCR is acceptable evidence for text-only questions or non-multimodal
|
||||
environments; state when OCR was the fallback. Layout, color, spacing, pixel,
|
||||
and rendering issues still require a real visual inspection or an explicit
|
||||
"could not verify visually" note.
|
||||
- Same-category UI surfaces must use one visual system per product area. Badges,
|
||||
chips, pills, tags, status labels, small buttons, cards, panels, and toolbar
|
||||
controls should reuse the shared component, shared token, or established CSS
|
||||
@@ -213,9 +220,26 @@ Optional for delivery smoke:
|
||||
- Docker daemon for image builds
|
||||
- Helm for chart lint/template checks
|
||||
|
||||
If a required local tool is missing, do not install system software
|
||||
automatically. Report the gap and point to `./planet.sh init` or
|
||||
`scripts/bootstrap-dev.sh` as the existing bootstrap path.
|
||||
For routine harness validation, do not install missing system software
|
||||
automatically. Report the gap and point to the explicit bootstrap entry points.
|
||||
`./planet.sh init` can install missing Docker Engine, Compose v2, and Buildx on
|
||||
Ubuntu / Ubuntu WSL, start the local service, and configure Docker group access.
|
||||
This bootstrap behavior is intentional; do not invoke it merely to make harness
|
||||
checks pass. `scripts/bootstrap-dev.sh` only prepares application dependencies.
|
||||
|
||||
Docker bootstrap regression checks use isolated command stubs and never install
|
||||
packages or modify the host daemon:
|
||||
|
||||
```bash
|
||||
uv run --frozen --project . python scripts/harness/test_docker_bootstrap.py
|
||||
uv run --frozen --project . python scripts/harness/test_database_startup.py
|
||||
```
|
||||
|
||||
Database startup regressions also run in quick-check. They cover Compose
|
||||
reconciliation of existing containers, visible startup errors, published-port
|
||||
checks, bounded recreation that preserves volumes, and the backend connection
|
||||
gate before schema initialization. Their command stubs and driver mocks do not
|
||||
modify the host Docker environment.
|
||||
|
||||
## What Agents Must Not Change Automatically
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**校正日期**:2026-06-26,控制台深链已从旧 tab 查询口径更新为当前 `?section=` 口径。
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.codex/skills/docs/SKILL.md`,让以后写文档时自动按受众归档。
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
|
||||
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
|
||||
|
||||
## .claude/commands/docs.md 增量
|
||||
## `.codex/skills/docs/SKILL.md` 增量
|
||||
|
||||
在 "Step 2 — Decide Scope" 后插一段:
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
|
||||
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
|
||||
- `docs/documentation-coverage-rules.md` — 加受众分层段
|
||||
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
|
||||
- `.codex/skills/docs/SKILL.md` — 加 Document Audience Routing 段
|
||||
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook` 到 `DOCS_METADATA`(`docs_admin` 组)
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
|
||||
|
||||
## Docker Initialization and Access
|
||||
|
||||
Initialize a new machine before starting the application services:
|
||||
|
||||
```bash
|
||||
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
|
||||
```
|
||||
|
||||
The entry point still requires `zsh`, `curl`, and reachable package repositories. Before synchronizing Python and frontend dependencies, `init` prepares Docker:
|
||||
|
||||
- Reuse working Docker, Compose v2, and Buildx (at least 0.17.0).
|
||||
- On Ubuntu / Ubuntu WSL, use apt to install the missing parts of `docker.io`, `docker-compose-v2`, and `docker-buildx`. When Docker CE CLI is already installed, use the configured Docker CE repository and corresponding plugin packages to keep the package family consistent.
|
||||
- If the local daemon is unavailable, check that `docker.service` exists, then enable and start it. WSL must have systemd enabled; unavailable service management produces an explicit Docker preparation error.
|
||||
- If the current user cannot read and write the Docker socket, check for `usermod`, install its `passwd` package when needed, and add the user to the `docker` group. This group grants privileged control of the local Docker engine. The script uses `sudo` to refresh group access as the original user and continue the original command with its arguments preserved. It does not depend on `sg` or run application processes as root.
|
||||
|
||||
When elevation is needed, sudo authentication runs in the foreground. Missing sudo for an unprivileged user, authentication failure, repository errors, or insufficient versions after installation stop initialization with a specific error.
|
||||
|
||||
Subsequent `planet.sh start` and other service commands in the same old terminal also refresh Docker group membership when it has been granted but is not yet active. Open a new Ubuntu session to use `docker` directly in the terminal.
|
||||
|
||||
When Docker Desktop is present but its WSL integration is unavailable, the script asks the operator to start Desktop and enable WSL Integration for the distribution. Unreachable remote or rootless endpoints produce a diagnostic for that environment; neither case installs a second local engine automatically. Automatic installation on other operating systems is not currently supported.
|
||||
|
||||
`planet.sh` calls `scripts/lib/docker-bootstrap.zsh` for preparation. Missing CLI, missing service units, socket permissions, and stopped daemons receive separate diagnostics. Advice to start `docker.socket` is shown only after confirming that the unit exists. Verify the result with:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
docker compose version
|
||||
docker buildx version
|
||||
```
|
||||
|
||||
## Database Initialization and Connection Checks
|
||||
|
||||
`init` reconciles PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
|
||||
|
||||
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, `init` runs `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1` before reporting database readiness or creating tables and seed data.
|
||||
|
||||
- If the actual local port mapping is still missing or mismatched, the script recreates PostgreSQL once from Compose while preserving its data volume, then checks again. A second failure stops initialization.
|
||||
- Authentication, database-name, and network failures stop before schema changes. Diagnostics show the host, port, and database name without passwords, full connection strings, or raw driver exceptions.
|
||||
- A process-level `DATABASE_URL` overrides `backend/.env`. Changing `POSTGRES_PASSWORD` alone updates neither the connection string nor the password stored in an existing data volume. Existing environment files are retained and their effective configuration must be checked.
|
||||
- Explicit external databases do not require a local container mapping. Host networking also does not require published ports. Both still require the real connection check.
|
||||
|
||||
For `port is already allocated` or `address already in use`, inspect `docker ps` port information and `ss -ltnp '( sport = :5432 )'`. With WSL mirrored networking, also inspect Windows listeners. Initialization does not kill other database services to acquire a port, delete data volumes, or reset passwords.
|
||||
|
||||
## First Startup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
|
||||
|
||||
## Docker 初始化与访问权限
|
||||
|
||||
新机器应先执行初始化,再启动应用服务:
|
||||
|
||||
```bash
|
||||
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
|
||||
```
|
||||
|
||||
脚本入口仍需先安装 `zsh`、`curl`,并保证软件源可访问。`init` 在同步 Python 和前端依赖之前准备 Docker:
|
||||
|
||||
- 已有可用的 Docker、Compose v2 和 Buildx(至少 0.17.0)时直接复用。
|
||||
- Ubuntu / Ubuntu WSL 缺少依赖时,通过 apt 安装 `docker.io`、`docker-compose-v2`、`docker-buildx` 中缺失的部分。若已安装 Docker CE CLI,则使用已配置的 Docker CE 软件源和对应插件包,避免混用软件包系列。
|
||||
- 本地 Docker daemon 未运行时,确认 `docker.service` 存在后启用并启动它。WSL 必须启用 systemd;如果服务管理不可用,脚本会在 Docker 准备阶段明确报错。
|
||||
- 当前用户不能读写 Docker socket 时,检查并补装提供 `usermod` 的 `passwd` 包,将用户加入 `docker` 组。该组拥有管理本机 Docker 的高权限。脚本使用 `sudo` 以原用户身份刷新组权限并继续原命令,保留参数,不依赖 `sg`,也不会把应用进程改为 root 用户运行。
|
||||
|
||||
需要提权时,脚本会在前台请求 sudo 认证。普通用户缺少 sudo、认证失败、软件源不可用或安装后版本仍不满足要求时,初始化会停止并报告具体原因。
|
||||
|
||||
同一旧终端随后执行 `planet.sh start` 等命令时,也会检测已加入但尚未生效的 Docker 组权限并刷新。若要在终端直接使用 `docker`,重新打开 Ubuntu 会话即可。
|
||||
|
||||
Docker Desktop 已存在但 WSL 集成不可用时,脚本提示启动 Desktop 并启用当前发行版的 WSL Integration。已有远程或 rootless endpoint 无法连接时,提示检查当前环境;这些情况不会自动安装另一套本地引擎。其他操作系统的自动安装暂未支持。
|
||||
|
||||
安装逻辑由 `planet.sh` 调用 `scripts/lib/docker-bootstrap.zsh`;缺少 CLI、没有服务单元、socket 权限不足和 daemon 未启动会分别诊断。仅在确认 `docker.socket` 单元存在时才给出启动该单元的建议。验证准备结果可执行:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
docker compose version
|
||||
docker buildx version
|
||||
```
|
||||
|
||||
## 数据库初始化与连接检查
|
||||
|
||||
`init` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
|
||||
|
||||
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`;通过后才显示“数据库服务已就绪”并创建表和默认数据。
|
||||
|
||||
- 如果本地实际端口映射仍缺失或不匹配,脚本会保留数据卷,按 Compose 配置重建一次 PostgreSQL 并重新检查;再次失败就停止。
|
||||
- 认证、库名或网络错误会在建表前停止,诊断只显示目标主机、端口和库名,不输出密码、完整连接串或驱动异常原文。
|
||||
- 进程环境变量中的 `DATABASE_URL` 优先于 `backend/.env`。单独修改 `POSTGRES_PASSWORD` 不会自动更新连接串,也不会改变已有数据卷内的密码。已有环境文件会保留,需要核对其有效配置。
|
||||
- 显式配置的外部数据库不要求本地容器端口匹配;host 网络模式也不要求发布端口,两者仍须通过实际连接检查。
|
||||
|
||||
出现 `port is already allocated` 或 `address already in use` 时,检查 `docker ps` 的端口信息和 `ss -ltnp '( sport = :5432 )'`;WSL 镜像网络下还需检查 Windows 侧监听。初始化不会为了占用数据库端口而自动结束其他数据库服务,也不会删除数据卷或重设密码。
|
||||
|
||||
## 首次启动
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.3`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 |
|
||||
| `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills,删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 |
|
||||
| `0.74.1` | improvement | `dev` | `pending` | 将品牌标识上传收敛到 Logo/标题图字段内,新增字段级拖拽反馈和 Tactile UI primary 上传按钮,并同步中英文使用文档 |
|
||||
| `0.74.0` | feature | `dev` | `pending` | 扩展统一 i18n 到 Web Earth 动态入口、控制台/API 错误和公开页面,修复 Earth 通知胶囊、语言 switch、品牌栏、legend、tooltip、新闻/TV 英文态裁切与中文残留,并加入 harness 回归覆盖 |
|
||||
| `0.73.0` | feature | `dev` | `pending` | 新增前端统一 i18n、控制台语言/主题偏好入口、英文态 legacy 过渡翻译和 admin 一屏/状态指示器布局验证 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.74.1",
|
||||
"version": "0.74.3",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
117
planet.sh
117
planet.sh
@@ -229,6 +229,8 @@ prepare_uv_build_config() {
|
||||
|
||||
prepare_uv_build_config
|
||||
|
||||
source "$SCRIPT_DIR/scripts/lib/docker-bootstrap.zsh"
|
||||
|
||||
# Shell / Docker helpers
|
||||
is_pid() {
|
||||
[[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
|
||||
@@ -373,14 +375,6 @@ write_port_state() {
|
||||
chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
compose_available() {
|
||||
docker compose version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
compose_v1_available() {
|
||||
command -v docker-compose >/dev/null 2>&1
|
||||
}
|
||||
|
||||
report_missing_compose() {
|
||||
clear_wait_spinner
|
||||
log_error "未检测到可用的 Docker Compose"
|
||||
@@ -394,27 +388,9 @@ report_missing_compose() {
|
||||
return 1
|
||||
}
|
||||
|
||||
buildx_version() {
|
||||
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
|
||||
}
|
||||
|
||||
buildx_meets_minimum() {
|
||||
local current_version="$1"
|
||||
local required_version="$2"
|
||||
|
||||
[ -n "$current_version" ] || return 1
|
||||
[ -n "$required_version" ] || return 1
|
||||
|
||||
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
log_buildx_diagnostics_if_needed() {
|
||||
local current_buildx_version=""
|
||||
local required_buildx_version="0.17.0"
|
||||
local required_buildx_version="$DOCKER_MIN_BUILDX_VERSION"
|
||||
|
||||
current_buildx_version="$(buildx_version)"
|
||||
|
||||
@@ -440,6 +416,10 @@ compose_up() {
|
||||
if docker compose "${args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
if ! compose_v1_available; then
|
||||
log_error "Docker Compose 执行失败: docker compose ${arg_text}"
|
||||
return 1
|
||||
fi
|
||||
log_warn "docker compose 执行失败,回退到 docker-compose v1"
|
||||
fi
|
||||
|
||||
@@ -1298,47 +1278,6 @@ format_wait_elapsed_seconds() {
|
||||
'
|
||||
}
|
||||
|
||||
docker_daemon_available() {
|
||||
docker info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
log_docker_daemon_diagnostics() {
|
||||
local docker_service_status=""
|
||||
local docker_socket_status=""
|
||||
local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker"
|
||||
|
||||
docker_service_status="$(systemctl is-active docker 2>/dev/null || true)"
|
||||
docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)"
|
||||
|
||||
if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then
|
||||
log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。"
|
||||
log_note "请确认当前用户已加入 docker 组,并重新登录会话。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$docker_service_status" = "active" ]; then
|
||||
log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。"
|
||||
log_note "请先执行: docker info"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then
|
||||
log_note "检测到 Docker daemon 当前未正常运行。"
|
||||
log_note "可先执行: ${service_hint}"
|
||||
log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$docker_socket_status" != "active" ]; then
|
||||
log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。"
|
||||
log_note "可先执行: ${service_hint}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_note "当前无法连接 Docker daemon,请先执行: docker info"
|
||||
log_note "若 daemon 未启动,可尝试: ${service_hint}"
|
||||
}
|
||||
|
||||
print_database_failure_diagnostics() {
|
||||
if ! docker_daemon_available; then
|
||||
log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
|
||||
@@ -2505,15 +2444,16 @@ wait_for_postgres_health() {
|
||||
}
|
||||
|
||||
start_database_services() {
|
||||
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||
# Reconcile existing containers too; docker start cannot apply changed port bindings.
|
||||
compose_up up -d --no-deps postgres redis
|
||||
}
|
||||
|
||||
restart_database_services() {
|
||||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||
start_database_services && docker restart planet_postgres planet_redis
|
||||
}
|
||||
|
||||
start_postgres_service() {
|
||||
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
|
||||
compose_up up -d --no-deps postgres
|
||||
}
|
||||
|
||||
# Backend lifecycle helpers
|
||||
@@ -4046,6 +3986,21 @@ asyncio.run(init_db())
|
||||
PY
|
||||
}
|
||||
|
||||
verify_backend_database_connection() {
|
||||
local check_status=0
|
||||
local check_command=("$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/scripts/check_database_connection.py")
|
||||
|
||||
set_wait_detail "验证后端实际 PostgreSQL 地址、端口与认证"
|
||||
run_command_with_spinner "验证后端数据库连接" "${check_command[@]}" || check_status=$?
|
||||
# Exit 2 means missing published ports; credentials and other failures must not recreate data services.
|
||||
[ "$check_status" -eq 2 ] || return "$check_status"
|
||||
|
||||
log_warn "PostgreSQL 实际端口映射不匹配,保留数据卷并按 Compose 配置重建一次"
|
||||
compose_up up -d --no-deps --force-recreate postgres || return 1
|
||||
wait_for_postgres_health || return 1
|
||||
run_command_with_spinner "重新验证后端数据库连接" "${check_command[@]}"
|
||||
}
|
||||
|
||||
planet_app_services_running() {
|
||||
local backend_port=""
|
||||
local frontend_port=""
|
||||
@@ -4109,6 +4064,14 @@ init() {
|
||||
|
||||
print_splash
|
||||
|
||||
start_wait_session "准备 Docker 运行环境"
|
||||
if ! ensure_docker_runtime "${PLANET_COMMAND_ARGS[@]}"; then
|
||||
stop_wait_session
|
||||
exit 1
|
||||
fi
|
||||
stop_wait_session
|
||||
log_success "Docker / Compose / Buildx 已就绪"
|
||||
|
||||
start_wait_session "准备 Python 运行时"
|
||||
ensure_python_runtime
|
||||
stop_wait_session
|
||||
@@ -4132,6 +4095,11 @@ init() {
|
||||
|
||||
start_wait_session "启动数据库服务"
|
||||
ensure_database_services_healthy
|
||||
if ! verify_backend_database_connection; then
|
||||
stop_wait_session
|
||||
log_error "后端数据库连接检查失败,尚未执行建表和默认数据初始化"
|
||||
exit 1
|
||||
fi
|
||||
stop_wait_session
|
||||
log_success "数据库服务已就绪"
|
||||
|
||||
@@ -4677,9 +4645,16 @@ parse_global_args() {
|
||||
|
||||
trap cleanup_failed_start EXIT
|
||||
|
||||
PLANET_COMMAND_ARGS=("$@")
|
||||
parse_global_args "$@"
|
||||
set -- "${GLOBAL_ARG_REMAINDER[@]}"
|
||||
|
||||
case "$1" in
|
||||
init|start|restart|stop|health|log|createuser)
|
||||
refresh_docker_group "${PLANET_COMMAND_ARGS[@]}" || exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$1" in
|
||||
init)
|
||||
shift
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.74.1"
|
||||
version = "0.74.3"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
17
rules.md
17
rules.md
@@ -161,6 +161,13 @@ rg -n "api[_-]?key|client_secret|BEGIN .*PRIVATE KEY|AKIA[0-9A-Z]" .
|
||||
|
||||
Always.
|
||||
|
||||
### Harness
|
||||
|
||||
- `rules.md` is the active repository rule source. Keep durable constraints here instead of duplicating them across prompt files.
|
||||
- Use `.codex/skills/` for specialized Codex workflows such as cleanup, docs, goal-driven work, and release.
|
||||
- Do not add new legacy harness entry points when an existing skill or rule module can carry the same instruction.
|
||||
- If a harness rule is no longer true for the current toolchain, update or delete it in the same cleanup pass.
|
||||
|
||||
### Git
|
||||
|
||||
- Do not revert user changes unless explicitly requested.
|
||||
@@ -173,6 +180,16 @@ Always.
|
||||
- Prefer maintained, widely used libraries.
|
||||
- Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json`.
|
||||
|
||||
### Visual Evidence Gate
|
||||
|
||||
- If the user provides a screenshot, image, video frame, visual mock, browser capture, or says something like "as shown", obtain evidence from that artifact before interpreting the request or changing code.
|
||||
- Path resolution is part of the task. If a provided path does not open, try reasonable local equivalents first, such as WSL/Windows path conversion, workspace-relative paths, absolute paths, and attached-file locations.
|
||||
- If the artifact still cannot be found or opened, stop that visual-dependent work and report the exact path/access problem instead of guessing. Ask for an accessible file/path or a fresh screenshot.
|
||||
- Do not infer visual intent from the filename, surrounding text, alt text, logs, or prior assumptions.
|
||||
- OCR is acceptable evidence for text-only questions or when the active environment lacks multimodal image viewing, but state that OCR was the fallback. Layout, color, spacing, pixel, and rendering issues still require a real visual inspection or an explicit "could not verify visually" note.
|
||||
- After inspecting the artifact, ground the work in at least one concrete observed detail when it affects the task.
|
||||
- For UI or rendering fixes that depend on appearance, verify with a real screenshot or browser render when the project can be run locally.
|
||||
|
||||
### Deterministic Context
|
||||
|
||||
- Prefer compact CLI evidence over reading large files or full diffs:
|
||||
|
||||
134
scripts/check_database_connection.py
Normal file
134
scripts/check_database_connection.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the backend's PostgreSQL target before init is allowed to change its schema."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import URL, make_url
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONNECT_TIMEOUT_SECONDS = 10
|
||||
DEFAULT_POSTGRES_PORT = 5432
|
||||
POSTGRES_CONTAINER = "planet_postgres"
|
||||
POSTGRES_CONTAINER_PORT = "5432/tcp"
|
||||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
class DatabaseReadinessError(RuntimeError):
|
||||
"""A safe, actionable diagnostic that does not include connection credentials."""
|
||||
|
||||
|
||||
class MissingPortBindingError(DatabaseReadinessError):
|
||||
"""Exit 2 asks planet.sh to reconcile the managed container once, preserving its volume."""
|
||||
|
||||
|
||||
def backend_database_url() -> str:
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
from app.core.config import settings
|
||||
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def check_published_port(url: URL) -> None:
|
||||
if url.host not in LOOPBACK_HOSTS:
|
||||
return # An explicitly configured external database has no local container mapping.
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.HostConfig.NetworkMode}}\n{{json .NetworkSettings.Ports}}",
|
||||
POSTGRES_CONTAINER,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=CONNECT_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise DatabaseReadinessError(
|
||||
"无法读取 planet_postgres;请检查当前 Docker context 和容器状态。"
|
||||
)
|
||||
network_mode, ports_json = result.stdout.strip().split("\n", 1)
|
||||
if network_mode == "host":
|
||||
return # Host networking intentionally has no published-port table.
|
||||
ports = json.loads(ports_json) or {}
|
||||
bindings = ports.get(POSTGRES_CONTAINER_PORT) or []
|
||||
port = str(url.port or DEFAULT_POSTGRES_PORT)
|
||||
if not any(binding.get("HostPort") == port for binding in bindings):
|
||||
raise MissingPortBindingError(
|
||||
f"planet_postgres 未向宿主机发布后端配置的端口 {port};"
|
||||
"请核对 Compose ports、DATABASE_URL 和 Docker context。"
|
||||
"容器内部健康不代表宿主机可连接。"
|
||||
)
|
||||
|
||||
|
||||
async def check_connection(url: URL) -> None:
|
||||
engine = create_async_engine(url, poolclass=NullPool, echo=False)
|
||||
try:
|
||||
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
|
||||
async with engine.connect() as connection:
|
||||
await connection.execute(text("SELECT 1"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def connection_diagnostic(error: BaseException) -> str:
|
||||
pending = [error]
|
||||
visited: set[int] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if id(current) in visited:
|
||||
continue
|
||||
visited.add(id(current))
|
||||
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||||
if sqlstate in {"28P01", "28000"}:
|
||||
return (
|
||||
"PostgreSQL 认证失败:核对 DATABASE_URL 的账号密码及连接目标。"
|
||||
"只改 POSTGRES_PASSWORD 不会更新 DATABASE_URL,也不会重设已有数据卷的密码。"
|
||||
)
|
||||
if sqlstate == "3D000":
|
||||
return "目标数据库不存在:核对 DATABASE_URL 的库名与已有数据库。"
|
||||
if isinstance(current, (TimeoutError, OSError)):
|
||||
return "数据库连接被拒绝、超时或地址不可达:核对端口映射、监听服务和 Docker endpoint。"
|
||||
for nested in (getattr(current, "orig", None), current.__cause__, current.__context__):
|
||||
if isinstance(nested, BaseException):
|
||||
pending.append(nested)
|
||||
return "数据库连接检查失败:核对 backend/.env、进程环境变量和目标 PostgreSQL 服务日志。"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
url = make_url(backend_database_url())
|
||||
print(
|
||||
f"后端数据库目标: host={url.host!r} port={url.port or DEFAULT_POSTGRES_PORT} database={url.database!r}"
|
||||
)
|
||||
print(
|
||||
"DATABASE_URL 来源: "
|
||||
+ ("进程环境变量" if "DATABASE_URL" in os.environ else "backend 配置")
|
||||
)
|
||||
check_published_port(url)
|
||||
asyncio.run(check_connection(url))
|
||||
except MissingPortBindingError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
except DatabaseReadinessError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 1
|
||||
except Exception as error:
|
||||
# Driver/config exceptions can contain DSNs and passwords. Never echo their raw text.
|
||||
print(connection_diagnostic(error), file=sys.stderr)
|
||||
return 1
|
||||
print("后端 PostgreSQL 连接与认证已通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -76,6 +76,8 @@ main() {
|
||||
check_file docs/harness-audit.md
|
||||
check_file docs/documentation-coverage-rules.md
|
||||
check_file planet.sh
|
||||
check_file scripts/lib/docker-bootstrap.zsh
|
||||
check_file scripts/harness/test_docker_bootstrap.py
|
||||
check_file pyproject.toml
|
||||
check_file frontend/package.json
|
||||
check_file scripts/harness/security-check.sh
|
||||
|
||||
@@ -13,6 +13,9 @@ main() {
|
||||
harness_run scripts/harness/doctor.sh
|
||||
harness_run git diff --check
|
||||
harness_run zsh -n planet.sh
|
||||
harness_run zsh -n scripts/lib/docker-bootstrap.zsh
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_bootstrap.py
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_database_startup.py
|
||||
harness_run bash -n scripts/bootstrap-dev.sh
|
||||
harness_run bash -n scripts/harness/lib.sh
|
||||
harness_run bash -n scripts/harness/doctor.sh
|
||||
|
||||
274
scripts/harness/test_database_startup.py
Normal file
274
scripts/harness/test_database_startup.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Database lifecycle regressions without starting or changing host containers."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from asyncpg import InvalidCatalogNameError, InvalidPasswordError
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import check_database_connection as probe # noqa: E402
|
||||
|
||||
TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/planet_db")
|
||||
|
||||
|
||||
def shell_function(name: str) -> str:
|
||||
source = (ROOT / "planet.sh").read_text()
|
||||
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing shell function: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
def run_shell(functions: list[str], setup: str, action: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["zsh", "-f"],
|
||||
input="\n".join(["set -e", *(shell_function(name) for name in functions), setup, action]),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseLifecycleTests(unittest.TestCase):
|
||||
def test_existing_container_gets_current_compose_configuration(self) -> None:
|
||||
for function in ("start_database_services", "start_postgres_service"):
|
||||
with self.subTest(function=function):
|
||||
result = run_shell(
|
||||
[function],
|
||||
"""
|
||||
mapped=0
|
||||
docker() { return 0; }
|
||||
compose_up() { mapped=1; }
|
||||
""",
|
||||
f"{function}\n[ $mapped -eq 1 ]",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_port_conflict_is_visible_and_cannot_fall_back_to_old_container(self) -> None:
|
||||
result = run_shell(
|
||||
["start_database_services"],
|
||||
"""
|
||||
docker() { return 0; }
|
||||
compose_up() { echo 'port is already allocated' >&2; return 1; }
|
||||
""",
|
||||
"start_database_services",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("port is already allocated", result.stderr)
|
||||
|
||||
def test_installed_compose_failure_is_not_reported_as_missing_compose(self) -> None:
|
||||
result = run_shell(
|
||||
["compose_up"],
|
||||
"""
|
||||
compose_available() { return 0; }
|
||||
compose_v1_available() { return 1; }
|
||||
docker() { echo 'address already in use' >&2; return 1; }
|
||||
set_wait_detail() { :; }
|
||||
log_warn() { echo "$*"; }
|
||||
log_error() { echo "$*"; }
|
||||
report_missing_compose() { echo MISSING_COMPOSE; return 1; }
|
||||
""",
|
||||
"compose_up up -d postgres",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("address already in use", result.stderr)
|
||||
self.assertNotIn("MISSING_COMPOSE", result.stdout)
|
||||
|
||||
def run_init(self, connection_status: int) -> subprocess.CompletedProcess[str]:
|
||||
return run_shell(
|
||||
["init"],
|
||||
f"""
|
||||
for fn in parse_service_args guard_init_when_services_running print_splash \
|
||||
start_wait_session stop_wait_session ensure_docker_runtime ensure_python_runtime \
|
||||
sync_python_deps sync_frontend_deps ensure_planet_env_files \
|
||||
prepare_motion_agent_host_dependencies set_wait_detail; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
log_success() {{ echo "$*"; }}
|
||||
log_error() {{ echo "$*"; }}
|
||||
log_step() {{ :; }}
|
||||
log_note() {{ :; }}
|
||||
ensure_database_services_healthy() {{ echo CONTAINERS_HEALTHY; }}
|
||||
verify_backend_database_connection() {{ echo CONNECTION_CHECK; return {connection_status}; }}
|
||||
run_command_with_spinner() {{ shift; "$@"; }}
|
||||
initialize_backend_database() {{ echo SCHEMA_INITIALIZED; }}
|
||||
""",
|
||||
"init --non-motion-agent",
|
||||
)
|
||||
|
||||
def test_healthy_containers_do_not_allow_schema_changes_on_bad_connection(self) -> None:
|
||||
result = self.run_init(1)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("CONNECTION_CHECK", result.stdout)
|
||||
self.assertNotIn("SCHEMA_INITIALIZED", result.stdout)
|
||||
self.assertNotIn("数据库服务已就绪", result.stdout)
|
||||
|
||||
def test_real_connection_is_checked_before_reporting_ready_and_creating_tables(self) -> None:
|
||||
result = self.run_init(0)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("CONNECTION_CHECK", result.stdout)
|
||||
self.assertLess(
|
||||
result.stdout.index("CONNECTION_CHECK"), result.stdout.index("数据库服务已就绪")
|
||||
)
|
||||
self.assertLess(
|
||||
result.stdout.index("数据库服务已就绪"), result.stdout.index("SCHEMA_INITIALIZED")
|
||||
)
|
||||
|
||||
def test_only_missing_ports_trigger_one_volume_preserving_recreation(self) -> None:
|
||||
for first_status, retry_status, expected_status, expected_recreates in (
|
||||
(0, 0, 0, 0),
|
||||
(1, 0, 1, 0),
|
||||
(2, 0, 0, 1),
|
||||
(2, 2, 2, 1),
|
||||
):
|
||||
with self.subTest(first_status=first_status, retry_status=retry_status):
|
||||
result = run_shell(
|
||||
["verify_backend_database_connection"],
|
||||
f"""
|
||||
checks=0
|
||||
set_wait_detail() {{ :; }}
|
||||
log_warn() {{ :; }}
|
||||
compose_up() {{ echo "COMPOSE $*"; }}
|
||||
wait_for_postgres_health() {{ return 0; }}
|
||||
run_command_with_spinner() {{
|
||||
checks=$((checks + 1))
|
||||
if [ $checks -eq 1 ]; then return {first_status}; fi
|
||||
return {retry_status}
|
||||
}}
|
||||
""",
|
||||
"verify_backend_database_connection",
|
||||
)
|
||||
self.assertEqual(result.returncode, expected_status, result.stderr)
|
||||
self.assertEqual(result.stdout.count("COMPOSE"), expected_recreates)
|
||||
if expected_recreates:
|
||||
self.assertIn("up -d --no-deps --force-recreate postgres", result.stdout)
|
||||
self.assertNotIn("--renew-anon-volumes", result.stdout)
|
||||
|
||||
|
||||
class DatabaseProbeTests(unittest.TestCase):
|
||||
def docker_result(
|
||||
self, ports: dict | None, mode: str = "bridge"
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess([], 0, f"{mode}\n{json.dumps(ports)}\n", "")
|
||||
|
||||
def test_missing_port_blocks_sql_even_if_container_is_healthy(self) -> None:
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", return_value=TEST_URL),
|
||||
patch.object(probe.subprocess, "run", return_value=self.docker_result({})),
|
||||
patch.object(probe, "check_connection", new_callable=AsyncMock) as connect,
|
||||
redirect_stderr(output),
|
||||
redirect_stdout(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 2)
|
||||
connect.assert_not_called()
|
||||
self.assertIn("未向宿主机发布", output.getvalue())
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
|
||||
def test_actual_backend_port_must_match_published_port(self) -> None:
|
||||
result = self.docker_result({"5432/tcp": [{"HostIp": "0.0.0.0", "HostPort": "15432"}]})
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError):
|
||||
probe.check_published_port(TEST_URL)
|
||||
probe.check_published_port(TEST_URL.set(port=15432))
|
||||
|
||||
def test_host_networking_does_not_require_a_port_mapping(self) -> None:
|
||||
with patch.object(probe.subprocess, "run", return_value=self.docker_result(None, "host")):
|
||||
probe.check_published_port(TEST_URL)
|
||||
|
||||
def test_external_database_does_not_require_a_local_mapping(self) -> None:
|
||||
with patch.object(probe.subprocess, "run") as inspect:
|
||||
probe.check_published_port(TEST_URL.set(host="configured-db.example"))
|
||||
inspect.assert_not_called()
|
||||
|
||||
def test_docker_failure_does_not_echo_raw_output(self) -> None:
|
||||
result = subprocess.CompletedProcess([], 1, "", "sensitive test-secret")
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError) as error:
|
||||
probe.check_published_port(TEST_URL)
|
||||
self.assertIn("Docker context", str(error.exception))
|
||||
self.assertNotIn("test-secret", str(error.exception))
|
||||
|
||||
def test_authentication_error_is_actionable_without_leaking_dsn_or_query_secrets(self) -> None:
|
||||
output = io.StringIO()
|
||||
url = TEST_URL.update_query_dict({"sslpassword": "query-secret"})
|
||||
error = DBAPIError(
|
||||
None, None, InvalidPasswordError(url.render_as_string(hide_password=False))
|
||||
)
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", return_value=url),
|
||||
patch.object(probe, "check_published_port"),
|
||||
patch.object(probe, "check_connection", new_callable=AsyncMock, side_effect=error),
|
||||
redirect_stderr(output),
|
||||
redirect_stdout(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 1)
|
||||
self.assertIn("认证失败", output.getvalue())
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
self.assertNotIn("query-secret", output.getvalue())
|
||||
self.assertNotIn("Traceback", output.getvalue())
|
||||
|
||||
def test_missing_database_and_network_errors_have_distinct_diagnostics(self) -> None:
|
||||
self.assertIn("数据库不存在", probe.connection_diagnostic(InvalidCatalogNameError()))
|
||||
for error in (ConnectionRefusedError(), TimeoutError()):
|
||||
self.assertIn("地址不可达", probe.connection_diagnostic(error))
|
||||
|
||||
def test_invalid_config_never_echoes_validation_input(self) -> None:
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", side_effect=ValueError("test-secret")),
|
||||
redirect_stderr(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 1)
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
|
||||
|
||||
class DatabaseConnectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_connection_deadline_stops_a_stalled_probe(self) -> None:
|
||||
async def stall() -> None:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.dispose = AsyncMock()
|
||||
engine.connect.return_value.__aenter__.side_effect = stall
|
||||
with (
|
||||
patch.object(probe, "create_async_engine", return_value=engine),
|
||||
patch.object(probe, "CONNECT_TIMEOUT_SECONDS", 0.001),
|
||||
):
|
||||
with self.assertRaises(TimeoutError):
|
||||
await probe.check_connection(TEST_URL)
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
async def test_probe_only_selects_and_always_disposes_the_engine(self) -> None:
|
||||
for error in (None, InvalidPasswordError("test-secret")):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
engine = MagicMock()
|
||||
engine.dispose = AsyncMock()
|
||||
connection = AsyncMock()
|
||||
engine.connect.return_value.__aenter__.return_value = connection
|
||||
connection.execute.side_effect = error
|
||||
with patch.object(probe, "create_async_engine", return_value=engine):
|
||||
if error:
|
||||
with self.assertRaises(InvalidPasswordError):
|
||||
await probe.check_connection(TEST_URL)
|
||||
else:
|
||||
await probe.check_connection(TEST_URL)
|
||||
self.assertEqual(str(connection.execute.call_args.args[0]), "SELECT 1")
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
332
scripts/harness/test_docker_bootstrap.py
Normal file
332
scripts/harness/test_docker_bootstrap.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Exercise Docker bootstrap with isolated command stubs, never the host daemon."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE = ROOT / "scripts/lib/docker-bootstrap.zsh"
|
||||
|
||||
COMMAND_STUB = r"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
state_path = Path(os.environ["DOCKER_TEST_STATE"])
|
||||
state = json.loads(state_path.read_text())
|
||||
name = Path(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
state["calls"].append([name, args])
|
||||
|
||||
def save() -> None:
|
||||
state_path.write_text(json.dumps(state))
|
||||
|
||||
def finish(code: int = 0, output: str = "") -> None:
|
||||
save()
|
||||
if output:
|
||||
print(output)
|
||||
sys.exit(code)
|
||||
|
||||
def install_command(name: str) -> None:
|
||||
target = state_path.parent / "bin" / name
|
||||
if not target.exists():
|
||||
target.symlink_to("mock-command")
|
||||
|
||||
if name == "id":
|
||||
if args == ["-u"]:
|
||||
finish(output=str(state["uid"]))
|
||||
if args == ["-un"]:
|
||||
finish(output="planet-test")
|
||||
finish(output="planet-test" + (" docker" if state["member"] else ""))
|
||||
if name == "sudo":
|
||||
if state.get("sudo_denied"):
|
||||
finish(1)
|
||||
if args == ["-v"]:
|
||||
finish()
|
||||
if "-E" in args:
|
||||
finish() # Record re-exec argv without executing planet.sh on this machine.
|
||||
save()
|
||||
sys.exit(subprocess.call(args[1:] if args[0] == "--" else args))
|
||||
if name == "apt-get":
|
||||
if state.get("apt_fail"):
|
||||
finish(1, "simulated apt failure")
|
||||
if args[0] == "install":
|
||||
for package in args[2:]:
|
||||
if package in ("docker.io", "docker-ce"):
|
||||
state["engine"] = True
|
||||
install_command("docker")
|
||||
install_command("dockerd")
|
||||
if package in ("docker-compose-v2", "docker-compose-plugin"):
|
||||
state["compose"] = True
|
||||
if package in ("docker-buildx", "docker-buildx-plugin"):
|
||||
state["buildx"] = "0.20.0"
|
||||
if package == "passwd":
|
||||
install_command("usermod")
|
||||
finish()
|
||||
if name == "dpkg-query":
|
||||
finish(0 if state.get("ce") else 1, "install ok installed" if state.get("ce") else "")
|
||||
if name == "systemctl":
|
||||
if args[0] == "show":
|
||||
unit_exists = state["engine"] and (args[-1] != "docker.socket" or state["socket_unit"])
|
||||
finish(output="loaded" if unit_exists else "not-found")
|
||||
if args[0] == "is-active":
|
||||
finish(output="active" if state["running"] else "inactive")
|
||||
if state.get("service_fail"):
|
||||
finish(1, "simulated service failure")
|
||||
state["running"] = True
|
||||
finish()
|
||||
if name == "usermod":
|
||||
state["member"] = True
|
||||
finish()
|
||||
if name == "mock-socket-access":
|
||||
finish(0 if state["running"] and state["uid"] != 0 and not state["access"] else 1)
|
||||
if name == "docker":
|
||||
if args[0] == "info":
|
||||
finish(0 if state["running"] and (state["uid"] == 0 or state["access"]) else 1)
|
||||
if args[:2] == ["context", "inspect"]:
|
||||
finish(output=state["endpoint"])
|
||||
if args[:2] == ["compose", "version"]:
|
||||
finish(0 if state["compose"] else 1)
|
||||
if args[:2] == ["buildx", "version"]:
|
||||
finish(0 if state["buildx"] else 1, "github.com/docker/buildx v" + state["buildx"])
|
||||
finish(1)
|
||||
"""
|
||||
|
||||
|
||||
class DockerBootstrapTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="planet-docker-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.folder = Path(self.temporary.name)
|
||||
self.bin = self.folder / "bin"
|
||||
self.bin.mkdir()
|
||||
self.state_file = self.folder / "state.json"
|
||||
self.state = {
|
||||
"calls": [],
|
||||
"uid": 1000,
|
||||
"member": False,
|
||||
"access": False,
|
||||
"engine": False,
|
||||
"running": False,
|
||||
"compose": False,
|
||||
"buildx": "",
|
||||
"endpoint": "unix:///var/run/docker.sock",
|
||||
"socket_unit": True,
|
||||
}
|
||||
stub = self.bin / "mock-command"
|
||||
stub.write_text(f"#!{sys.executable}\n" + COMMAND_STUB)
|
||||
stub.chmod(0o755)
|
||||
for name in ("id", "sudo", "apt-get", "systemctl", "dpkg-query", "mock-socket-access"):
|
||||
(self.bin / name).symlink_to("mock-command")
|
||||
for name in ("zsh", "env", "sort", "head", "tail", "readlink", "awk", "sed"):
|
||||
executable = shutil.which(name)
|
||||
self.assertIsNotNone(executable, f"test prerequisite missing: {name}")
|
||||
(self.bin / name).symlink_to(executable)
|
||||
|
||||
def existing_engine(self, *, running: bool = True, access: bool = True) -> None:
|
||||
for name in ("docker", "dockerd"):
|
||||
(self.bin / name).symlink_to("mock-command")
|
||||
self.state.update(
|
||||
engine=True, running=running, access=access, compose=True, buildx="0.20.0"
|
||||
)
|
||||
|
||||
def run_bootstrap(
|
||||
self,
|
||||
action: str = "ensure_docker_runtime",
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
arguments: list[str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.state_file.write_text(json.dumps(self.state))
|
||||
env = os.environ.copy()
|
||||
for name in ("DOCKER_HOST", "DOCKER_CONTEXT", "PLANET_DOCKER_GROUP_REEXEC"):
|
||||
env.pop(name, None)
|
||||
env.update(
|
||||
PATH=str(self.bin),
|
||||
DOCKER_TEST_STATE=str(self.state_file),
|
||||
TEST_OS="ubuntu",
|
||||
TEST_DESKTOP="0",
|
||||
PLANET_STATE_DIR=str(self.folder),
|
||||
)
|
||||
env.update(extra_env or {})
|
||||
arguments = arguments or ["init", "--non-motion-agent"]
|
||||
prelude = f"""
|
||||
set -e
|
||||
SCRIPT_DIR={shlex.quote(str(self.folder / "repo with ' quotes"))}
|
||||
source {shlex.quote(str(MODULE))}
|
||||
log_note() {{ print -r -- "$*"; }}
|
||||
log_error() {{ print -r -- "$*"; }}
|
||||
log_step() {{ print -r -- "$*"; }}
|
||||
docker_bootstrap_os_id() {{ print -r -- "$TEST_OS"; }}
|
||||
docker_desktop_present() {{ [[ "$TEST_DESKTOP" == 1 ]]; }}
|
||||
docker_socket_needs_group_access() {{ mock-socket-access; }}
|
||||
run_command_quiet_unless_verbose() {{ local output="$1"; shift; "$@" > "$output" 2>&1; }}
|
||||
ensure_system_command() {{ command -v "$1" >/dev/null || docker_as_root apt-get install -y "$2"; }}
|
||||
{action} {shlex.join(arguments)}
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[str(self.bin / "zsh"), "-f", "-c", prelude],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
self.state = json.loads(self.state_file.read_text())
|
||||
return result
|
||||
|
||||
def calls(self, name: str) -> list[list[str]]:
|
||||
return [args for command, args in self.state["calls"] if command == name]
|
||||
|
||||
def test_working_environment_is_reused_without_privilege_or_installation(self) -> None:
|
||||
self.existing_engine()
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
|
||||
def test_fresh_root_install_starts_and_validates_engine(self) -> None:
|
||||
self.state["uid"] = 0
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get"),
|
||||
[
|
||||
["update", "--error-on=any"],
|
||||
["install", "-y", "docker.io", "docker-compose-v2", "docker-buildx"],
|
||||
],
|
||||
)
|
||||
self.assertIn(["enable", "--now", "docker.service"], self.calls("systemctl"))
|
||||
self.assertTrue(self.state["running"])
|
||||
|
||||
def test_fresh_user_installs_missing_usermod_and_reexecutes_without_sg(self) -> None:
|
||||
args = ["--verbose", "init", "--non-motion-agent", "a 'quoted' $(argument)"]
|
||||
result = self.run_bootstrap(arguments=args)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn(["install", "-y", "passwd"], self.calls("apt-get"))
|
||||
self.assertEqual(self.calls("usermod"), [["-aG", "docker", "planet-test"]])
|
||||
reexec = self.calls("sudo")[-1]
|
||||
self.assertEqual(reexec[:7], ["-E", "-u", "planet-test", "-g", "docker", "--", "env"])
|
||||
self.assertEqual(reexec[7], "PLANET_DOCKER_GROUP_REEXEC=1")
|
||||
self.assertEqual(reexec[8], f"PATH={self.bin}")
|
||||
self.assertEqual(reexec[10], str(self.folder / "repo with ' quotes/planet.sh"))
|
||||
self.assertEqual(reexec[11:], args)
|
||||
self.assertFalse((self.bin / "sg").exists())
|
||||
|
||||
def test_existing_group_membership_refreshes_start_without_installing(self) -> None:
|
||||
self.existing_engine(access=False)
|
||||
self.state["member"] = True
|
||||
result = self.run_bootstrap("refresh_docker_group", arguments=["start", "--allow-lan"])
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("sudo")[-1][-2:], ["start", "--allow-lan"])
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_failed_group_refresh_does_not_loop(self) -> None:
|
||||
self.existing_engine(access=False)
|
||||
self.state["member"] = True
|
||||
result = self.run_bootstrap(
|
||||
"refresh_docker_group", extra_env={"PLANET_DOCKER_GROUP_REEXEC": "1"}
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
|
||||
def test_stopped_existing_engine_is_started_without_installation(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
self.assertTrue(self.state["running"])
|
||||
|
||||
def test_missing_or_outdated_plugins_are_installed_without_replacing_engine(self) -> None:
|
||||
self.existing_engine()
|
||||
self.state.update(compose=False, buildx="0.16.0")
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get")[-1], ["install", "-y", "docker-compose-v2", "docker-buildx"]
|
||||
)
|
||||
|
||||
def test_existing_docker_ce_keeps_its_package_family(self) -> None:
|
||||
self.existing_engine()
|
||||
self.state.update(compose=False, buildx="", ce=True)
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get")[-1],
|
||||
["install", "-y", "docker-compose-plugin", "docker-buildx-plugin"],
|
||||
)
|
||||
|
||||
def test_install_failure_stops_before_daemon_start(self) -> None:
|
||||
self.state["apt_fail"] = True
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
self.assertIn("simulated apt failure", result.stdout)
|
||||
|
||||
def test_missing_sudo_reports_requirement_without_attempting_install(self) -> None:
|
||||
(self.bin / "sudo").unlink()
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("缺少 sudo", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_desktop_and_nonlocal_contexts_are_not_replaced(self) -> None:
|
||||
result = self.run_bootstrap(extra_env={"TEST_DESKTOP": "1"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Docker Desktop", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_working_remote_environment_is_reused(self) -> None:
|
||||
self.existing_engine()
|
||||
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
|
||||
def test_unsupported_os_has_an_explicit_error(self) -> None:
|
||||
result = self.run_bootstrap(extra_env={"TEST_OS": "debian"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("自动安装目前支持 Ubuntu", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_missing_cli_diagnostic_never_suggests_a_socket_unit(self) -> None:
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("未找到 Docker CLI", result.stdout)
|
||||
self.assertNotIn("systemctl", result.stdout)
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
|
||||
def test_missing_service_diagnostic_never_suggests_a_socket_unit(self) -> None:
|
||||
(self.bin / "docker").symlink_to("mock-command")
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertIn("未检测到可用的 docker.service", result.stdout)
|
||||
self.assertNotIn("enable --now docker.socket", result.stdout)
|
||||
|
||||
def test_service_without_socket_unit_gets_service_only_hint(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
self.state["socket_unit"] = False
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertIn("enable --now docker.service", result.stdout)
|
||||
self.assertNotIn("enable --now docker.socket", result.stdout)
|
||||
|
||||
def test_daemon_start_failure_is_not_reported_as_database_failure(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
self.state["service_fail"] = True
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Docker Engine 启动失败", result.stdout)
|
||||
self.assertNotIn("数据库启动失败", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
239
scripts/lib/docker-bootstrap.zsh
Normal file
239
scripts/lib/docker-bootstrap.zsh
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
# Docker prerequisites for planet.sh; lifecycle and logging remain in planet.sh.
|
||||
DOCKER_MIN_BUILDX_VERSION="0.17.0"
|
||||
|
||||
compose_available() {
|
||||
docker compose version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
compose_v1_available() {
|
||||
command -v docker-compose >/dev/null 2>&1
|
||||
}
|
||||
|
||||
buildx_version() {
|
||||
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
|
||||
}
|
||||
|
||||
buildx_meets_minimum() {
|
||||
local current_version="$1"
|
||||
local required_version="$2"
|
||||
|
||||
[ -n "$current_version" ] || return 1
|
||||
[ -n "$required_version" ] || return 1
|
||||
|
||||
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
docker_daemon_available() {
|
||||
command -v docker >/dev/null 2>&1 && command docker info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
docker_uses_local_engine() {
|
||||
local endpoint="${DOCKER_HOST:-}"
|
||||
if [ -n "${DOCKER_CONTEXT:-}" ] || [ -z "$endpoint" ]; then
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
endpoint="$(command docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null)" || return 1
|
||||
elif [ -n "${DOCKER_CONTEXT:-}" ]; then
|
||||
return 1
|
||||
else
|
||||
endpoint="unix:///var/run/docker.sock"
|
||||
fi
|
||||
fi
|
||||
case "$endpoint" in
|
||||
unix:///var/run/docker.sock|unix:///run/docker.sock) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
docker_desktop_present() {
|
||||
local docker_path=""
|
||||
[ -d /mnt/wsl/docker-desktop ] && return 0
|
||||
docker_path="$(command -v docker 2>/dev/null || true)"
|
||||
[ -n "$docker_path" ] || return 1
|
||||
docker_path="$(readlink -f "$docker_path" 2>/dev/null || true)"
|
||||
[[ "$docker_path" == */docker-desktop/* ]]
|
||||
}
|
||||
|
||||
docker_systemd_unit_exists() {
|
||||
command -v systemctl >/dev/null 2>&1 || return 1
|
||||
[ "$(systemctl show --property=LoadState --value "$1" 2>/dev/null)" = "loaded" ]
|
||||
}
|
||||
|
||||
docker_socket_needs_group_access() {
|
||||
[ -S /var/run/docker.sock ] && { [ ! -r /var/run/docker.sock ] || [ ! -w /var/run/docker.sock ]; }
|
||||
}
|
||||
|
||||
docker_require_sudo() {
|
||||
[ "$(id -u)" -eq 0 ] && return 0
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
log_error "缺少 sudo,无法安装或配置 Docker;请先由管理员安装 sudo 并授予当前用户权限。"
|
||||
return 1
|
||||
fi
|
||||
# Authenticate in the foreground, before command output is redirected to a log.
|
||||
sudo -v
|
||||
}
|
||||
|
||||
docker_as_root() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
"$@"
|
||||
else
|
||||
sudo -- "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
refresh_docker_group() {
|
||||
[ "$(id -u)" -ne 0 ] || return 0
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
docker_uses_local_engine || return 0
|
||||
docker_socket_needs_group_access || return 0
|
||||
|
||||
local username="$(id -un)"
|
||||
local groups="$(id -nG "$username")"
|
||||
[[ " $groups " == *" docker "* ]] || return 0
|
||||
if [ "${PLANET_DOCKER_GROUP_REEXEC:-0}" = "1" ]; then
|
||||
log_error "刷新组权限后仍无法访问 Docker socket,请检查 socket 的属组和权限。"
|
||||
return 1
|
||||
fi
|
||||
docker_require_sudo || return 1
|
||||
log_note "刷新 Docker 组权限,以当前用户继续执行。"
|
||||
# Preserve WSL interop/tool paths even when sudo applies secure_path.
|
||||
# argv stays an array: repository paths and arguments are never evaluated as shell text.
|
||||
exec sudo -E -u "$username" -g docker -- env PLANET_DOCKER_GROUP_REEXEC=1 "PATH=$PATH" \
|
||||
"$(command -v zsh)" "$SCRIPT_DIR/planet.sh" "$@"
|
||||
}
|
||||
|
||||
docker_bootstrap_os_id() {
|
||||
[ -r /etc/os-release ] || return 1
|
||||
(. /etc/os-release; printf '%s\n' "$ID")
|
||||
}
|
||||
|
||||
install_docker_prerequisites() {
|
||||
local log_file="$PLANET_STATE_DIR/docker_install.log"
|
||||
local engine_package="docker.io"
|
||||
local compose_package="docker-compose-v2"
|
||||
local buildx_package="docker-buildx"
|
||||
local -a packages=()
|
||||
|
||||
if [ "$(docker_bootstrap_os_id)" != "ubuntu" ] || ! command -v apt-get >/dev/null 2>&1; then
|
||||
log_error "Docker 自动安装目前支持 Ubuntu / Ubuntu WSL;请先安装本系统的 Docker Engine、Compose v2 和 Buildx。"
|
||||
return 1
|
||||
fi
|
||||
if command -v dpkg-query >/dev/null 2>&1 &&
|
||||
[ "$(dpkg-query -W -f='${Status}' docker-ce-cli 2>/dev/null)" = "install ok installed" ]; then
|
||||
engine_package="docker-ce"
|
||||
compose_package="docker-compose-plugin"
|
||||
buildx_package="docker-buildx-plugin"
|
||||
fi
|
||||
if ! command -v docker >/dev/null 2>&1 ||
|
||||
{ ! docker_daemon_available && ! command -v dockerd >/dev/null 2>&1; }; then
|
||||
packages+=("$engine_package")
|
||||
fi
|
||||
compose_available || packages+=("$compose_package")
|
||||
buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION" || packages+=("$buildx_package")
|
||||
[ "${#packages[@]}" -gt 0 ] || return 0
|
||||
|
||||
docker_require_sudo || return 1
|
||||
log_step "安装 Docker 依赖: ${packages[*]}"
|
||||
if ! run_command_quiet_unless_verbose "$log_file" docker_as_root apt-get update --error-on=any ||
|
||||
! run_command_quiet_unless_verbose "$log_file" docker_as_root apt-get install -y "${packages[@]}"; then
|
||||
log_error "Docker 依赖安装失败,请检查软件源、网络和 apt 错误。"
|
||||
tail -20 "$log_file" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
rehash
|
||||
}
|
||||
|
||||
start_local_docker_engine() {
|
||||
local log_file="$PLANET_STATE_DIR/docker_service.log"
|
||||
if ! docker_systemd_unit_exists docker.service; then
|
||||
log_error "没有可用的 docker.service;请确认 Docker Engine 已安装且 systemd 正常运行。"
|
||||
log_note "Ubuntu WSL 可在 /etc/wsl.conf 的 [boot] 下启用 systemd=true,再重启 WSL。"
|
||||
return 1
|
||||
fi
|
||||
docker_require_sudo || return 1
|
||||
if ! run_command_quiet_unless_verbose "$log_file" docker_as_root systemctl enable --now docker.service; then
|
||||
log_error "Docker Engine 启动失败。"
|
||||
tail -20 "$log_file" 2>/dev/null || true
|
||||
log_note "查看原因: journalctl -u docker.service -n 50 --no-pager"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_docker_user_access() {
|
||||
[ "$(id -u)" -ne 0 ] || return 0
|
||||
docker_socket_needs_group_access || return 0
|
||||
docker_require_sudo || return 1
|
||||
ensure_system_command usermod passwd "usermod" || return 1
|
||||
log_note "将当前用户加入 docker 组(该组可管理本机 Docker)。"
|
||||
docker_as_root usermod -aG docker "$(id -un)" || return 1
|
||||
refresh_docker_group "$@"
|
||||
}
|
||||
|
||||
ensure_docker_runtime() {
|
||||
if docker_daemon_available && compose_available &&
|
||||
buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION"; then
|
||||
return 0
|
||||
fi
|
||||
if docker_desktop_present; then
|
||||
log_error "检测到 Docker Desktop,但当前 WSL 的 Docker / Compose / Buildx 尚不可用。"
|
||||
log_note "请启动 Docker Desktop,并为当前发行版启用 WSL Integration。"
|
||||
return 1
|
||||
fi
|
||||
if ! docker_uses_local_engine; then
|
||||
log_error "当前 Docker 使用其他 context 或远程/rootless endpoint,请先检查该环境的连接和插件。"
|
||||
return 1
|
||||
fi
|
||||
install_docker_prerequisites || return 1
|
||||
if ! command -v docker >/dev/null 2>&1 || ! compose_available ||
|
||||
! buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION"; then
|
||||
log_error "安装后 Docker CLI、Compose v2 或 Buildx >= ${DOCKER_MIN_BUILDX_VERSION} 仍不可用。"
|
||||
return 1
|
||||
fi
|
||||
if ! docker_daemon_available; then
|
||||
start_local_docker_engine || return 1
|
||||
ensure_docker_user_access "$@" || return 1
|
||||
fi
|
||||
if ! docker_daemon_available; then
|
||||
log_error "Docker 已安装,但当前用户仍无法连接 daemon。"
|
||||
log_docker_daemon_diagnostics
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
log_docker_daemon_diagnostics() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
log_note "未找到 Docker CLI,尚未安装 Docker;Ubuntu / WSL 请先执行 ./planet.sh init。"
|
||||
return 0
|
||||
fi
|
||||
if docker_desktop_present; then
|
||||
log_note "请启动 Docker Desktop,并确认当前 WSL 已启用 WSL Integration。"
|
||||
return 0
|
||||
fi
|
||||
if ! docker_uses_local_engine; then
|
||||
log_note "当前使用其他 Docker endpoint;请检查 docker context ls 和 docker info。"
|
||||
return 0
|
||||
fi
|
||||
if docker_socket_needs_group_access; then
|
||||
log_note "Docker socket 存在,但当前用户没有读写权限;请执行 ./planet.sh init 配置 docker 组。"
|
||||
return 0
|
||||
fi
|
||||
if ! docker_systemd_unit_exists docker.service; then
|
||||
log_note "未检测到可用的 docker.service;Ubuntu / WSL 请执行 ./planet.sh init 检查安装。"
|
||||
return 0
|
||||
fi
|
||||
if [ "$(systemctl is-active docker.service 2>/dev/null)" = "active" ]; then
|
||||
log_note "Docker 服务已启动,但 CLI 无法连接;请检查 docker context ls 和 docker info。"
|
||||
return 0
|
||||
fi
|
||||
if docker_systemd_unit_exists docker.socket; then
|
||||
log_note "可先执行: sudo systemctl enable --now docker.socket && sudo systemctl restart docker.service"
|
||||
else
|
||||
log_note "可先执行: sudo systemctl enable --now docker.service"
|
||||
fi
|
||||
log_note "如仍失败,查看: journalctl -u docker.service -n 50 --no-pager"
|
||||
}
|
||||
Reference in New Issue
Block a user