Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"):
Expand Down Expand Up @@ -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。"""
Expand Down
44 changes: 44 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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"]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 源不可达而失败。