Compare commits

...

1 Commits
v0.74.6 ... dev

Author SHA1 Message Date
linkong
60eefb19c9 release: bump version to 0.74.7
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
ci / delivery (push) Blocked by required conditions
release / images (push) Waiting to run
2026-09-17 17:30:54 +08:00
25 changed files with 1079 additions and 116 deletions

View File

@@ -1 +1 @@
0.74.6
0.74.7

View File

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

View File

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

View File

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

View File

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

View File

@@ -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并把它传给 ComposeCompose 失败时保留错误并停止,不再回退手动启动。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自:
1. `aiprovider/.env`
2. `~/.zshrc` 中简单的 `export AI_...=...``AI_...=...`

View File

@@ -302,7 +302,7 @@ OTP 一次性验证码走 Rediskey 格式 `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 一次性验证码走 Rediskey 格式 `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前端使用 BunPython 使用项目 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 一次性验证码走 Rediskey 格式 `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`。日志权限为 600URL 中的代理用户名和密码会被遮盖。`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。代理地址从环境读取不硬编码某台机器的端口。

View File

@@ -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 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 |

View File

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

218
planet.sh
View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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__":

View File

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

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

View File

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

2
uv.lock generated
View File

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