release: bump version to 0.74.3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled

This commit is contained in:
rayd1o
2026-09-13 02:17:55 +08:00
parent 1dd2921674
commit a54fcdbeed
17 changed files with 1160 additions and 80 deletions

View File

@@ -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并创建表、默认数据源和本地默认用户

View File

@@ -1 +1 @@
0.74.2
0.74.3

View File

@@ -8,6 +8,23 @@ 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

View File

@@ -220,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

View File

@@ -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

View File

@@ -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

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.74.2`
- `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 回归覆盖 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.74.2",
"version": "0.74.3",
"private": true,
"packageManager": "bun@1",
"dependencies": {

117
planet.sh
View File

@@ -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

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.74.2"
version = "0.74.3"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

View 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())

View File

@@ -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

View File

@@ -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

View 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()

View 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)

View 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尚未安装 DockerUbuntu / 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.serviceUbuntu / 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"
}

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.74.2"
version = "0.74.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },