From 9b84f0dc5d67f47bdaaaf9806ef89db1f9300554 Mon Sep 17 00:00:00 2001 From: PlankBevelen <3585138040@qq.com> Date: Sat, 10 Oct 2026 21:37:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20Compose=20=E9=95=BF=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=20bind=20=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E5=AE=BF=E4=B8=BB=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 #1089:开发 Compose 在 skill-sources 等状态目录不存在时启动失败。 显式开启 create_host_path,并补充正负向契约测试与决策记录。 Co-authored-by: Cursor --- .../test_docker_compose_service_boundaries.py | 33 +++++++++++++- docker-compose.yml | 44 +++++++++++++++++++ ...026-10-10-compose-bind-create-host-path.md | 33 ++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 docs/develop-guides/decisions/implemented/2026-10-10-compose-bind-create-host-path.md diff --git a/backend/test/unit/config/test_docker_compose_service_boundaries.py b/backend/test/unit/config/test_docker_compose_service_boundaries.py index 0bfeafe92..ee1b7a647 100644 --- a/backend/test/unit/config/test_docker_compose_service_boundaries.py +++ b/backend/test/unit/config/test_docker_compose_service_boundaries.py @@ -67,7 +67,7 @@ def _project_root() -> Path: def _load_compose(filename: str) -> dict: - return yaml.safe_load((_project_root() / filename).read_text()) + return yaml.safe_load((_project_root() / filename).read_text(encoding="utf-8")) def _volume_target(volume: object) -> str: @@ -89,6 +89,19 @@ def _volume_is_read_only(volume: object) -> bool: return isinstance(volume, str) and len(volume.split(":")) >= 3 and volume.split(":")[-1] == "ro" +def _long_bind_missing_create_host_path(compose: dict) -> set[tuple[str, str]]: + """识别未显式开启 create_host_path 的长格式 bind,避免缺失宿主目录时启动失败。""" + violations: set[tuple[str, str]] = set() + for service_name, service in compose.get("services", {}).items(): + for volume in service.get("volumes") or []: + if not isinstance(volume, dict) or volume.get("type") != "bind": + continue + if (volume.get("bind") or {}).get("create_host_path") is True: + continue + violations.add((service_name, str(volume.get("source") or volume.get("target") or ""))) + return violations + + def _forbidden_api_worker_mounts(compose: dict) -> set[tuple[str, str]]: violations: set[tuple[str, str]] = set() for service_name in ("api", "worker"): @@ -131,6 +144,24 @@ def _legacy_workspace_permission_markers(source: str) -> set[str]: return {marker for marker in LEGACY_WORKSPACE_PERMISSION_MARKERS if marker in source} +def test_dev_compose_long_binds_set_create_host_path() -> None: + """开发 Compose 长格式 bind 必须在缺失宿主目录时自动创建路径。""" + assert _long_bind_missing_create_host_path(_load_compose("docker-compose.yml")) == set() + + +def test_dev_compose_create_host_path_guard_detects_omitted_bind_option() -> None: + """负控证明去掉 create_host_path 后契约检查会失败。""" + compose = deepcopy(_load_compose("docker-compose.yml")) + volume = next( + volume + for volume in compose["services"]["api"]["volumes"] + if isinstance(volume, dict) and volume.get("target") == "/app/skill-sources" + ) + volume.pop("bind", None) + + assert ("api", volume["source"]) in _long_bind_missing_create_host_path(compose) + + @pytest.mark.parametrize("filename", ["docker-compose.yml", "docker-compose.prod.yml"]) def test_api_and_worker_do_not_mount_unused_host_dependencies(filename: str): """API/worker 不得重新依赖模型目录或 Docker daemon。""" diff --git a/docker-compose.yml b/docker-compose.yml index f6ee9fbc9..517909bd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,12 +59,18 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/threads target: /app/user-data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-sources target: /app/skill-sources + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-projections target: /app/skill-projections + bind: + create_host_path: true - ./backend/test:/app/test - ./backend/scripts:/app/scripts - ./docker/sandbox_provisioner:/app/docker/sandbox_provisioner:ro @@ -121,12 +127,18 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/threads target: /app/user-data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-sources target: /app/skill-sources + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-projections target: /app/skill-projections + bind: + create_host_path: true - ./backend/test:/app/test - ./backend/scripts:/app/scripts - ./docker/sandbox_provisioner:/app/docker/sandbox_provisioner:ro @@ -181,15 +193,23 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi target: /app/legacy-saves + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/threads target: /app/user-data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-sources target: /app/skill-sources + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-projections target: /app/skill-projections + bind: + create_host_path: true networks: - app-network env_file: @@ -214,9 +234,13 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/threads target: /app/user-data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/yuxi/skill-projections target: /app/skill-projections + bind: + create_host_path: true - ./docker/sandbox_provisioner/app.py:/app/app.py:ro - ${SANDBOX_ENV_FILE:-./docker/sandbox_provisioner/sandbox.env}:/app/sandbox.env:ro - /var/run/docker.sock:/var/run/docker.sock @@ -312,9 +336,13 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/neo4j/data target: /data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/neo4j/logs target: /logs + bind: + create_host_path: true healthcheck: test: ["CMD-SHELL", "cypher-shell -a bolt://localhost:7687 -u ${NEO4J_USERNAME:-neo4j} -p ${NEO4J_PASSWORD:-0123456789} 'RETURN 1;' >/dev/null || exit 1"] interval: 20s @@ -343,6 +371,8 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/milvus/etcd target: /etcd + bind: + create_host_path: true command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd healthcheck: test: ["CMD", "etcdctl", "endpoint", "health"] @@ -366,9 +396,13 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/milvus/minio target: /data + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/milvus/minio_config target: /root/.minio + bind: + create_host_path: true command: minio server /data --address 0.0.0.0:9000 --console-address 0.0.0.0:9001 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] @@ -403,9 +437,13 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/milvus/milvus target: /var/lib/milvus + bind: + create_host_path: true - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/milvus/logs target: /var/lib/milvus/logs + bind: + create_host_path: true healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] interval: 60s @@ -436,6 +474,8 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/postgresql target: /var/lib/postgresql/data + bind: + create_host_path: true healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-yuxi} || exit 1"] interval: 5s @@ -459,6 +499,8 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/redis target: /data + bind: + create_host_path: true networks: - app-network restart: unless-stopped @@ -513,6 +555,8 @@ services: - type: bind source: ${YUXI_STATE_DIR:-./docker/volumes}/paddlex target: /paddlex + bind: + create_host_path: true ports: - "127.0.0.1:${YUXI_PADDLEX_PORT:-8080}:8080" healthcheck: diff --git a/docs/develop-guides/decisions/implemented/2026-10-10-compose-bind-create-host-path.md b/docs/develop-guides/decisions/implemented/2026-10-10-compose-bind-create-host-path.md new file mode 100644 index 000000000..3520f9523 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-10-10-compose-bind-create-host-path.md @@ -0,0 +1,33 @@ +# Compose 长格式 bind 自动创建宿主目录 + +状态:implemented +类型:bug-fix +Owner:docker-compose.yml + +## 问题 + +Issue #1089:新克隆仓库后 `docker compose up` 因长格式 bind 的宿主路径不存在而失败(例如 `docker/volumes/yuxi/skill-sources`)。短格式会创建缺失目录,长格式走 mount API 时默认不会。 + +## 决策 + +### 实现方案 + +开发 Compose 中所有 `type: bind` 状态挂载显式设置 `bind.create_host_path: true`,由 Compose 在启动前创建缺失宿主目录。生产 Compose 继续使用短格式,行为不变。不在 `init.sh` 预创建目录,避免与 Compose 契约双写。 + +## 替代方案 + +在 init 脚本中 `mkdir`:增加第二 Owner,且跳过 init 的直接 `compose up` 仍会失败。把开发配置改回短格式:能创建目录,但失去与现有长格式挂载声明的一致性,且不解决后续再引入长格式时的同类缺陷。 + +## 后果 + +首次启动会在 `YUXI_STATE_DIR`(默认 `./docker/volumes`)下自动创建空状态目录。生产短格式路径不受影响。 + +## 验证 + +| 验收主张 | 失败面 | 语义 Owner | 直接证据 / 命令 | 负向案例 | 当前结果 | +|---|---|---|---|---|---| +| 开发 Compose 每个长格式 bind 开启 create_host_path | 漏改某一挂载 | docker-compose.yml | unit:`test_dev_compose_long_binds_set_create_host_path`;`docker compose config` 回读 21 个默认 profile 长格式 bind 均含 `create_host_path: true` | 去掉 skill-sources 的 bind 选项后 guard 失败 | Passed | +| 契约回归不被静默绕过 | 只改 YAML 不改测试 | test_docker_compose_service_boundaries.py | 负向 unit | 同上 | Passed | + +- Passed:`python -m pytest backend/test/unit/config/test_docker_compose_service_boundaries.py -k create_host_path --noconftest -q`(2 项);临时 `YUXI_STATE_DIR` 下 `docker compose config --format json` 回读默认 profile 21 个长格式状态 bind 均含 `create_host_path: true`;YAML 静态计数 22 个长格式 bind(含 `paddlex` profile)均设置该选项;临时空 `YUXI_STATE_DIR` 执行 `docker compose up -d redis` 退出码 0 且宿主 `redis/` 目录被创建;`python scripts/verify_engineering_contracts.py`;`git diff --check`。 +- Not run:`docker compose exec api … pytest` 因本地重建 API 镜像时清华 apt 源不可达而失败。