Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60eefb19c9 |
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
aiprovider:
|
||||
image: ${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}
|
||||
@@ -32,7 +30,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: planet_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:${PLANET_POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -45,7 +43,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: planet_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:${PLANET_REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.74.7] — 2026-09-17
|
||||
|
||||
Released: 2026-09-17
|
||||
|
||||
### Highlights
|
||||
- 启动进度统一显示在状态副行,成功清除、失败保留具体错误并退出,减少演示前的静默等待和重复报错。
|
||||
- 镜像仓库网络故障支持一次临时备用源回退,校验具体镜像 manifest 并锁定 digest,不改写全局镜像源。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 为公开 Python、uv、PostgreSQL 和 Redis 镜像提供 DaoCloud 回退及可配置前缀;认证、证书、限流和 Dockerfile 依赖安装错误不误触发回退。
|
||||
- 数据库启动前识别其他项目的端口占用,按后端实际配置同步本机映射,保留命名数据卷;修复停止后 TIME_WAIT 被误判为监听占用。
|
||||
- Docker 命令增加有界等待、私有日志和进程组清理;失败副行保留实际原因,避免外层汇总及清理通知覆盖。
|
||||
- 修复 Python 依赖清华源回退前过早冻结错误状态,移除 Compose 失败后的旧手动启动分支。
|
||||
- 同步中英文排障手册,补齐镜像回退、非网络错误、digest 校验、端口复用及失败退出回归,并验证实际备用镜像构建与项目启动。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.6] — 2026-09-16
|
||||
|
||||
Released: 2026-09-16
|
||||
|
||||
@@ -80,7 +80,7 @@ The `aiprovider` dependency group in the root `pyproject.toml` uses the same `uv
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose or the manual `docker run` fallback. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority:
|
||||
Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose; a failed Compose operation stops with its error instead of falling back to a manual launch. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority:
|
||||
|
||||
1. `aiprovider/.env`
|
||||
2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc`
|
||||
|
||||
@@ -302,7 +302,7 @@ One-time codes are stored in Redis under `otp:{purpose}:{email}` with a 600-seco
|
||||
|
||||
## Error Cause Catalog
|
||||
|
||||
Every planet.sh log_error retains its context and reads its diagnostic code, cause and remedy directly from the Chinese table. AI Provider build failures also inspect the full build log. Rows are matched in order using case-insensitive literal fragments separated by semicolons; specific errors precede summaries. Codes are diagnostic identifiers, not process exit codes; failure still returns a nonzero status. Matching fragments below intentionally retain the Chinese shell messages.
|
||||
By default, planet.sh retains one specific error beneath the main status. With --verbose, log_error reads its diagnostic code, cause and remedy from the Chinese table. AI Provider build failures also inspect the full build log. Rows are matched in order using case-insensitive literal fragments separated by semicolons; specific errors precede summaries. Codes are diagnostic identifiers, not process exit codes; failure still returns a nonzero status. Matching fragments below intentionally retain the Chinese shell messages.
|
||||
|
||||
For each newly confirmed cause, update both language tables with a stable code, distinguishing evidence and a regression case before integrating it into the script. Unverified failures remain P_UNKNOWN; the script must not append guessed causes to documentation. scripts/lib/error-diagnostics.zsh reads the table. Do not put vertical bars in cells. scripts/harness/test_error_diagnostics.py checks classification, bilingual codes and runtime wording.
|
||||
|
||||
@@ -335,6 +335,7 @@ For each newly confirmed cause, update both language tables with a stable code,
|
||||
| P_LOCKFILE_CHANGED | 修改了 uv.lock | Dependency preparation unexpectedly changed the lockfile. | Check manifest and lockfile consistency; use frozen installation without implicit lockfile updates. |
|
||||
| P_DEPENDENCIES | 安装失败;安装后仍不可用;安装完成后仍未找到;未找到 .venv/bin/python;自动安装后仍无法解析运行时;未找到 Vite Bun 入口;缺少 mediapipe/opencv-python;仍无法导入 mediapipe/opencv-python;需要 openssl | A required dependency failed to install, is missing or is unavailable in the active environment. | Inspect the installer log, network, package sources and PATH; use Bun for frontend and the project uv environment for Python. |
|
||||
| P_ARGUMENT | 未知参数;非法端口;需要端口号;需要逗号分隔;--motion-agent-mode 需要;--motion-agent-wsl-usbipd-busid 需要;用法: ./planet.sh | The command or an argument does not match the supported format. | Check ./planet.sh usage and correct the arguments before retrying. |
|
||||
| P_DB_PORT_OCCUPIED | PLANET_DB_PORT_OCCUPIED | Another container or host service occupies the Planet database or Redis port. | Identify the owner with docker ps; update the DATABASE_URL port or REDIS_PORT in backend/.env (process REDIS_URL takes precedence). planet.sh synchronizes Compose mappings without stopping other projects or deleting volumes. |
|
||||
| P_PORT | 地址已被占用;端口仍不可用;清理失败,请检查占用进程;port is already allocated;address already in use | The requested port is occupied or cannot be bound in the host environment. | Check ss and Windows Get-NetTCPConnection; identify the owner before changing ports or stopping the service. |
|
||||
| P_CAMERA | live 模式缺少可用摄像头;未找到可打开并能读帧的摄像头 | Motion Agent cannot capture frames from a usable camera. | Check hardware, permissions and WSL USB forwarding; use --non-motion-agent or explicit dry-run when live capture is not needed. |
|
||||
| P_DB_CONNECTION | 后端数据库连接检查失败 | The backend database connection or published-port check failed. | Inspect the probe output and verify DATABASE_URL, credentials, database name and port; container health alone is insufficient. |
|
||||
@@ -345,16 +346,46 @@ For each newly confirmed cause, update both language tables with a stable code,
|
||||
| P_MOTION_START | Motion Agent 启动失败 | Motion Agent failed to start. | Use ./planet.sh log -m and check dependencies, cameras and input mode. |
|
||||
| P_ACCOUNT_INPUT | 用户名不能为空;密码不能为空;密码长度不能少于;两次输入的密码不一致 | User creation input failed validation. | Supply a username and matching passwords that satisfy the minimum length. |
|
||||
| P_HTTP_HEALTH | 不可访问: | The specified HTTP endpoint failed its access check. | Check the URL, listener, firewall and local or LAN routing; check certificate trust for HTTPS. |
|
||||
| P_DOCKER_MIRROR_FAILED | PLANET_DOCKER_MIRROR_FAILED | Mirror manifest validation or image pulling failed, so fallback could not complete. | Inspect the specific registry error, mirror availability, allowlist, architecture and digest. A reachable homepage is insufficient. Configure PLANET_DOCKER_MIRROR_PREFIX or disable fallback while repairing the origin. |
|
||||
| P_DOCKER_COMMAND_TIMEOUT | PLANET_DOCKER_COMMAND_TIMEOUT | The current Docker command exceeded the script deadline; timeout alone does not establish a proxy, network or local engine fault. | Locate the stalled stage using its label, latest output and log. For silent commands, inspect that Docker command and daemon logs; increase the deadline only after confirming progress. |
|
||||
| P_COMPOSE_FAILED | Docker Compose 执行失败;docker-compose v1 执行失败 | The selected Compose command failed without a more specific classified cause. | Inspect the preceding original error; repair an installed Compose v2 instead of installing v1 as a fallback. |
|
||||
| P_BUILD_FAILED | AI Provider 镜像构建失败;failed to solve | Image build failed without evidence matching a known specific cause. | Inspect the earliest specific error in aiprovider_build.log and add the confirmed cause and a regression case to this catalog. |
|
||||
| P_UNKNOWN | — | Unclassified; the available evidence does not establish the cause. | Retain the error and command; after verifying the root cause, update both language catalogs and add a regression case. |
|
||||
<!-- planet-error-catalog:end -->
|
||||
|
||||
### Docker Progress and Deadlines
|
||||
|
||||
Docker probes, Compose startup, database connection checks and AI Provider builds update the secondary line beneath the existing main status. There are no extra start/completion records, version numbers or log paths. Success clears the secondary line. Silent waits retain the current step and update elapsed time. Failure leaves one specific error and exits; later summaries and cleanup notices do not overwrite it. Failed Compose operations do not retry; health checks after a successful container launch retain their existing budgets.
|
||||
|
||||
`PLANET_DOCKER_PROBE_TIMEOUT`, `PLANET_COMPOSE_TIMEOUT` and `PLANET_DOCKER_BUILD_TIMEOUT` default to 15, 180 and 900 seconds respectively. Each accepts positive seconds and bounds one command. Timeout terminates that CLI process group; the daemon may already have accepted a request, so inspect actual container state. A Compose probe timeout does not fall back to v1.
|
||||
|
||||
Detailed logs remain under `${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}`: `compose.*.log`, `aiprovider_build.log` and `database-check.*.log`. Logs use mode 600 and mask proxy usernames/passwords in URLs. `scripts/docker_command.py` owns recording, timing and process cleanup; `planet.sh` owns terminal rendering. Invoking system `docker compose` directly does not load this wrapper.
|
||||
|
||||
### Database Troubleshooting Before Demos
|
||||
|
||||
A confirmed failure occurs when another project's PostgreSQL and Redis occupy `5432` and `6379`. Planet containers may appear healthy without published host ports, and the backend can accidentally reach the other database and fail authentication. `scripts/check_database_connection.py --ports` identifies conflicts before startup and stops instead of repeatedly recreating containers.
|
||||
|
||||
For coexistence, change the port in `backend/.env`'s `DATABASE_URL` to an available port such as `15432`, and `REDIS_PORT` to an available port such as `16379`. Preserve credentials and the database name. If the process exports `REDIS_URL`, update it too. `planet.sh` derives `PLANET_POSTGRES_PORT` and `PLANET_REDIS_PORT` from effective backend settings and preserves named volumes when recreating port mappings. Direct Compose invocations must supply these variables explicitly. Database ports bind only to `127.0.0.1`.
|
||||
|
||||
Handle other errors according to evidence: authentication failures require checking the target and existing volume credentials; changing `POSTGRES_PASSWORD` does not reset an existing password. Missing published ports without a conflict allow one recreation preserving volumes. DNS, TLS, proxy authentication and registry rate limits have separate catalog remedies. Slow downloads or dependency installation are not failures; unchanged code and dependencies should reuse the image on subsequent starts.
|
||||
|
||||
### Temporary Docker Image Mirrors
|
||||
|
||||
The Tsinghua [Docker CE repository](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/) distributes Docker installation packages, not Docker Hub images. Image fallback defaults to the [DaoCloud public image mirror](https://github.com/DaoCloud/public-image-mirror). Tsinghua PyPI fallback remains specific to local Python dependencies.
|
||||
|
||||
Build preparation tries the origin through proxy and direct routes, then the mirror route when neither works. A network failure during an actual registry request can also trigger one fallback after a successful origin probe. Authentication, certificate, rate-limit, port and Dockerfile package-installation failures do not switch image sources. Exhausted routes leave the specific error in the secondary line and exit. Original failures remain in adjacent `*.primary.log` files.
|
||||
|
||||
The specific mirror image must pass manifest inspection; execution pins its returned sha256 digest. An explicitly requested digest must match. Base images are overridden only through this build's arguments; original configuration, fingerprint and output image name remain unchanged. Database fallback pulls only missing images for requested services, tags them with the original local names, then retries the original Compose operation. Global registry-mirrors, source configuration and named volumes remain unchanged.
|
||||
|
||||
`PLANET_DOCKER_MIRROR_FALLBACK=0` disables fallback. `PLANET_DOCKER_MIRROR_PREFIX` defaults to `m.daocloud.io` and accepts a trusted registry/path prefix. `PLANET_DOCKER_MIRROR_TIMEOUT` defaults to 120 seconds for base-image verification. Only Planet's public Python, uv, PostgreSQL and Redis repositories are mapped; private/custom images are not sent to public mirrors. Public services have allowlists, quotas and cache delays, so mutable tags can lag. Use digests when strict version identity matters and build/cache images before demos.
|
||||
|
||||
`scripts/docker_mirror.py` owns mapping, manifest checks and database pulls; `scripts/lib/docker-mirror.zsh` owns network-error selection and temporary arguments. Both reuse the existing progress/deadline runner. Regressions cover private repositories, digest mismatches, non-network errors, a single fallback and preservation of original configuration.
|
||||
|
||||
### Docker Daemon Proxy and Build Networking
|
||||
|
||||
Shell HTTP_PROXY / HTTPS_PROXY settings do not automatically configure a running Docker daemon. When the shell can reach a registry through its proxy but Docker pulls time out, compare direct requests, proxied requests and daemon settings. HTTP 401, 403 and 429 establish a registry response, not pull authorization or remaining quota. Verify authentication and rate limits with the actual build; these responses must not cause a reachable proxy to be disabled.
|
||||
|
||||
Before an actual AI Provider build, the script reads HTTPS_PROXY, HTTP_PROXY and ALL_PROXY, including lowercase forms. It validates HTTP/HTTPS candidates against the registries selected by PYTHON_IMAGE and UV_IMAGE, respecting NO_PROXY. A working proxy is configured for the local Linux Docker Engine. Missing or unusable proxies cause a direct probe and removal of stale Planet-managed proxy settings. If neither route works, P_PROXY_NO_ROUTE stops the build. A host without a proxy and a daemon already using direct access receives no proxy configuration. Fingerprint cache hits and --no-build skip probing. This is not a background monitor: proxy availability is checked on the next actual build.
|
||||
Before an actual AI Provider build, the script reads HTTPS_PROXY, HTTP_PROXY and ALL_PROXY, including lowercase forms. It validates HTTP/HTTPS candidates against the registries selected by PYTHON_IMAGE and UV_IMAGE, respecting NO_PROXY. A working proxy is configured for the local Linux Docker Engine. Missing or unusable proxies cause a direct probe and removal of stale Planet-managed proxy settings. If neither origin route works, the mirror route is checked; P_PROXY_NO_ROUTE stops the build only when that also fails. A host without a proxy and a daemon already using direct access receives no proxy configuration. Fingerprint cache hits and --no-build skip probing. This is not a background monitor: proxy availability is checked on the next actual build.
|
||||
|
||||
scripts/docker_proxy.py manages the proxies section of /etc/docker/daemon.json and a root-only ownership record at /etc/docker/planet-proxy-state.json. Administrator settings, systemd proxy settings and manually edited proxies are not overwritten. Unchanged settings need neither elevation nor a restart. Updates use the existing sudo flow, preserve unrelated Docker settings, save daemon.json.planet-proxy.bak, validate configuration, restart Docker and start previously running containers. Failures trigger rollback; check container health after recovery. Credentials travel through environment variables or restricted files and are excluded from logs. Docker Desktop, remote and rootless daemons retain their own settings without local daemon.json changes. Proxy addresses come from the environment, never a machine-specific port in the repository.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ COPY aiprovider /app/aiprovider
|
||||
|
||||
### 运行期配置来源
|
||||
|
||||
`planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file,并把它传给 Compose 或手动 `docker run` fallback。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自:
|
||||
`planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file,并把它传给 Compose;Compose 失败时保留错误并停止,不再回退手动启动。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自:
|
||||
|
||||
1. `aiprovider/.env`
|
||||
2. `~/.zshrc` 中简单的 `export AI_...=...` 或 `AI_...=...` 行
|
||||
|
||||
@@ -302,7 +302,7 @@ OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600
|
||||
|
||||
## 错误原因对照表
|
||||
|
||||
`planet.sh` 的所有 `log_error` 输出保留现场信息,并直接从下表读取错误编号、原因和处理建议;AI Provider 构建失败还会匹配完整构建日志。匹配按表中顺序进行,不区分英文大小写,分号分隔多个字面关键片段,具体错误优先于汇总错误。错误编号不是进程退出码,失败仍返回非零状态。
|
||||
`planet.sh` 默认只保留状态主行下的具体错误;使用 `--verbose` 时,`log_error` 从下表读取错误编号、原因和处理建议;AI Provider 构建失败还会匹配完整构建日志。匹配按表中顺序进行,不区分英文大小写,分号分隔多个字面关键片段,具体错误优先于汇总错误。错误编号不是进程退出码,失败仍返回非零状态。
|
||||
|
||||
确认新的故障原因时,必须更新中英文表,补充稳定编号、可辨识的日志片段和回归用例,再接入脚本。未确认的错误归入 `P_UNKNOWN`,不得自动把未知日志当成已验证原因写入手册。表由 `scripts/lib/error-diagnostics.zsh` 读取,不要在单元格中使用竖线;`scripts/harness/test_error_diagnostics.py` 验证匹配、双语编号和输出一致性。
|
||||
|
||||
@@ -335,6 +335,7 @@ OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600
|
||||
| P_LOCKFILE_CHANGED | 修改了 uv.lock | 依赖准备意外修改了锁文件。 | 检查依赖清单与锁文件的一致性;新环境使用 frozen 安装,不要隐式更新锁文件。 |
|
||||
| P_DEPENDENCIES | 安装失败;安装后仍不可用;安装完成后仍未找到;未找到 .venv/bin/python;自动安装后仍无法解析运行时;未找到 Vite Bun 入口;缺少 mediapipe/opencv-python;仍无法导入 mediapipe/opencv-python;需要 openssl | 必需依赖安装失败、缺失或未进入当前运行环境。 | 查看对应安装日志,检查网络、软件源和 PATH;前端使用 Bun,Python 使用项目 uv 环境。 |
|
||||
| P_ARGUMENT | 未知参数;非法端口;需要端口号;需要逗号分隔;--motion-agent-mode 需要;--motion-agent-wsl-usbipd-busid 需要;用法: ./planet.sh | 命令或参数不符合脚本支持的格式。 | 查看 ./planet.sh 用法,修正参数和值后重试。 |
|
||||
| P_DB_PORT_OCCUPIED | PLANET_DB_PORT_OCCUPIED | Planet 的数据库或 Redis 宿主机端口已被其他容器或服务占用。 | 根据错误中的占用者核对 docker ps;调整 backend/.env 的 DATABASE_URL 端口或 REDIS_PORT(进程 REDIS_URL 优先),planet.sh 会同步 Compose 映射;不停止其他项目或删除数据卷。 |
|
||||
| P_PORT | 地址已被占用;端口仍不可用;清理失败,请检查占用进程;port is already allocated;address already in use | 请求的端口被占用,或在宿主机/外部环境中不可绑定。 | 检查 ss 和 Windows Get-NetTCPConnection,确认占用者后调整端口或停止对应服务。 |
|
||||
| P_CAMERA | live 模式缺少可用摄像头;未找到可打开并能读帧的摄像头 | Motion Agent 无法取得可用摄像头画面。 | 检查设备、权限和 WSL USB 转发;无需摄像头时使用 --non-motion-agent 或明确选择 dry-run。 |
|
||||
| P_DB_CONNECTION | 后端数据库连接检查失败 | 后端实际数据库连接或发布端口检查未通过。 | 查看连接探测的具体输出,核对 DATABASE_URL、凭据、库名和端口;容器健康不等于后端能连接。 |
|
||||
@@ -345,16 +346,46 @@ OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600
|
||||
| P_MOTION_START | Motion Agent 启动失败 | Motion Agent 未正常启动。 | 查看 ./planet.sh log -m,检查依赖、摄像头和输入模式。 |
|
||||
| P_ACCOUNT_INPUT | 用户名不能为空;密码不能为空;密码长度不能少于;两次输入的密码不一致 | 创建用户时输入不满足校验要求。 | 按提示重新输入用户名及满足长度要求且一致的密码。 |
|
||||
| P_HTTP_HEALTH | 不可访问: | 指定 HTTP 端点未通过访问检查。 | 检查目标 URL、服务监听、防火墙和本机/局域网路由;HTTPS 还需检查证书信任。 |
|
||||
| P_DOCKER_MIRROR_FAILED | PLANET_DOCKER_MIRROR_FAILED | 备用镜像的 manifest 校验或实际拉取失败,未能完成回退。 | 查看具体仓库错误;检查备用站可用性、镜像白名单、架构和 digest,不把首页可达当成镜像可用。可配置 PLANET_DOCKER_MIRROR_PREFIX 或关闭自动回退后修复原源。 |
|
||||
| P_DOCKER_COMMAND_TIMEOUT | PLANET_DOCKER_COMMAND_TIMEOUT | 当前 Docker 命令超过脚本的等待上限;仅凭超时不能确定是代理、网络还是本地引擎问题。 | 根据阶段名称、最近输出和日志定位;无输出时检查对应 Docker 命令及 daemon 日志,确认仍有进展后再调整该阶段超时。 |
|
||||
| P_COMPOSE_FAILED | Docker Compose 执行失败;docker-compose v1 执行失败 | 所选 Compose 命令失败,尚未识别更具体原因。 | 查看命令前面的原始错误;已有 Compose v2 时修复其错误,不安装 v1 作为回退。 |
|
||||
| P_BUILD_FAILED | AI Provider 镜像构建失败;failed to solve | 镜像构建失败,现有证据未匹配已知的具体原因。 | 查看 aiprovider_build.log 中最早的具体错误,确认原因后补充本表和回归用例。 |
|
||||
| P_UNKNOWN | — | 尚未归类,不能从现有证据确认原因。 | 保留完整错误和执行命令;确认根因后补充本表、中英文说明及回归用例。 |
|
||||
<!-- planet-error-catalog:end -->
|
||||
|
||||
### Docker 等待进度与超时
|
||||
|
||||
Docker 检查、Compose 启动、数据库连接检查和 AI Provider 构建的最新步骤更新在现有状态主行下面的副行,不打印额外的开始/完成记录、版本号或日志路径。成功后清除副行;静默等待时保留当前步骤并更新耗时。失败时保留具体错误并退出,后续汇总错误和清理通知不会覆盖它。Compose 执行失败不再重复启动;容器正常启动后的健康等待继续沿用原有预算。
|
||||
|
||||
`PLANET_DOCKER_PROBE_TIMEOUT` 默认 15 秒,`PLANET_COMPOSE_TIMEOUT` 默认 180 秒,`PLANET_DOCKER_BUILD_TIMEOUT` 默认 900 秒,均为单次命令总时限,可设为正数秒。超时终止本次 CLI 进程组;daemon 可能已接受请求,仍应检查实际容器状态。Compose 检查超时不会回退到 v1。
|
||||
|
||||
详细日志位于 `${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}`:Compose 使用 `compose.*.log`,构建使用 `aiprovider_build.log`,连接检查使用 `database-check.*.log`。日志权限为 600,URL 中的代理用户名和密码会被遮盖。`scripts/docker_command.py` 负责记录、计时和进程清理,终端渲染由 `planet.sh` 统一负责。直接执行系统 `docker compose` 不会加载此包装器。
|
||||
|
||||
### 演示启动的数据库排障
|
||||
|
||||
已确认的冲突案例是另一个项目的 PostgreSQL 和 Redis 分别占用 `5432`、`6379`。Planet 容器可能显示健康,却没有实际发布宿主机端口;后端也可能误连另一套数据库并报认证失败。`scripts/check_database_connection.py --ports` 在启动前检测占用者,冲突时立即停止,不通过反复重建解决端口争用。
|
||||
|
||||
需要共存时,在 `backend/.env` 中将 `DATABASE_URL` 的端口改为可用端口(例如 `15432`),将 `REDIS_PORT` 改为可用端口(例如 `16379`)。保留账号、密码和库名;若进程设置了 `REDIS_URL`,也必须同步它。`planet.sh` 从后端实际配置导出 `PLANET_POSTGRES_PORT`、`PLANET_REDIS_PORT`,重建容器映射时保留命名数据卷。直接使用 Compose 时需显式提供这两个变量。数据库端口只发布到 `127.0.0.1`。
|
||||
|
||||
其他错误按证据分别处理:认证失败先核对连接目标和已有卷的密码,修改 `POSTGRES_PASSWORD` 不会重置已有密码;端口映射缺失且没有冲突时允许保留卷重建一次;镜像下载的 DNS、TLS、代理认证和限流错误按上表各自处理。正常下载或依赖安装的慢进度不算失败,代码与依赖未变化时后续启动应复用镜像。
|
||||
|
||||
### Docker 镜像的临时备用源
|
||||
|
||||
清华 [Docker CE 软件仓库](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/) 只提供 Docker 安装包,不是 Docker Hub 镜像服务。Docker 镜像回退默认使用 [DaoCloud 公开镜像服务](https://github.com/DaoCloud/public-image-mirror),清华 PyPI 回退仍只用于本机 Python 依赖。
|
||||
|
||||
构建前先检测原仓库的代理和直连;均不可达时验证备用路径。原路径探测成功、实际拉取仍发生 DNS/连接超时等网络错误时,也只回退一次。认证失败、证书错误、限流、端口冲突及 Dockerfile 内部依赖安装失败不会触发镜像源切换。所有路径失败后保留错误副行并退出。原始失败日志保留为同目录的 `*.primary.log`。
|
||||
|
||||
备用站必须通过具体镜像的 manifest 检查,执行时锁定返回的 `sha256` digest;输入本身指定 digest 时必须一致。基础镜像只临时传入本次构建参数,原配置、构建指纹和最终镜像名不变。数据库只为本次请求中缺少的镜像执行备用拉取,成功后加回原始本地标签,再执行原 Compose 启动。不会修改全局 `registry-mirrors` 或源码配置,已有命名数据卷不变。
|
||||
|
||||
`PLANET_DOCKER_MIRROR_FALLBACK=0` 关闭回退;`PLANET_DOCKER_MIRROR_PREFIX` 默认 `m.daocloud.io`,可设置为可信镜像服务的 `registry/path` 前缀;`PLANET_DOCKER_MIRROR_TIMEOUT` 默认 120 秒,用于基础镜像验证。仅映射 Planet 使用的公开 Python、uv、PostgreSQL 和 Redis 仓库,自定义/私有镜像不会被自动送往公共站。公共镜像服务有白名单、限流和缓存延迟,可变标签可能滞后;需要严格固定版本时应使用 digest,演示前应完成构建并保留本地镜像。
|
||||
|
||||
`scripts/docker_mirror.py` 负责映射、manifest 校验及数据库镜像拉取,`scripts/lib/docker-mirror.zsh` 负责网络错误筛选和临时参数;它们复用现有命令进度与超时执行器。回归覆盖私有仓库保护、digest 不一致、非网络错误、一次回退与原配置保留。
|
||||
|
||||
### Docker 服务代理与构建网络
|
||||
|
||||
终端的 HTTP_PROXY / HTTPS_PROXY 不会自动配置已经运行的 Docker 服务。若终端通过代理能访问镜像仓库,而 Docker 拉取超时,应分别验证代理连接、直连和 Docker 服务的实际代理设置。探测收到 HTTP 401、403 或 429 表示仓库已响应,不等于已获得镜像拉取权限或剩余额度;认证及限流仍由实际构建验证,不能据此把可达代理切换掉。
|
||||
|
||||
实际构建 AI Provider 镜像前,脚本自动读取当前环境的 HTTPS_PROXY、HTTP_PROXY、ALL_PROXY(含小写形式),依次验证 HTTP/HTTPS 代理能否访问 PYTHON_IMAGE、UV_IMAGE 对应的仓库,并尊重 NO_PROXY。有可用代理才为本地 Linux Docker Engine 设置代理;没有代理或代理不可用时测试直连并清除脚本管理的旧代理。两种路径都不可用时,用 P_PROXY_NO_ROUTE 明确停止。没有代理且 Docker 原本也是直连时,不新增代理配置。指纹命中跳过构建或使用 --no-build 时,不做联网探测;这不是后台监控,代理启停在下一次实际构建时检测。
|
||||
实际构建 AI Provider 镜像前,脚本自动读取当前环境的 HTTPS_PROXY、HTTP_PROXY、ALL_PROXY(含小写形式),依次验证 HTTP/HTTPS 代理能否访问 PYTHON_IMAGE、UV_IMAGE 对应的仓库,并尊重 NO_PROXY。有可用代理才为本地 Linux Docker Engine 设置代理;没有代理或代理不可用时测试直连并清除脚本管理的旧代理。原源两种路径都不可用时继续检测备用镜像路径;备用路径也不可用时,用 P_PROXY_NO_ROUTE 明确停止。没有代理且 Docker 原本也是直连时,不新增代理配置。指纹命中跳过构建或使用 --no-build 时,不做联网探测;这不是后台监控,代理启停在下一次实际构建时检测。
|
||||
|
||||
`scripts/docker_proxy.py` 管理 `/etc/docker/daemon.json` 的 proxies,归属记录保存在仅 root 可读写的 `/etc/docker/planet-proxy-state.json`。不覆盖管理员配置、systemd 代理或人工修改过的代理。配置相同不提权、不重启;需要修改时使用现有 sudo 流程,保留其他 Docker 设置,备份到 daemon.json.planet-proxy.bak,校验后重启 Docker 并启动原先运行的容器。失败时回滚;恢复后应检查容器健康。代理凭据只经环境或受限文件传递,不输出到日志。Docker Desktop、远程和 rootless Docker 沿用自身设置,不修改本机 daemon.json。代理地址从环境读取,不硬编码某台机器的端口。
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.6`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.7`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.74.7` | improvement | `dev` | `v0.74.7` | Docker 镜像网络故障临时回退,精简副行进度与失败显示,修复数据库端口冲突及启动重试并补齐实际验证 |
|
||||
| `0.74.6` | improvement | `dev` | `v0.74.6` | 自动检测 Docker 构建代理与直连,统一运维错误诊断,支持跳过 AI Provider 构建并修复 Compose 回退与失败退出状态 |
|
||||
| `0.74.5` | improvement | `dev` | `v0.74.5` | 恢复模型预设持久化,统一官方目录刷新与连通性检查,修复 M3 思考模式和 OpenCode 协议路由 |
|
||||
| `0.74.4` | improvement | `dev` | `v0.74.4` | Earth 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.74.6",
|
||||
"version": "0.74.7",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
218
planet.sh
218
planet.sh
@@ -78,6 +78,8 @@ WAIT_SPINNER_TICKS_PER_SECOND=8
|
||||
WAIT_SPINNER_DETAIL=""
|
||||
WAIT_SPINNER_MESSAGE=""
|
||||
WAIT_SESSION_ACTIVE=0
|
||||
WAIT_FAILURE_REPORTED=0
|
||||
LAST_COMMAND_ERROR=""
|
||||
PORT_CHECK_STATUS_ACTIVE=0
|
||||
VERBOSE=0
|
||||
WAIT_VERBOSE_LOG_FILE=""
|
||||
@@ -233,6 +235,7 @@ prepare_uv_build_config
|
||||
source "$SCRIPT_DIR/scripts/lib/docker-bootstrap.zsh"
|
||||
source "$SCRIPT_DIR/scripts/lib/docker-proxy.zsh"
|
||||
source "$SCRIPT_DIR/scripts/lib/error-diagnostics.zsh"
|
||||
source "$SCRIPT_DIR/scripts/lib/docker-mirror.zsh"
|
||||
|
||||
# Shell / Docker helpers
|
||||
is_pid() {
|
||||
@@ -403,38 +406,63 @@ log_buildx_diagnostics_if_needed() {
|
||||
|
||||
compose_up() {
|
||||
local args=("$@")
|
||||
local arg_text="$*"
|
||||
local probe_status=0
|
||||
|
||||
if compose_available; then
|
||||
set_wait_detail "正在用 docker compose 执行: ${arg_text}"
|
||||
if docker compose "${args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
log_error "Docker Compose 执行失败: docker compose ${arg_text}"
|
||||
return 1
|
||||
compose_available || probe_status=$?
|
||||
if [ "$probe_status" -eq 0 ]; then
|
||||
run_compose_command docker compose "${args[@]}"
|
||||
return $?
|
||||
fi
|
||||
if [ "$probe_status" -eq 124 ]; then
|
||||
log_error "PLANET_DOCKER_COMMAND_TIMEOUT"
|
||||
return 124
|
||||
fi
|
||||
|
||||
if compose_v1_available; then
|
||||
set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}"
|
||||
if docker-compose "${args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
clear_wait_spinner
|
||||
log_error "docker-compose v1 执行失败"
|
||||
log_note "最后尝试的命令: docker-compose ${arg_text}"
|
||||
return 1
|
||||
run_compose_command docker-compose "${args[@]}"
|
||||
return $?
|
||||
fi
|
||||
|
||||
report_missing_compose
|
||||
}
|
||||
|
||||
run_compose_command() {
|
||||
local log_file="$(mktemp "$PLANET_STATE_DIR/compose.XXXXXX.log")"
|
||||
local command_status=0
|
||||
local DOCKER_MIRROR_ERROR_LOG_FILE=""
|
||||
# Preflight separates daemon access from image pulls and container reconciliation.
|
||||
if ! docker_daemon_available; then
|
||||
log_error "Docker Compose 执行失败: 无法连接 daemon" "$log_file"
|
||||
return 1
|
||||
fi
|
||||
COMPOSE_PROGRESS=plain BUILDKIT_PROGRESS=plain run_docker_observed \
|
||||
"$*" "${PLANET_COMPOSE_TIMEOUT:-180}" "$log_file" "$@" >/dev/null || command_status=$?
|
||||
if [ "$command_status" -ne 0 ] && docker_failure_allows_mirror "$log_file"; then
|
||||
cp "$log_file" "${log_file%.log}.primary.log"
|
||||
if pull_database_mirrors "$@"; then
|
||||
command_status=0
|
||||
COMPOSE_PROGRESS=plain run_docker_observed "$*" \
|
||||
"${PLANET_COMPOSE_TIMEOUT:-180}" "$log_file" "$@" >/dev/null || command_status=$?
|
||||
elif [ -n "${DOCKER_MIRROR_ERROR_LOG_FILE:-}" ]; then
|
||||
cp "$DOCKER_MIRROR_ERROR_LOG_FILE" "$log_file"
|
||||
fi
|
||||
fi
|
||||
if [ "$command_status" -ne 0 ]; then
|
||||
log_error "Docker Compose 执行失败: $*" "$log_file"
|
||||
fi
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
compose_supports_build() {
|
||||
if compose_available; then
|
||||
docker compose build --help >/dev/null 2>&1
|
||||
local probe_status=0
|
||||
compose_available || probe_status=$?
|
||||
if [ "$probe_status" -eq 0 ]; then
|
||||
docker_probe docker compose build --help >/dev/null
|
||||
return $?
|
||||
fi
|
||||
[ "$probe_status" -ne 124 ] || return 124
|
||||
if compose_v1_available; then
|
||||
docker-compose build --help >/dev/null 2>&1
|
||||
docker_probe docker-compose build --help >/dev/null
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
@@ -465,13 +493,14 @@ animate_wait_spinner() {
|
||||
|
||||
while [ "$tick" -lt "$ticks" ]; do
|
||||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||||
render_wait_spinner "${WAIT_SPINNER_MESSAGE:-$message}" "$WAIT_SPINNER_STEP"
|
||||
sleep 0.125
|
||||
tick=$((tick + 1))
|
||||
done
|
||||
}
|
||||
|
||||
clear_wait_spinner() {
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || return 0
|
||||
local line_count=0
|
||||
|
||||
printf "\r\033[K" >&2
|
||||
@@ -574,6 +603,7 @@ flush_wait_verbose_log() {
|
||||
}
|
||||
|
||||
set_wait_detail() {
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || return 0
|
||||
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
|
||||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||||
@@ -676,6 +706,7 @@ log_timestamp() {
|
||||
}
|
||||
|
||||
log_line() {
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || return 0
|
||||
local label="$1"
|
||||
local color="$2"
|
||||
local message="$3"
|
||||
@@ -697,11 +728,20 @@ log_halt() {
|
||||
}
|
||||
|
||||
log_error() {
|
||||
log_line "fail" "$RED" "$1"
|
||||
report_error_reason "$1" "${2:-/dev/null}"
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || return 0
|
||||
local message="${WAIT_SPINNER_MESSAGE:-$1}"
|
||||
local detail="${LAST_COMMAND_ERROR:-$1}"
|
||||
stop_wait_session
|
||||
log_line "fail" "$RED" "$message"
|
||||
printf "${DIM} %s${NC}\n" "$detail"
|
||||
if [ "${VERBOSE:-0}" -eq 1 ]; then
|
||||
report_error_reason "$1" "${2:-/dev/null}"
|
||||
fi
|
||||
WAIT_FAILURE_REPORTED=1
|
||||
}
|
||||
|
||||
log_note() {
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || return 0
|
||||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||||
clear_wait_spinner
|
||||
fi
|
||||
@@ -1079,7 +1119,11 @@ run_with_retry() {
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_retries" ]; then
|
||||
log_error "${failure_message}"
|
||||
if [ "${RETRY_FAILURE_RECOVERABLE:-0}" -eq 1 ]; then
|
||||
set_wait_detail "$failure_message"
|
||||
else
|
||||
log_error "${failure_message}"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -1094,6 +1138,7 @@ run_uv_sync_with_mirror_fallback() {
|
||||
local log_file="$1"
|
||||
local lock_digest_before
|
||||
local sync_status
|
||||
local RETRY_FAILURE_RECOVERABLE=1
|
||||
|
||||
lock_digest_before="$(uv_lock_digest)"
|
||||
if run_with_retry \
|
||||
@@ -1108,6 +1153,7 @@ run_uv_sync_with_mirror_fallback() {
|
||||
assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen"
|
||||
|
||||
configure_uv_tuna_index
|
||||
RETRY_FAILURE_RECOVERABLE=0
|
||||
set_wait_detail "已准备临时清华源配置,重新执行 uv sync"
|
||||
|
||||
lock_digest_before="$(uv_lock_digest)"
|
||||
@@ -1336,24 +1382,6 @@ remove_ai_provider_containers() {
|
||||
printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
run_ai_provider_container_manually() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
local env_file_args=()
|
||||
|
||||
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
|
||||
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
|
||||
fi
|
||||
if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then
|
||||
env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE")
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name "$AI_PROVIDER_CONTAINER_NAME" \
|
||||
"${env_file_args[@]}" \
|
||||
-p "${ai_provider_port}:8010" \
|
||||
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
|
||||
}
|
||||
|
||||
write_ai_provider_runtime_env_file() {
|
||||
local env_name env_value
|
||||
|
||||
@@ -1379,15 +1407,8 @@ compose_up_ai_provider() {
|
||||
}
|
||||
|
||||
recreate_ai_provider_container() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
|
||||
remove_ai_provider_containers
|
||||
|
||||
if compose_up_ai_provider >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
run_ai_provider_container_manually "$ai_provider_port"
|
||||
compose_up_ai_provider
|
||||
}
|
||||
|
||||
ensure_ai_provider_image_current() {
|
||||
@@ -1439,8 +1460,10 @@ ensure_ai_provider_image_current() {
|
||||
if ! build_ai_provider_image; then
|
||||
clear_wait_spinner
|
||||
log_error "AI Provider 镜像构建失败" "$AI_PROVIDER_BUILD_LOG_FILE"
|
||||
tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true
|
||||
log_buildx_diagnostics_if_needed
|
||||
if [ "$VERBOSE" -eq 1 ]; then
|
||||
tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true
|
||||
log_buildx_diagnostics_if_needed
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
log_success "AI Provider 镜像已构建"
|
||||
@@ -1450,11 +1473,14 @@ ensure_ai_provider_image_current() {
|
||||
}
|
||||
|
||||
build_ai_provider_image() {
|
||||
if compose_available; then
|
||||
local probe_status=0
|
||||
compose_available || probe_status=$?
|
||||
if [ "$probe_status" -eq 0 ]; then
|
||||
set_wait_detail "使用 docker compose 构建 AI Provider 镜像"
|
||||
run_ai_provider_build_command "docker compose"
|
||||
return $?
|
||||
fi
|
||||
[ "$probe_status" -ne 124 ] || return 124
|
||||
|
||||
if compose_v1_available; then
|
||||
set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像"
|
||||
@@ -1468,17 +1494,26 @@ build_ai_provider_image() {
|
||||
run_ai_provider_build_command() {
|
||||
local compose_command="$1"
|
||||
local compose_args=("${(@s: :)compose_command}")
|
||||
|
||||
if [ "$VERBOSE" -eq 1 ]; then
|
||||
run_command_with_spinner "构建 AI Provider 镜像" zsh -o pipefail -c \
|
||||
'log_file="$1"; shift; "$@" 2>&1 | tee "$log_file"' \
|
||||
planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider
|
||||
return $?
|
||||
local command_status=0
|
||||
local -a DOCKER_MIRROR_BUILD_ARGS=()
|
||||
if [ "${DOCKER_BUILD_USE_MIRROR:-0}" -eq 1 ]; then
|
||||
resolve_build_mirror_args || return 1
|
||||
fi
|
||||
|
||||
run_command_with_spinner "构建 AI Provider 镜像" zsh -c \
|
||||
'log_file="$1"; shift; "$@" > "$log_file" 2>&1' \
|
||||
planet-build "$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build aiprovider
|
||||
COMPOSE_PROGRESS=plain BUILDKIT_PROGRESS=plain run_docker_observed \
|
||||
"构建 AI Provider 镜像" "${PLANET_DOCKER_BUILD_TIMEOUT:-900}" \
|
||||
"$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build "${DOCKER_MIRROR_BUILD_ARGS[@]}" aiprovider >/dev/null || command_status=$?
|
||||
if [ "$command_status" -ne 0 ] && [ "${DOCKER_BUILD_USE_MIRROR:-0}" -eq 0 ] && \
|
||||
docker_failure_allows_mirror "$AI_PROVIDER_BUILD_LOG_FILE"; then
|
||||
cp "$AI_PROVIDER_BUILD_LOG_FILE" "${AI_PROVIDER_BUILD_LOG_FILE%.log}.primary.log"
|
||||
prepare_docker_build_proxy mirror || return 1
|
||||
resolve_build_mirror_args || return 1
|
||||
COMPOSE_PROGRESS=plain BUILDKIT_PROGRESS=plain run_docker_observed \
|
||||
"临时使用备用源构建 AI Provider" "${PLANET_DOCKER_BUILD_TIMEOUT:-900}" \
|
||||
"$AI_PROVIDER_BUILD_LOG_FILE" "${compose_args[@]}" build "${DOCKER_MIRROR_BUILD_ARGS[@]}" aiprovider >/dev/null
|
||||
return $?
|
||||
fi
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
install_uv_if_needed() {
|
||||
@@ -2287,7 +2322,12 @@ wait_for_container_health() {
|
||||
set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态,已等待 0 秒"
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
|
||||
local probe_status=0
|
||||
health_status="$(docker_probe docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name")" || probe_status=$?
|
||||
if [ "$probe_status" -eq 124 ]; then
|
||||
log_error "PLANET_DOCKER_COMMAND_TIMEOUT"
|
||||
return 124
|
||||
fi
|
||||
|
||||
if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then
|
||||
finish_wait_spinner "${service_name}容器健康检查已通过"
|
||||
@@ -2460,17 +2500,32 @@ wait_for_postgres_health() {
|
||||
|
||||
start_database_services() {
|
||||
# Reconcile existing containers too; docker start cannot apply changed port bindings.
|
||||
prepare_database_ports || return $?
|
||||
compose_up up -d --no-deps postgres redis
|
||||
}
|
||||
|
||||
restart_database_services() {
|
||||
start_database_services && docker restart planet_postgres planet_redis
|
||||
start_database_services && docker_probe docker restart planet_postgres planet_redis
|
||||
}
|
||||
|
||||
start_postgres_service() {
|
||||
prepare_database_ports || return $?
|
||||
compose_up up -d --no-deps postgres
|
||||
}
|
||||
|
||||
prepare_database_ports() {
|
||||
local ports_file="$(mktemp "$PLANET_STATE_DIR/database-ports.XXXXXX.log")"
|
||||
local port_values
|
||||
if ! run_docker_observed "检查数据库端口" 15 "$ports_file" \
|
||||
"$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/scripts/check_database_connection.py" --ports >/dev/null; then
|
||||
log_error "后端数据库连接检查失败" "$ports_file"
|
||||
return 1
|
||||
fi
|
||||
port_values="$(<"$ports_file")"
|
||||
read -r PLANET_POSTGRES_PORT PLANET_REDIS_PORT <<< "$port_values"
|
||||
export PLANET_POSTGRES_PORT PLANET_REDIS_PORT
|
||||
}
|
||||
|
||||
# Backend lifecycle helpers
|
||||
backend_startup_failed() {
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
@@ -2582,12 +2637,14 @@ start_ai_provider_service() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up_ai_provider >/dev/null 2>&1; then
|
||||
elif docker_probe docker start "$AI_PROVIDER_CONTAINER_NAME" || compose_up_ai_provider; then
|
||||
if wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || exit 1
|
||||
|
||||
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
||||
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次"
|
||||
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
|
||||
@@ -2616,9 +2673,11 @@ ensure_database_services_healthy() {
|
||||
local retry=1
|
||||
|
||||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||||
if start_database_services && wait_for_database_health; then
|
||||
start_database_services || exit $?
|
||||
if wait_for_database_health; then
|
||||
return 0
|
||||
fi
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || exit 1
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
@@ -2626,7 +2685,7 @@ ensure_database_services_healthy() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
|
||||
docker_probe docker restart planet_postgres planet_redis || true
|
||||
animate_wait_spinner "数据库启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
@@ -2636,9 +2695,11 @@ ensure_postgres_service_healthy() {
|
||||
local retry=1
|
||||
|
||||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||||
if start_postgres_service && wait_for_postgres_health; then
|
||||
start_postgres_service || exit $?
|
||||
if wait_for_postgres_health; then
|
||||
return 0
|
||||
fi
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || exit 1
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
@@ -2651,7 +2712,7 @@ ensure_postgres_service_healthy() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker restart planet_postgres >/dev/null 2>&1 || true
|
||||
docker_probe docker restart planet_postgres || true
|
||||
animate_wait_spinner "PostgreSQL 启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
@@ -2662,9 +2723,11 @@ restart_database_service() {
|
||||
set_wait_detail "重启数据库"
|
||||
|
||||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||||
if restart_database_services && wait_for_database_health; then
|
||||
restart_database_services || exit $?
|
||||
if wait_for_database_health; then
|
||||
return 0
|
||||
fi
|
||||
[ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ] || exit 1
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
@@ -2684,12 +2747,14 @@ start_backend_service() {
|
||||
local backend_start_status=0
|
||||
|
||||
ensure_uv_backend_deps
|
||||
start_wait_session "启动数据库"
|
||||
set_wait_detail "启动数据库"
|
||||
ensure_database_services_healthy
|
||||
if ! verify_backend_database_connection; then
|
||||
log_error "后端数据库连接检查失败,停止启动"
|
||||
exit 1
|
||||
fi
|
||||
stop_wait_session
|
||||
log_success "启动数据库已就绪"
|
||||
|
||||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||||
@@ -2699,7 +2764,9 @@ start_backend_service() {
|
||||
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||||
else
|
||||
log_note "AI Provider 未就绪,正在启动本地代理服务"
|
||||
start_wait_session "启动 AI Provider"
|
||||
start_ai_provider_service "$ai_provider_port"
|
||||
stop_wait_session
|
||||
fi
|
||||
|
||||
if [ "$backend_port_requested" -eq 1 ]; then
|
||||
@@ -3943,8 +4010,10 @@ cleanup_failed_start() {
|
||||
[ "$START_RUN_ACTIVE" -eq 1 ] || return 0
|
||||
[ "$START_RUN_COMPLETED" -eq 0 ] || return 0
|
||||
|
||||
clear_wait_spinner
|
||||
log_warn "启动未完成,清理本轮已拉起的本地进程"
|
||||
if [ "${WAIT_FAILURE_REPORTED:-0}" -eq 0 ]; then
|
||||
clear_wait_spinner
|
||||
log_warn "启动未完成,清理本轮已拉起的本地进程"
|
||||
fi
|
||||
|
||||
if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then
|
||||
cleanup_motion_agent_processes TERM
|
||||
@@ -4028,14 +4097,15 @@ verify_backend_database_connection() {
|
||||
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=$?
|
||||
local log_file="$(mktemp "$PLANET_STATE_DIR/database-check.XXXXXX.log")"
|
||||
run_docker_observed "验证后端数据库连接" 30 "$log_file" "${check_command[@]}" >/dev/null || 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 配置重建一次"
|
||||
set_wait_detail "PostgreSQL 端口映射不匹配,保留数据卷重建一次"
|
||||
compose_up up -d --no-deps --force-recreate postgres || return 1
|
||||
wait_for_postgres_health || return 1
|
||||
run_command_with_spinner "重新验证后端数据库连接" "${check_command[@]}"
|
||||
run_docker_observed "重新验证后端数据库连接" 30 "$log_file" "${check_command[@]}" >/dev/null
|
||||
}
|
||||
|
||||
planet_app_services_running() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.74.6"
|
||||
version = "0.74.7"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -5,6 +5,8 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy.pool import NullPool
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONNECT_TIMEOUT_SECONDS = 10
|
||||
DEFAULT_POSTGRES_PORT = 5432
|
||||
DEFAULT_REDIS_PORT = 6379
|
||||
POSTGRES_CONTAINER = "planet_postgres"
|
||||
POSTGRES_CONTAINER_PORT = "5432/tcp"
|
||||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
@@ -36,6 +39,55 @@ def backend_database_url() -> str:
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def configured_ports() -> tuple[int, int]:
|
||||
database = make_url(backend_database_url())
|
||||
from app.core.config import settings
|
||||
|
||||
redis = make_url(settings.REDIS_URL)
|
||||
postgres_port = database.port or DEFAULT_POSTGRES_PORT
|
||||
redis_port = redis.port or DEFAULT_REDIS_PORT
|
||||
targets = []
|
||||
if database.host in LOOPBACK_HOSTS:
|
||||
targets.append(("PostgreSQL", POSTGRES_CONTAINER, postgres_port))
|
||||
if redis.host in LOOPBACK_HOSTS:
|
||||
targets.append(("Redis", "planet_redis", redis_port))
|
||||
check_port_conflicts(targets)
|
||||
return postgres_port, redis_port
|
||||
|
||||
|
||||
def check_port_conflicts(targets: list[tuple[str, str, int]]) -> None:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"],
|
||||
capture_output=True, text=True, timeout=CONNECT_TIMEOUT_SECONDS, check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise DatabaseReadinessError("无法读取 Docker 端口占用;请检查 daemon 和当前用户权限。")
|
||||
for service, expected_container, port in targets:
|
||||
own_binding = False
|
||||
for line in result.stdout.splitlines():
|
||||
name, _, bindings = line.partition("\t")
|
||||
if not re.search(rf":{port}->", bindings):
|
||||
continue
|
||||
if name == expected_container:
|
||||
own_binding = True
|
||||
else:
|
||||
raise DatabaseReadinessError(
|
||||
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port} 被 {name} 占用;"
|
||||
"请调整 Planet 后端连接端口。"
|
||||
)
|
||||
if not own_binding:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||
try:
|
||||
# Docker can reuse recently closed TCP connections; TIME_WAIT is not a listener.
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", port))
|
||||
except OSError as error:
|
||||
raise DatabaseReadinessError(
|
||||
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port} 被宿主机服务占用;"
|
||||
"请检查监听进程或调整 Planet 后端连接端口。"
|
||||
) from error
|
||||
|
||||
|
||||
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.
|
||||
@@ -106,6 +158,9 @@ def connection_diagnostic(error: BaseException) -> str:
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if sys.argv[1:] == ["--ports"]:
|
||||
print(*configured_ports())
|
||||
return 0
|
||||
url = make_url(backend_database_url())
|
||||
print(
|
||||
f"后端数据库目标: host={url.host!r} port={url.port or DEFAULT_POSTGRES_PORT} database={url.database!r}"
|
||||
|
||||
156
scripts/docker_command.py
Normal file
156
scripts/docker_command.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Run Docker CLI work with live evidence, a deadline, and process-group cleanup."""
|
||||
|
||||
import argparse
|
||||
from contextlib import nullcontext
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
def safe_line(value: str) -> str:
|
||||
value = re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", value)
|
||||
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1***@", value)
|
||||
|
||||
|
||||
def concise_line(value: str) -> str:
|
||||
value = safe_line(value).strip()
|
||||
if "Bind for " in value:
|
||||
return value[value.index("Bind for "):]
|
||||
return re.sub(r"sha256:([0-9a-f]{12})[0-9a-f]{52}", r"sha256:\1…", value)
|
||||
|
||||
|
||||
def write_status(path: Path | None, value: str) -> None:
|
||||
if path is not None:
|
||||
temporary = path.with_suffix(".pending")
|
||||
temporary.write_text(value, encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def stop_group(process: subprocess.Popen[bytes]) -> None:
|
||||
# A Compose plugin or credential helper can outlive the Docker CLI parent.
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(process.pid, sig)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
if sig == signal.SIGTERM:
|
||||
time.sleep(0.2)
|
||||
process.wait()
|
||||
|
||||
|
||||
def monitor(
|
||||
process: subprocess.Popen[bytes],
|
||||
args: argparse.Namespace,
|
||||
log: TextIO | None,
|
||||
) -> int:
|
||||
started = time.monotonic()
|
||||
next_notice = started
|
||||
latest = args.label
|
||||
failure = ""
|
||||
pending = b""
|
||||
|
||||
def emit(raw: bytes) -> None:
|
||||
nonlocal latest, failure
|
||||
line = safe_line(raw.decode("utf-8", errors="replace"))
|
||||
if not line.strip():
|
||||
return
|
||||
latest = concise_line(line)
|
||||
if re.search(r"error|failed|denied|timeout|refused|不存在|失败|被占用|未向宿主机", line, re.I):
|
||||
failure = latest
|
||||
print(line, flush=True)
|
||||
if log is not None:
|
||||
log.write(line + "\n")
|
||||
log.flush()
|
||||
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while selector.get_map() or process.poll() is None:
|
||||
now = time.monotonic()
|
||||
if now - started >= args.timeout:
|
||||
if pending:
|
||||
emit(pending)
|
||||
message = (
|
||||
f"PLANET_DOCKER_COMMAND_TIMEOUT: {args.label};"
|
||||
f"已等待 {now - started:.1f}s;最近输出: {latest}"
|
||||
)
|
||||
write_status(args.status_file, f"{latest}(等待 {now - started:.0f} 秒后超时)")
|
||||
if log is not None:
|
||||
log.write(message + "\n")
|
||||
log.flush()
|
||||
return 124
|
||||
if now >= next_notice:
|
||||
detail = concise_line(pending.decode("utf-8", errors="replace")) or latest
|
||||
write_status(args.status_file, f"{detail} · {now - started:.0f}s")
|
||||
next_notice = now + args.heartbeat
|
||||
for key, _ in selector.select(timeout=min(0.1, args.heartbeat)):
|
||||
chunk = os.read(key.fd, 65536)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
parts = re.split(b"[\r\n]", pending + chunk)
|
||||
pending = parts.pop()
|
||||
for part in parts:
|
||||
emit(part)
|
||||
write_status(args.status_file, concise_line(pending.decode("utf-8", errors="replace")) or latest)
|
||||
if pending:
|
||||
emit(pending)
|
||||
code = process.wait()
|
||||
write_status(args.status_file, (failure or latest) if code else "")
|
||||
return code if code >= 0 else 128 - code
|
||||
|
||||
|
||||
def positive_seconds(value: str) -> float:
|
||||
seconds = float(value)
|
||||
if not 0 < seconds < float("inf"):
|
||||
raise argparse.ArgumentTypeError("时间必须为有限正数")
|
||||
return seconds
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--label", required=True)
|
||||
parser.add_argument("--timeout", type=positive_seconds, required=True)
|
||||
parser.add_argument("--heartbeat", type=positive_seconds, default=5)
|
||||
parser.add_argument("--log", type=Path)
|
||||
parser.add_argument("--status-file", type=Path)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
||||
if not command:
|
||||
parser.error("缺少命令")
|
||||
log_context = nullcontext(None)
|
||||
if args.log:
|
||||
args.log.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.fchmod(descriptor, 0o600)
|
||||
log_context = os.fdopen(descriptor, "w", encoding="utf-8")
|
||||
with log_context as log:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
write_status(args.status_file, safe_line(str(error)))
|
||||
return 127
|
||||
previous_handler = signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
|
||||
try:
|
||||
return monitor(process, args, log)
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
finally:
|
||||
stop_group(process)
|
||||
signal.signal(signal.SIGTERM, previous_handler)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
128
scripts/docker_mirror.py
Normal file
128
scripts/docker_mirror.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Resolve verified public-image mirrors without changing Docker's global registry settings."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from docker_command import safe_line
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MIRROR_PREFIX = "m.daocloud.io"
|
||||
MANIFEST_TIMEOUT = 30
|
||||
PULL_TIMEOUT = 180
|
||||
PUBLIC_REPOSITORIES = {
|
||||
"docker.io/library/python", "docker.io/library/postgres", "docker.io/library/redis",
|
||||
"ghcr.io/astral-sh/uv",
|
||||
}
|
||||
|
||||
|
||||
class MirrorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def build_images(environment: dict[str, str]) -> dict[str, str]:
|
||||
defaults = dict(re.findall(
|
||||
r"^ARG (PYTHON_IMAGE|UV_IMAGE)=(.+)$", (ROOT / "aiprovider/Dockerfile").read_text(), re.M
|
||||
))
|
||||
return {key: environment.get(key) or value for key, value in defaults.items()}
|
||||
|
||||
|
||||
def mirror_prefix(environment: dict[str, str]) -> str:
|
||||
prefix = environment.get("PLANET_DOCKER_MIRROR_PREFIX", DEFAULT_MIRROR_PREFIX).rstrip("/")
|
||||
if not re.fullmatch(r"[a-zA-Z0-9.-]+(?::[0-9]+)?(?:/[a-zA-Z0-9._-]+)*", prefix):
|
||||
raise MirrorError("备用镜像地址须为 registry 或 registry/path,不得包含凭据或 URL 参数")
|
||||
return prefix
|
||||
|
||||
|
||||
def canonical_image(image: str) -> str:
|
||||
first = image.split("/")[0]
|
||||
if "/" not in image or not ("." in first or ":" in first or first == "localhost"):
|
||||
image = "docker.io/" + ("library/" if "/" not in image else "") + image
|
||||
if image.startswith("index.docker.io/"):
|
||||
image = "docker.io/" + image.removeprefix("index.docker.io/")
|
||||
if image.startswith("docker.io/") and image.count("/") == 1:
|
||||
image = "docker.io/library/" + image.removeprefix("docker.io/")
|
||||
return image
|
||||
|
||||
|
||||
def mirror_image(image: str, environment: dict[str, str]) -> str:
|
||||
canonical = canonical_image(image)
|
||||
repository = canonical.split("@")[0].rsplit("/", 1)
|
||||
repository = repository[0] + "/" + repository[1].split(":")[0]
|
||||
if repository not in PUBLIC_REPOSITORIES:
|
||||
raise MirrorError("仅为 Planet 的公开基础镜像回退;自定义或私有镜像须配置自身仓库")
|
||||
return mirror_prefix(environment) + "/" + canonical
|
||||
|
||||
|
||||
def mirror_registry_urls(environment: dict[str, str]) -> list[str]:
|
||||
for image in build_images(environment).values():
|
||||
mirror_image(image, environment)
|
||||
return ["https://" + urlsplit("https://" + mirror_prefix(environment)).netloc + "/v2/"]
|
||||
|
||||
|
||||
def run(command: list[str], timeout: int = MANIFEST_TIMEOUT) -> str:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
if result.returncode:
|
||||
detail = safe_line(result.stderr.strip() or result.stdout.strip())
|
||||
raise MirrorError(detail[-600:] or "备用镜像命令失败")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def resolve_image(image: str, environment: dict[str, str]) -> str:
|
||||
mirrored = mirror_image(image, environment)
|
||||
manifest = json.loads(run([
|
||||
"docker", "buildx", "imagetools", "inspect", mirrored, "--format", "{{json .Manifest}}"
|
||||
]))
|
||||
digest = manifest.get("digest") if isinstance(manifest, dict) else None
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
|
||||
raise MirrorError("备用镜像未返回有效的 manifest digest")
|
||||
if "@" in image and digest != image.rsplit("@", 1)[1]:
|
||||
raise MirrorError("备用镜像 digest 与指定镜像不一致")
|
||||
repository = mirrored.split("@")[0]
|
||||
if ":" in repository.rsplit("/", 1)[1]:
|
||||
repository = repository.rsplit(":", 1)[0]
|
||||
return repository + "@" + digest
|
||||
|
||||
|
||||
def pull_services(services: list[str], environment: dict[str, str]) -> None:
|
||||
if not services or any(service not in {"postgres", "redis"} for service in services):
|
||||
raise MirrorError("备用拉取只处理本次请求的 PostgreSQL / Redis 服务")
|
||||
configuration = json.loads(run(["docker", "compose", "config", "--format", "json"]))
|
||||
for service in services:
|
||||
image = configuration["services"][service]["image"]
|
||||
local = subprocess.run(
|
||||
["docker", "image", "inspect", image], capture_output=True, timeout=MANIFEST_TIMEOUT
|
||||
)
|
||||
if local.returncode == 0:
|
||||
continue
|
||||
resolved = resolve_image(image, environment)
|
||||
print(f"备用源拉取 {service}", flush=True)
|
||||
# Docker verifies layer digests while pulling the immutable manifest reference.
|
||||
run(["docker", "pull", resolved], PULL_TIMEOUT)
|
||||
run(["docker", "tag", resolved, image])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
environment = dict(os.environ)
|
||||
if sys.argv[1:] == ["build"]:
|
||||
resolved = {key: resolve_image(value, environment)
|
||||
for key, value in build_images(environment).items()}
|
||||
for key, value in resolved.items():
|
||||
print(f"{key}={value}")
|
||||
elif sys.argv[1:2] == ["pull"]:
|
||||
pull_services(sys.argv[2:], environment)
|
||||
else:
|
||||
raise MirrorError("未知备用镜像操作")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (MirrorError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
|
||||
detail = str(error) if isinstance(error, MirrorError) else "镜像验证或拉取未完成"
|
||||
sys.stderr.write("PLANET_DOCKER_MIRROR_FAILED: " + detail + "\n")
|
||||
sys.exit(1)
|
||||
@@ -7,12 +7,13 @@ import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from docker_mirror import build_images, mirror_registry_urls
|
||||
|
||||
CONFIG = Path("/etc/docker/daemon.json")
|
||||
STATE = Path("/etc/docker/planet-proxy-state.json")
|
||||
PROXY_KEYS = ("http-proxy", "https-proxy", "no-proxy")
|
||||
@@ -45,11 +46,8 @@ def daemon_proxies() -> dict[str, str]:
|
||||
|
||||
|
||||
def registry_urls(environment: dict[str, str]) -> list[str]:
|
||||
dockerfile = Path(__file__).resolve().parents[1] / "aiprovider/Dockerfile"
|
||||
defaults = dict(re.findall(r"^ARG (PYTHON_IMAGE|UV_IMAGE)=(.+)$", dockerfile.read_text(), re.M))
|
||||
registries = set()
|
||||
for key in ("PYTHON_IMAGE", "UV_IMAGE"):
|
||||
image = environment.get(key) or defaults[key]
|
||||
for image in build_images(environment).values():
|
||||
prefix = image.split("/")[0]
|
||||
registry = (
|
||||
prefix
|
||||
@@ -118,6 +116,17 @@ def select_route(environment: dict[str, str], urls: list[str]) -> dict[str, obje
|
||||
return {"desired": {}, "mode": "direct", "reachable": reachable(urls, "")}
|
||||
|
||||
|
||||
def plan_routes(environment: dict[str, str], force_mirror: bool = False) -> dict[str, object]:
|
||||
if not force_mirror:
|
||||
plan = select_route(environment, registry_urls(environment))
|
||||
plan["mirror"] = False
|
||||
if plan["reachable"] or environment.get("PLANET_DOCKER_MIRROR_FALLBACK") == "0":
|
||||
return plan
|
||||
plan = select_route(environment, mirror_registry_urls(environment))
|
||||
plan["mirror"] = True
|
||||
return plan
|
||||
|
||||
|
||||
def atomic_write(path: Path, contents: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
@@ -207,13 +216,13 @@ def rollback(
|
||||
def main() -> None:
|
||||
action = sys.argv[1]
|
||||
if action == "plan":
|
||||
plan = select_route(dict(os.environ), registry_urls(dict(os.environ)))
|
||||
plan = plan_routes(dict(os.environ), sys.argv[2:] == ["mirror"])
|
||||
plan["current"] = daemon_proxies()
|
||||
json.dump(plan, sys.stdout)
|
||||
elif action == "status":
|
||||
plan = json.load(sys.stdin)
|
||||
changed = int(plan["current"] != plan["desired"])
|
||||
print(plan["mode"], changed, int(plan["reachable"]))
|
||||
print(plan["mode"], changed, int(plan["reachable"]), int(plan.get("mirror", False)))
|
||||
elif action == "apply":
|
||||
if os.geteuid() != 0:
|
||||
raise ProxyError(CONFIG_ERROR)
|
||||
|
||||
@@ -15,9 +15,12 @@ main() {
|
||||
harness_run zsh -n planet.sh
|
||||
harness_run zsh -n scripts/lib/docker-bootstrap.zsh
|
||||
harness_run zsh -n scripts/lib/docker-proxy.zsh
|
||||
harness_run zsh -n scripts/lib/docker-mirror.zsh
|
||||
harness_run zsh -n scripts/lib/error-diagnostics.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_docker_command.py
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_proxy.py
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_mirror.py
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_database_startup.py
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_error_diagnostics.py
|
||||
harness_run bash -n scripts/bootstrap-dev.sh
|
||||
|
||||
@@ -6,6 +6,8 @@ import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -25,7 +27,7 @@ TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/pl
|
||||
|
||||
|
||||
def shell_function(name: str) -> str:
|
||||
source = (ROOT / "planet.sh").read_text()
|
||||
source = (ROOT / "planet.sh").read_text() + "\n" + (ROOT / "scripts/lib/docker-bootstrap.zsh").read_text()
|
||||
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing shell function: {name}")
|
||||
@@ -44,6 +46,47 @@ def run_shell(functions: list[str], setup: str, action: str) -> subprocess.Compl
|
||||
|
||||
|
||||
class ComposeSelectionTests(unittest.TestCase):
|
||||
def test_probe_timeout_does_not_fall_back_or_claim_compose_is_missing(self) -> None:
|
||||
result = run_shell(
|
||||
["compose_up"],
|
||||
"""
|
||||
compose_available() { return 124; }
|
||||
log_error() { echo "$*"; }
|
||||
compose_v1_available() { echo UNEXPECTED_FALLBACK; return 0; }
|
||||
run_compose_command() { echo UNEXPECTED_COMMAND; }
|
||||
report_missing_compose() { echo UNEXPECTED_MISSING; }
|
||||
""",
|
||||
"compose_up up -d postgres",
|
||||
)
|
||||
self.assertEqual(result.returncode, 124)
|
||||
self.assertNotIn("UNEXPECTED", result.stdout)
|
||||
|
||||
def test_compose_timeout_keeps_live_output_and_passes_log_to_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
binary = Path(folder) / "docker"
|
||||
binary.write_text("#!/bin/sh\necho 'postgres Pulling'\nsleep 30\n")
|
||||
binary.chmod(0o755)
|
||||
result = run_shell(
|
||||
["run_compose_command", "run_docker_observed"],
|
||||
f"""
|
||||
PLANET_STATE_DIR={shlex.quote(folder)}
|
||||
DOCKER_COMMAND_RUNNER={shlex.quote(str(ROOT / 'scripts/docker_command.py'))}
|
||||
PLANET_COMPOSE_TIMEOUT=0.3
|
||||
clear_wait_spinner() {{ :; }}
|
||||
docker_failure_allows_mirror() {{ return 1; }}
|
||||
docker_daemon_available() {{ return 0; }}
|
||||
set_wait_detail() {{ echo "$*" >&2; }}
|
||||
log_error() {{ echo "DIAGNOSTIC $2"; }}
|
||||
""",
|
||||
f"run_compose_command {shlex.quote(str(binary))} compose up -d postgres",
|
||||
)
|
||||
self.assertEqual(result.returncode, 124, result.stderr)
|
||||
self.assertIn("postgres Pulling", result.stderr)
|
||||
logs = list(Path(folder).glob("compose.*.log"))
|
||||
self.assertEqual(len(logs), 1)
|
||||
self.assertIn(f"DIAGNOSTIC {logs[0]}", result.stdout)
|
||||
self.assertIn("PLANET_DOCKER_COMMAND_TIMEOUT", logs[0].read_text())
|
||||
|
||||
def test_operations_use_v1_only_when_v2_is_unavailable(self) -> None:
|
||||
operations = (
|
||||
("compose_up", "up -d postgres"),
|
||||
@@ -74,6 +117,8 @@ class ComposeSelectionTests(unittest.TestCase):
|
||||
f"""
|
||||
compose_available() {{ return {int(not v2_available)}; }}
|
||||
compose_v1_available() {{ return {int(not v1_available)}; }}
|
||||
run_compose_command() {{ "$@"; }}
|
||||
docker_probe() {{ "$@"; }}
|
||||
docker() {{
|
||||
echo "docker $*" >> '{calls_file}'
|
||||
echo ORIGINAL_ERROR >&2
|
||||
@@ -112,6 +157,47 @@ class ComposeSelectionTests(unittest.TestCase):
|
||||
|
||||
|
||||
class DatabaseLifecycleTests(unittest.TestCase):
|
||||
def test_compose_error_exits_before_health_retry_or_restart(self) -> None:
|
||||
result = run_shell(
|
||||
["ensure_database_services_healthy"],
|
||||
"""
|
||||
DATABASE_START_MAX_RETRIES=3
|
||||
start_database_services() { echo PORT_CONFLICT; return 1; }
|
||||
wait_for_database_health() { echo UNEXPECTED_HEALTH; }
|
||||
docker_probe() { echo UNEXPECTED_RESTART; }
|
||||
animate_wait_spinner() { echo UNEXPECTED_RETRY; }
|
||||
""",
|
||||
"ensure_database_services_healthy",
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout.strip(), "PORT_CONFLICT")
|
||||
|
||||
def test_failure_secondary_line_survives_outer_error_and_cleanup_notice(self) -> None:
|
||||
result = run_shell(
|
||||
["log_error", "cleanup_failed_start"],
|
||||
"""
|
||||
WAIT_SPINNER_MESSAGE='启动数据库'
|
||||
LAST_COMMAND_ERROR='Bind for 127.0.0.1:5432 failed: port is already allocated'
|
||||
VERBOSE=0
|
||||
log_line() { echo "$1 $3"; }
|
||||
stop_wait_session() { :; }
|
||||
clear_wait_spinner() { echo UNEXPECTED_CLEAR; }
|
||||
log_warn() { echo UNEXPECTED_WARNING; }
|
||||
START_RUN_ACTIVE=1
|
||||
START_RUN_COMPLETED=0
|
||||
STARTED_MOTION_AGENT_THIS_RUN=0
|
||||
STARTED_FRONTEND_THIS_RUN=0
|
||||
STARTED_BACKEND_THIS_RUN=0
|
||||
""",
|
||||
"log_error 'Docker Compose 执行失败'\n"
|
||||
"log_error '后端数据库连接检查失败'\ncleanup_failed_start",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(), [
|
||||
"fail 启动数据库",
|
||||
" Bind for 127.0.0.1:5432 failed: port is already allocated",
|
||||
])
|
||||
|
||||
def test_ai_start_uses_host_readiness_without_waiting_for_docker_probe_schedule(self) -> None:
|
||||
for recreate in (0, 1):
|
||||
with self.subTest(recreate=recreate):
|
||||
@@ -124,7 +210,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
AI_PROVIDER_HEALTH_CHECK_INTERVAL=2
|
||||
AI_PROVIDER_CONTAINER_NAME=planet_aiprovider
|
||||
for fn in set_wait_detail write_ai_provider_runtime_env_file \
|
||||
ensure_ai_provider_image_current recreate_ai_provider_container docker; do
|
||||
ensure_ai_provider_image_current recreate_ai_provider_container docker_probe; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
wait_for_container_health() {{ echo WAIT_FOR_DOCKER_SCHEDULE; return 1; }}
|
||||
@@ -195,7 +281,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
f"""
|
||||
BACKEND_MAX_RETRIES=3
|
||||
BACKEND_LOG_FILE=/dev/null
|
||||
for fn in set_wait_detail log_success log_note log_error; do
|
||||
for fn in set_wait_detail start_wait_session stop_wait_session log_success log_note log_error; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
ensure_uv_backend_deps() {{ echo DEPS; }}
|
||||
@@ -276,6 +362,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
[function],
|
||||
"""
|
||||
mapped=0
|
||||
prepare_database_ports() { return 0; }
|
||||
docker() { return 0; }
|
||||
compose_up() { mapped=1; }
|
||||
""",
|
||||
@@ -288,6 +375,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
["start_database_services"],
|
||||
"""
|
||||
docker() { return 0; }
|
||||
prepare_database_ports() { return 0; }
|
||||
compose_up() { echo 'port is already allocated' >&2; return 1; }
|
||||
""",
|
||||
"start_database_services",
|
||||
@@ -301,6 +389,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
"""
|
||||
compose_available() { return 0; }
|
||||
compose_v1_available() { return 1; }
|
||||
run_compose_command() { "$@"; }
|
||||
docker() { echo 'address already in use' >&2; return 1; }
|
||||
set_wait_detail() { :; }
|
||||
log_warn() { echo "$*"; }
|
||||
@@ -365,11 +454,12 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
["verify_backend_database_connection"],
|
||||
f"""
|
||||
checks=0
|
||||
mktemp() {{ echo /dev/null; }}
|
||||
set_wait_detail() {{ :; }}
|
||||
log_warn() {{ :; }}
|
||||
compose_up() {{ echo "COMPOSE $*"; }}
|
||||
wait_for_postgres_health() {{ return 0; }}
|
||||
run_command_with_spinner() {{
|
||||
run_docker_observed() {{
|
||||
checks=$((checks + 1))
|
||||
if [ $checks -eq 1 ]; then return {first_status}; fi
|
||||
return {retry_status}
|
||||
@@ -385,6 +475,54 @@ class DatabaseLifecycleTests(unittest.TestCase):
|
||||
|
||||
|
||||
class DatabaseProbeTests(unittest.TestCase):
|
||||
def test_recently_closed_connection_is_not_a_port_conflict(self) -> None:
|
||||
with socket.socket() as server, socket.socket() as client:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
port = server.getsockname()[1]
|
||||
server.listen()
|
||||
client.connect(("127.0.0.1", port))
|
||||
connection, _ = server.accept()
|
||||
connection.close()
|
||||
client.recv(1)
|
||||
result = subprocess.CompletedProcess([], 0, "", "")
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", port)])
|
||||
|
||||
def test_actual_host_listener_is_still_a_conflict(self) -> None:
|
||||
with socket.socket() as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen()
|
||||
port = server.getsockname()[1]
|
||||
result = subprocess.CompletedProcess([], 0, "", "")
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError):
|
||||
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", port)])
|
||||
|
||||
def test_other_project_database_and_redis_ports_are_identified_before_recreate(self) -> None:
|
||||
result = subprocess.CompletedProcess([], 0,
|
||||
"happyrouter-db-1\t127.0.0.1:5432->5432/tcp\n"
|
||||
"happyrouter-redis-1\t127.0.0.1:6379->6379/tcp\n", "")
|
||||
for service, container, port, owner in (
|
||||
("PostgreSQL", "planet_postgres", 5432, "happyrouter-db-1"),
|
||||
("Redis", "planet_redis", 6379, "happyrouter-redis-1"),
|
||||
):
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError) as error:
|
||||
probe.check_port_conflicts([(service, container, port)])
|
||||
self.assertIn(owner, str(error.exception))
|
||||
self.assertIn("PLANET_DB_PORT_OCCUPIED", str(error.exception))
|
||||
|
||||
def test_own_published_port_is_reusable_and_other_port_is_not_a_conflict(self) -> None:
|
||||
result = subprocess.CompletedProcess([], 0,
|
||||
"planet_postgres\t127.0.0.1:15432->5432/tcp\n"
|
||||
"happyrouter-db-1\t127.0.0.1:5432->5432/tcp\n", "")
|
||||
with patch.object(probe.subprocess, "run", return_value=result), \
|
||||
patch.object(probe.socket, "socket") as bind:
|
||||
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", 15432)])
|
||||
bind.assert_not_called()
|
||||
|
||||
def docker_result(
|
||||
self, ports: dict | None, mode: str = "bridge"
|
||||
) -> subprocess.CompletedProcess:
|
||||
|
||||
@@ -126,7 +126,8 @@ class DockerBootstrapTests(unittest.TestCase):
|
||||
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"):
|
||||
for name in ("zsh", "env", "sort", "head", "tail", "readlink", "awk", "sed", "python3",
|
||||
"mktemp", "sleep", "cat", "rm"):
|
||||
executable = shutil.which(name)
|
||||
self.assertIsNotNone(executable, f"test prerequisite missing: {name}")
|
||||
(self.bin / name).symlink_to(executable)
|
||||
|
||||
93
scripts/harness/test_docker_command.py
Normal file
93
scripts/harness/test_docker_command.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Exercise stalled Docker substitutes without contacting a daemon."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
RUNNER = Path(__file__).resolve().parents[1] / "docker_command.py"
|
||||
|
||||
|
||||
class DockerCommandTests(unittest.TestCase):
|
||||
def run_command(self, source: str) -> tuple[subprocess.CompletedProcess[str], str]:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
log = Path(folder) / "quoted ' $(literal).log"
|
||||
status = Path(folder) / "status"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(RUNNER), "--label", "Compose up postgres redis",
|
||||
"--timeout", "0.6", "--heartbeat", "0.15", "--log", str(log),
|
||||
"--status-file", str(status), "--",
|
||||
sys.executable, "-c", source],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
|
||||
self.last_status = status.read_text()
|
||||
return result, log.read_text()
|
||||
|
||||
def test_silent_hang_has_heartbeat_deadline_and_no_invented_cause(self) -> None:
|
||||
result, log = self.run_command("import time; time.sleep(30)")
|
||||
self.assertEqual(result.returncode, 124, result.stderr)
|
||||
self.assertIn("Compose up postgres redis", self.last_status)
|
||||
self.assertIn("超时", self.last_status)
|
||||
self.assertEqual(result.stdout + result.stderr, "")
|
||||
self.assertIn("PLANET_DOCKER_COMMAND_TIMEOUT", log)
|
||||
self.assertNotIn("代理", log)
|
||||
|
||||
def test_partial_and_carriage_return_output_survives_timeout(self) -> None:
|
||||
result, log = self.run_command(
|
||||
"import time; print('postgres Pulling\\rredis Downloading', end='', flush=True); "
|
||||
"time.sleep(30)"
|
||||
)
|
||||
self.assertEqual(result.returncode, 124)
|
||||
self.assertIn("postgres Pulling", log)
|
||||
self.assertIn("redis Downloading", self.last_status)
|
||||
|
||||
def test_failure_is_streamed_and_original_exit_code_preserved(self) -> None:
|
||||
result, log = self.run_command(
|
||||
"import sys; print('TLS handshake timeout', file=sys.stderr); sys.exit(17)"
|
||||
)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
self.assertIn("TLS handshake timeout", result.stdout)
|
||||
self.assertEqual(log, "TLS handshake timeout\n")
|
||||
self.assertEqual(self.last_status, "TLS handshake timeout")
|
||||
|
||||
def test_success_and_proxy_credentials(self) -> None:
|
||||
result, log = self.run_command("print('https://user:secret@example.test/v2/ ready')")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(self.last_status, "")
|
||||
self.assertEqual(result.stderr, "")
|
||||
self.assertIn("https://***@example.test/v2/ ready", log)
|
||||
self.assertNotIn("secret", result.stdout + result.stderr + log)
|
||||
|
||||
def test_long_compose_network_error_keeps_the_port_in_secondary_line(self) -> None:
|
||||
result, _ = self.run_command(
|
||||
"import sys; print('Error response from daemon: failed to set up container networking: '"
|
||||
" + 'x' * 160 + ': Bind for 127.0.0.1:5432 failed: port is already allocated'); sys.exit(1)"
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(self.last_status,
|
||||
"Bind for 127.0.0.1:5432 failed: port is already allocated")
|
||||
|
||||
def test_timeout_kills_descendant_even_after_cli_parent_exits(self) -> None:
|
||||
result, _ = self.run_command(
|
||||
"import subprocess, sys; "
|
||||
"p = subprocess.Popen([sys.executable, '-c', "
|
||||
"'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); "
|
||||
"time.sleep(30)']); print(p.pid, flush=True)"
|
||||
)
|
||||
self.assertEqual(result.returncode, 124)
|
||||
child = int(result.stdout.strip())
|
||||
state = Path(f"/proc/{child}/stat")
|
||||
try:
|
||||
self.assertTrue(not state.exists() or state.read_text().split()[2] == "Z")
|
||||
finally:
|
||||
if state.exists() and state.read_text().split()[2] != "Z":
|
||||
os.kill(child, signal.SIGKILL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
134
scripts/harness/test_docker_mirror.py
Normal file
134
scripts/harness/test_docker_mirror.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Mirror routing regressions without pulling images or editing daemon configuration."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_database_startup import run_shell
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import docker_mirror as mirror
|
||||
import docker_proxy as proxy
|
||||
|
||||
DIGEST = "sha256:" + "a" * 64
|
||||
|
||||
|
||||
class MirrorTests(unittest.TestCase):
|
||||
def test_recoverable_uv_failure_does_not_freeze_status_before_tuna_fallback(self) -> None:
|
||||
result = run_shell(["run_uv_sync_with_mirror_fallback", "run_with_retry"], """
|
||||
DEPENDENCY_INSTALL_MAX_RETRIES=1
|
||||
DEPENDENCY_INSTALL_RETRY_INTERVAL=0
|
||||
WAIT_FAILURE_REPORTED=0
|
||||
uv_lock_digest() { echo stable; }
|
||||
assert_uv_lock_unchanged() { :; }
|
||||
run_command_with_spinner() { shift; "$@"; }
|
||||
run_command_quiet_unless_verbose() { return 1; }
|
||||
configure_uv_tuna_index() { echo MIRROR_READY; }
|
||||
run_uv_sync_with_tuna_config() { echo RECOVERED; }
|
||||
set_wait_detail() { :; }
|
||||
log_error() { echo FATAL; WAIT_FAILURE_REPORTED=1; }
|
||||
""", "run_uv_sync_with_mirror_fallback /dev/null\n[ $WAIT_FAILURE_REPORTED -eq 0 ]")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(), ["MIRROR_READY", "RECOVERED"])
|
||||
|
||||
def test_build_network_failure_retries_once_with_temporary_args(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
result = run_shell(["run_ai_provider_build_command"], f"""
|
||||
AI_PROVIDER_BUILD_LOG_FILE={folder}/build.log
|
||||
attempts=0
|
||||
DOCKER_BUILD_USE_MIRROR=0
|
||||
PYTHON_IMAGE=python:3.14-slim
|
||||
run_docker_observed() {{
|
||||
attempts=$((attempts + 1))
|
||||
echo "$*" >&2
|
||||
echo failure > "$3"
|
||||
[ $attempts -eq 2 ]
|
||||
}}
|
||||
docker_failure_allows_mirror() {{ return 0; }}
|
||||
prepare_docker_build_proxy() {{ DOCKER_BUILD_USE_MIRROR=1; }}
|
||||
resolve_build_mirror_args() {{ DOCKER_MIRROR_BUILD_ARGS=(--build-arg PYTHON_IMAGE=mirror/python@{DIGEST}); }}
|
||||
""", 'run_ai_provider_build_command "docker compose"\n'
|
||||
'[ $attempts -eq 2 ] && [ "$PYTHON_IMAGE" = python:3.14-slim ]')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("--build-arg PYTHON_IMAGE=mirror/python@", result.stderr)
|
||||
self.assertTrue((Path(folder) / "build.primary.log").exists())
|
||||
|
||||
def test_mapping_retains_original_repository_and_digest(self) -> None:
|
||||
for image, expected in (
|
||||
("python:3.14-slim", "docker.io/library/python:3.14-slim"),
|
||||
("docker.io/postgres:15", "docker.io/library/postgres:15"),
|
||||
("ghcr.io/astral-sh/uv:latest", "ghcr.io/astral-sh/uv:latest"),
|
||||
("redis@" + DIGEST, "docker.io/library/redis@" + DIGEST),
|
||||
):
|
||||
self.assertEqual(mirror.mirror_image(image, {}), "m.daocloud.io/" + expected)
|
||||
|
||||
def test_private_repository_and_credentials_are_not_sent_to_public_mirror(self) -> None:
|
||||
for image in ("private.example/team/python:3", "docker.io/private/app:latest"):
|
||||
with self.assertRaises(mirror.MirrorError):
|
||||
mirror.mirror_image(image, {})
|
||||
with self.assertRaises(mirror.MirrorError):
|
||||
mirror.mirror_prefix({"PLANET_DOCKER_MIRROR_PREFIX": "user:secret@mirror.test"})
|
||||
|
||||
def test_manifest_is_required_and_pinned_even_for_mutable_tags(self) -> None:
|
||||
with patch.object(mirror, "run", return_value=json.dumps({"digest": DIGEST})):
|
||||
self.assertEqual(mirror.resolve_image("python:3.14-slim", {}),
|
||||
"m.daocloud.io/docker.io/library/python@" + DIGEST)
|
||||
self.assertEqual(mirror.resolve_image("redis@" + DIGEST, {}),
|
||||
"m.daocloud.io/docker.io/library/redis@" + DIGEST)
|
||||
for manifest in ([], {}, {"digest": None}, {"digest": "invalid"}, {"digest": "sha256:" + "b" * 64}):
|
||||
with patch.object(mirror, "run", return_value=json.dumps(manifest)):
|
||||
with self.assertRaises(mirror.MirrorError):
|
||||
mirror.resolve_image("redis@" + DIGEST, {})
|
||||
|
||||
def test_primary_route_is_preferred_and_fallback_can_be_disabled(self) -> None:
|
||||
for reachable, disabled, calls in ((True, False, 1), (False, True, 1), (False, False, 2)):
|
||||
with patch.object(proxy, "select_route", side_effect=[
|
||||
{"reachable": reachable}, {"reachable": True}
|
||||
]) as select:
|
||||
plan = proxy.plan_routes({"PLANET_DOCKER_MIRROR_FALLBACK": "0"} if disabled else {})
|
||||
self.assertEqual(select.call_count, calls)
|
||||
self.assertEqual(plan["mirror"], calls == 2)
|
||||
|
||||
def test_database_pull_keeps_canonical_tag_and_does_not_replace_cached_images(self) -> None:
|
||||
for cached in (True, False):
|
||||
calls = []
|
||||
def run(command, timeout=30):
|
||||
calls.append(command)
|
||||
return json.dumps({"services": {"postgres": {"image": "postgres:15"}}})
|
||||
with patch.object(mirror, "run", side_effect=run), \
|
||||
patch.object(mirror, "resolve_image", return_value="mirror/pg@" + DIGEST), \
|
||||
patch.object(mirror.subprocess, "run", return_value=subprocess.CompletedProcess([], 0 if cached else 1)):
|
||||
mirror.pull_services(["postgres"], {})
|
||||
self.assertEqual(len(calls), 1 if cached else 3)
|
||||
if not cached:
|
||||
self.assertEqual(calls[-1], ["docker", "tag", "mirror/pg@" + DIGEST, "postgres:15"])
|
||||
|
||||
def test_only_registry_network_errors_allow_fallback(self) -> None:
|
||||
cases = (
|
||||
('Head "https://registry-1.docker.io/v2/python": i/o timeout', True),
|
||||
('#3 [internal] load metadata for python\nPLANET_DOCKER_COMMAND_TIMEOUT', True),
|
||||
('Head "https://registry-1.docker.io/v2/python": 429 Too Many Requests', False),
|
||||
('Head "https://registry-1.docker.io/v2/python": unauthorized:', False),
|
||||
('Head "https://registry-1.docker.io/v2/python": x509: unknown authority', False),
|
||||
('#3 load metadata for python\nno such host\nprocess "/bin/sh -c uv sync" failed', False),
|
||||
('Bind for 127.0.0.1:5432 failed: port is already allocated', False),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
log = Path(folder) / "failure.log"
|
||||
for evidence, expected in cases:
|
||||
log.write_text(evidence)
|
||||
result = run_shell([], f"""
|
||||
SCRIPT_DIR={ROOT}
|
||||
source {ROOT}/scripts/lib/error-diagnostics.zsh
|
||||
source {ROOT}/scripts/lib/docker-mirror.zsh
|
||||
""", f"docker_failure_allows_mirror {log}")
|
||||
self.assertEqual(result.returncode == 0, expected, evidence + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -112,6 +112,8 @@ class ErrorDiagnosticsTests(unittest.TestCase):
|
||||
source {shlex.quote(str(MODULE))}
|
||||
log_line() {{ echo "$3"; }}
|
||||
log_note() {{ echo "$1"; }}
|
||||
stop_wait_session() {{ :; }}
|
||||
VERBOSE=1
|
||||
{shell_function('log_error')}
|
||||
log_error 'new failure'
|
||||
""",
|
||||
@@ -133,19 +135,22 @@ class ErrorDiagnosticsTests(unittest.TestCase):
|
||||
with self.subTest(verbose=verbose):
|
||||
log = Path(folder) / "build ' quoted $(not-a-command).log"
|
||||
result = run_shell(
|
||||
["run_ai_provider_build_command"],
|
||||
["run_ai_provider_build_command", "run_docker_observed"],
|
||||
f"""
|
||||
PATH={shlex.quote(folder + ':' + os.environ['PATH'])}
|
||||
VERBOSE={verbose}
|
||||
DOCKER_COMMAND_RUNNER={shlex.quote(str(ROOT / 'scripts/docker_command.py'))}
|
||||
AI_PROVIDER_BUILD_LOG_FILE={shlex.quote(str(log))}
|
||||
run_command_with_spinner() {{ shift; "$@"; }}
|
||||
clear_wait_spinner() {{ :; }}
|
||||
docker_failure_allows_mirror() {{ return 1; }}
|
||||
""",
|
||||
'if run_ai_provider_build_command "docker compose"; '
|
||||
"then exit 0; else exit $?; fi",
|
||||
)
|
||||
self.assertEqual(result.returncode, 17, result.stderr)
|
||||
self.assertEqual(log.read_text(), "TLS handshake timeout\n")
|
||||
self.assertNotIn("not-a-command", result.stderr)
|
||||
self.assertEqual(result.stdout + result.stderr, "")
|
||||
self.assertNotIn("command not found", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,9 +2,56 @@
|
||||
|
||||
# Docker prerequisites for planet.sh; lifecycle and logging remain in planet.sh.
|
||||
DOCKER_MIN_BUILDX_VERSION="0.17.0"
|
||||
DOCKER_COMMAND_RUNNER="${${(%):-%N}:A:h:h}/docker_command.py"
|
||||
|
||||
docker_probe() {
|
||||
local log_file="$(mktemp "$PLANET_STATE_DIR/docker-probe.XXXXXX.log")"
|
||||
local label="检查 Docker"
|
||||
case "$1 $2" in
|
||||
'docker compose') label="检查 Docker Compose" ;;
|
||||
'docker info') label="检查 Docker 服务" ;;
|
||||
'docker buildx') label="检查 Docker Buildx" ;;
|
||||
'docker context') label="检查 Docker context" ;;
|
||||
'docker inspect') label="检查容器 ${@[-1]}" ;;
|
||||
'docker start'|'docker restart') label="启动容器 ${@[-1]}" ;;
|
||||
esac
|
||||
run_docker_observed "$label" "${PLANET_DOCKER_PROBE_TIMEOUT:-15}" "$log_file" "$@"
|
||||
}
|
||||
|
||||
run_docker_observed() {
|
||||
setopt localtraps
|
||||
local label="$1" deadline="$2" log_file="$3"
|
||||
shift 3
|
||||
local detail_file="${log_file}.status" command_status=0
|
||||
LAST_COMMAND_ERROR=""
|
||||
python3 "$DOCKER_COMMAND_RUNNER" --label "$label" --timeout "$deadline" \
|
||||
--log "$log_file" --status-file "$detail_file" -- "$@" >/dev/null 2>&1 &
|
||||
local command_pid=$!
|
||||
trap 'kill -TERM "$command_pid" 2>/dev/null || true; wait "$command_pid" 2>/dev/null || true; return 130' INT
|
||||
trap 'kill -TERM "$command_pid" 2>/dev/null || true; wait "$command_pid" 2>/dev/null || true; return 143' TERM
|
||||
while kill -0 "$command_pid" 2>/dev/null; do
|
||||
if (( $+functions[set_wait_detail] )); then
|
||||
local detail="$label"
|
||||
[ ! -f "$detail_file" ] || detail="$(<"$detail_file")"
|
||||
set_wait_detail "$detail"
|
||||
fi
|
||||
sleep 0.125
|
||||
done
|
||||
wait "$command_pid" || command_status=$?
|
||||
if [ "$command_status" -ne 0 ]; then
|
||||
LAST_COMMAND_ERROR="$label"
|
||||
[ ! -s "$detail_file" ] || LAST_COMMAND_ERROR="$(<"$detail_file")"
|
||||
fi
|
||||
rm -f "$detail_file"
|
||||
if (( $+functions[set_wait_detail] )); then
|
||||
set_wait_detail "${LAST_COMMAND_ERROR:-}"
|
||||
fi
|
||||
cat "$log_file"
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
compose_available() {
|
||||
docker compose version >/dev/null 2>&1
|
||||
docker_probe docker compose version >/dev/null
|
||||
}
|
||||
|
||||
compose_v1_available() {
|
||||
@@ -12,7 +59,7 @@ compose_v1_available() {
|
||||
}
|
||||
|
||||
buildx_version() {
|
||||
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
|
||||
docker_probe docker buildx version | awk '{print $2}' | sed 's/^v//'
|
||||
}
|
||||
|
||||
buildx_meets_minimum() {
|
||||
@@ -30,14 +77,14 @@ buildx_meets_minimum() {
|
||||
}
|
||||
|
||||
docker_daemon_available() {
|
||||
command -v docker >/dev/null 2>&1 && command docker info >/dev/null 2>&1
|
||||
command -v docker >/dev/null 2>&1 && docker_probe docker info --format '{{.ServerVersion}}' >/dev/null
|
||||
}
|
||||
|
||||
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
|
||||
endpoint="$(docker_probe docker context inspect --format '{{.Endpoints.docker.Host}}')" || return 1
|
||||
elif [ -n "${DOCKER_CONTEXT:-}" ]; then
|
||||
return 1
|
||||
else
|
||||
|
||||
44
scripts/lib/docker-mirror.zsh
Normal file
44
scripts/lib/docker-mirror.zsh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
docker_failure_allows_mirror() {
|
||||
[ "${PLANET_DOCKER_MIRROR_FALLBACK:-1}" != 0 ] || return 1
|
||||
local record="$(planet_error_record '' "$1")"
|
||||
local code="${record%%$'\t'*}"
|
||||
case "$code" in
|
||||
P_DNS|P_NETWORK_TIMEOUT|P_CONNECTION_REFUSED|P_DOCKER_COMMAND_TIMEOUT) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
grep -Eq 'process "/bin/sh -c|executor failed running' "$1" && return 1
|
||||
# Package installation and daemon failures are not image-registry failures.
|
||||
grep -Eiq 'load metadata|resolve source metadata|failed to resolve|fetch anonymous token|https?://[^ ]*/v2/|Pulling|Downloading' "$1"
|
||||
}
|
||||
|
||||
resolve_build_mirror_args() {
|
||||
local mirror_log="$(mktemp "$PLANET_STATE_DIR/docker-mirror.XXXXXX.log")"
|
||||
DOCKER_MIRROR_BUILD_ARGS=()
|
||||
if ! run_docker_observed "原镜像源不可达,验证备用镜像" \
|
||||
"${PLANET_DOCKER_MIRROR_TIMEOUT:-120}" "$mirror_log" \
|
||||
python3 "$SCRIPT_DIR/scripts/docker_mirror.py" build >/dev/null; then
|
||||
cp "$mirror_log" "$AI_PROVIDER_BUILD_LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
local assignment
|
||||
while IFS= read -r assignment; do
|
||||
DOCKER_MIRROR_BUILD_ARGS+=(--build-arg "$assignment")
|
||||
done < "$mirror_log"
|
||||
}
|
||||
|
||||
pull_database_mirrors() {
|
||||
local -a services=()
|
||||
local argument
|
||||
for argument in "$@"; do
|
||||
case "$argument" in postgres|redis) services+=("$argument") ;; esac
|
||||
done
|
||||
[ "${#services[@]}" -gt 0 ] || return 1
|
||||
prepare_docker_build_proxy mirror || return 1
|
||||
local mirror_log="$(mktemp "$PLANET_STATE_DIR/docker-mirror.XXXXXX.log")"
|
||||
DOCKER_MIRROR_ERROR_LOG_FILE="$mirror_log"
|
||||
run_docker_observed "原镜像源不可达,临时使用备用源" \
|
||||
"${PLANET_COMPOSE_TIMEOUT:-180}" "$mirror_log" \
|
||||
python3 "$SCRIPT_DIR/scripts/docker_mirror.py" pull "${services[@]}" >/dev/null
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
prepare_docker_build_proxy() {
|
||||
DOCKER_BUILD_USE_MIRROR=0
|
||||
# Desktop and remote/rootless daemons are managed in their own environment.
|
||||
docker_uses_local_engine || return 0
|
||||
docker_desktop_present && return 0
|
||||
@@ -18,7 +19,8 @@ prepare_docker_build_proxy() {
|
||||
: > "$error_file"
|
||||
chmod 600 "$error_file"
|
||||
{
|
||||
if ! "$python_bin" "$helper" plan > "$plan_file" 2> "$error_file"; then
|
||||
set_wait_detail "检查镜像源的代理、直连与备用路径"
|
||||
if ! "$python_bin" "$helper" plan "${1:-auto}" > "$plan_file" 2> "$error_file"; then
|
||||
log_error "Docker 构建代理检测失败" "$error_file"
|
||||
return 1
|
||||
fi
|
||||
@@ -26,7 +28,11 @@ prepare_docker_build_proxy() {
|
||||
log_error "Docker 构建代理检测失败" "$error_file"
|
||||
return 1
|
||||
fi
|
||||
read -r mode changed connected <<< "$status_text"
|
||||
read -r mode changed connected DOCKER_BUILD_USE_MIRROR <<< "$status_text"
|
||||
if [ "$connected" -ne 1 ]; then
|
||||
log_error "PLANET_PROXY_NO_ROUTE"
|
||||
return 1
|
||||
fi
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
docker_require_sudo || return 1
|
||||
log_note "更新 Docker 构建代理配置,重启后恢复原先运行的容器"
|
||||
@@ -35,14 +41,10 @@ prepare_docker_build_proxy() {
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ "$connected" -ne 1 ]; then
|
||||
log_error "PLANET_PROXY_NO_ROUTE"
|
||||
return 1
|
||||
fi
|
||||
if [ "$mode" = proxy ]; then
|
||||
log_note "Docker 构建使用已验证可用的主机代理"
|
||||
set_wait_detail "Docker 使用已验证可用的主机代理"
|
||||
else
|
||||
log_note "未发现可用主机代理,Docker 构建使用直连"
|
||||
set_wait_detail "Docker 使用已验证可用的直连路径"
|
||||
fi
|
||||
} always {
|
||||
rm -f -- "$plan_file" "$error_file"
|
||||
|
||||
Reference in New Issue
Block a user