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
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:
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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 回归覆盖 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.74.2",
|
||||
"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.2"
|
||||
version = "0.74.3"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
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