Files
Jiangzhou 92d611968e feat: unified Docker workspace mount with supervised daemon (#135)
* feat: unified Docker workspace mount with supervised daemon

Reshape the Docker experience around a single bind mount and a single
named volume. Global settings live on the host under
$HOME/.cocoindex_code/ (visible and editable); index data and the model
cache persist in one cocoindex-data volume; daemon runtime state stays
on the container's native filesystem.

CLI and MCP output now show host-side paths via a bidirectional
COCOINDEX_CODE_HOST_PATH_MAPPING translator. A shell wrapper that
forwards $PWD (COCOINDEX_CODE_HOST_CWD) lets ccc work from any project
subdirectory on the host.

The daemon tolerates a missing global_settings.yml (starts in
no-settings mode) so ccc init's interactive picker works in Docker on
first run. A supervisor restart loop in the entrypoint, driven by a new
COCOINDEX_CODE_DAEMON_SUPERVISED contract, makes settings-change
auto-restart safe — editing global_settings.yml triggers an in-place
daemon respawn without taking the container down.

Linux ownership alignment via PUID/PGID, gosu privilege drop, and a
coco user baked into the image. Release workflow now publishes to both
Docker Hub (cocoindex/cocoindex-code) and GHCR
(ghcr.io/cocoindex-io/cocoindex-code).

Also:
- Merge cocoindex-db and cocoindex-model-cache into a single volume
- find_parent_with_marker requires .cocoindex_code/settings.yml, so a
  workspace-root global-only dir doesn't trigger nested-init warnings
- New pytest marker `docker_e2e` gates the Docker-backed E2E suite
  (excluded from default pytest runs)

* fix: mypy on Windows for POSIX-only os.getuid/getgid calls
2026-04-14 13:30:50 -07:00

372 lines
12 KiB
Python

"""End-to-end tests exercising the full Docker setup.
Gated behind the ``docker_e2e`` marker (excluded from the default pytest run).
Run with: ``uv run pytest -m docker_e2e``.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
try:
result = subprocess.run(["docker", "info"], capture_output=True, timeout=5, check=False)
except (subprocess.TimeoutExpired, OSError):
return False
return result.returncode == 0
pytestmark = [
pytest.mark.docker_e2e,
pytest.mark.skipif(not _docker_available(), reason="Docker not available on this host"),
]
def docker_exec(
container_name: str,
argv: list[str],
env: dict[str, str] | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Thin wrapper over ``docker exec`` that captures stdout/stderr as text."""
cmd = ["docker", "exec"]
for k, v in (env or {}).items():
cmd.extend(["-e", f"{k}={v}"])
cmd.append(container_name)
cmd.extend(argv)
return subprocess.run(cmd, capture_output=True, text=True, check=check)
def test_index_and_search_happy_path(container: str, fixture_workspace: Path) -> None:
"""Baseline flow: daemon comes up, `ccc init` writes settings and triggers
supervised respawn, `ccc index` succeeds, `ccc search` returns hits.
"""
# Container starts with no global_settings.yml; daemon is in no-settings mode.
# `ccc init -f` writes defaults (no TTY → falls back to default st model).
docker_exec(
container,
["ccc", "init", "-f"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace)},
)
index_result = docker_exec(
container,
["ccc", "index"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace)},
)
assert index_result.returncode == 0, index_result.stderr
search = docker_exec(
container,
["ccc", "search", "password", "verification"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace)},
)
assert search.returncode == 0, search.stderr
# Relative path — unchanged by host-path mapping, identical to what a
# host-side `ccc` would print.
assert "src/auth.py" in search.stdout
def test_host_cwd_forwarding_resolves_project(container: str, fixture_workspace: Path) -> None:
"""With COCOINDEX_CODE_HOST_CWD set, subproject-cwd commands find the right project."""
docker_exec(
container,
["ccc", "init", "-f"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace)},
)
# Invoke `ccc status` from the host-form subdirectory path.
status = docker_exec(
container,
["ccc", "status"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace / "src")},
)
assert status.returncode == 0, status.stderr
# Project header should show the HOST path (translated via the mapping),
# rooted at the host workspace — not /workspace.
assert str(fixture_workspace) in status.stdout
def test_host_cwd_invalid_warns_but_continues(container: str) -> None:
"""An unresolvable host-cwd emits a stderr warning; the command still runs."""
result = docker_exec(
container,
["ccc", "daemon", "status"],
env={"COCOINDEX_CODE_HOST_CWD": "/nonexistent/zzz"},
check=False,
)
assert "COCOINDEX_CODE_HOST_CWD" in result.stderr
assert result.returncode == 0
def test_settings_change_triggers_supervised_restart(
container: str, fixture_workspace: Path
) -> None:
"""Editing ``global_settings.yml`` should cause the next CLI call to
restart the daemon via the entrypoint's respawn loop — not take the
container down.
"""
import time
# First pass — daemon in no-settings mode writes settings via init.
docker_exec(
container,
["ccc", "init", "-f"],
env={"COCOINDEX_CODE_HOST_CWD": str(fixture_workspace)},
)
# Record the container's start-time so we can detect a respawn (pid reset).
initial_status = docker_exec(container, ["ccc", "daemon", "status"])
assert initial_status.returncode == 0
assert "Uptime:" in initial_status.stdout
# Edit global_settings.yml on the host side (bind mount → visible in container).
settings_file = fixture_workspace / ".cocoindex_code" / "global_settings.yml"
assert settings_file.is_file()
content = settings_file.read_text()
# Touch mtime by rewriting identical content — still triggers mtime mismatch.
time.sleep(1.1) # ensure mtime actually changes on coarse-grained filesystems
settings_file.write_text(content)
# Next CLI call: the client should detect the mtime mismatch, request
# daemon stop, and the entrypoint restart loop should bring a fresh
# daemon back up. Container must still be running.
after = docker_exec(container, ["ccc", "daemon", "status"])
assert after.returncode == 0, after.stderr
assert "Uptime:" in after.stdout
# Container's PID 1 (the entrypoint shell) should still be alive → container not restarted.
inspect = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", container],
capture_output=True,
text=True,
check=True,
)
assert inspect.stdout.strip() == "running"
def test_first_start_uses_baked_model(container: str) -> None:
"""Fresh container starts with the pre-baked model — no network download."""
# The daemon is already up from the `container` fixture's readiness probe.
# Check that the sentence-transformers cache contains the baked model.
check = docker_exec(
container,
["ls", "/var/cocoindex/cache/sentence-transformers"],
)
assert check.returncode == 0
# At least one model directory should be present from the bake stage.
assert check.stdout.strip() != ""
# Daemon log should not contain a "Downloading" line.
log_result = subprocess.run(
["docker", "logs", container],
capture_output=True,
text=True,
check=True,
)
# sentence-transformers prints "Downloading" when it fetches a model. A
# missing bake would trigger that.
assert "Downloading" not in log_result.stdout
assert "Downloading" not in log_result.stderr
@pytest.mark.skipif(sys.platform != "linux", reason="PUID/PGID only meaningful on Linux")
def test_linux_puid_gives_host_owned_files(
docker_image: str, fixture_workspace: Path, tmp_path: Path
) -> None:
"""With PUID/PGID set, daemon-written files on the bind mount are owned by the host user."""
import os
import uuid
name = f"ccc-e2e-puid-{uuid.uuid4().hex[:8]}"
host_ws = str(fixture_workspace)
# os.getuid/getgid only exist on POSIX; the skipif above already gates
# this test to Linux, so getattr lets mypy pass on Windows runners.
uid = os.getuid() # type: ignore[attr-defined,unused-ignore]
gid = os.getgid() # type: ignore[attr-defined,unused-ignore]
try:
subprocess.run(
[
"docker",
"run",
"-d",
"--rm",
"--name",
name,
"-v",
f"{host_ws}:/workspace",
"-v",
"/var/cocoindex",
"-e",
f"COCOINDEX_CODE_HOST_PATH_MAPPING=/workspace={host_ws}",
"-e",
f"PUID={uid}",
"-e",
f"PGID={gid}",
docker_image,
],
check=True,
capture_output=True,
)
# Wait for daemon startup (which creates /workspace/.cocoindex_code/global_settings.yml).
import time
settings_file = fixture_workspace / ".cocoindex_code" / "global_settings.yml"
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if settings_file.is_file():
break
time.sleep(0.5)
else:
raise TimeoutError("Daemon did not create global_settings.yml in time")
st = settings_file.stat()
assert st.st_uid == uid, f"Expected uid {uid}, got {st.st_uid}"
assert st.st_gid == gid, f"Expected gid {gid}, got {st.st_gid}"
finally:
subprocess.run(["docker", "rm", "-f", name], capture_output=True, check=False)
@pytest.mark.skipif(sys.platform != "linux", reason="root-in-container UID check is Linux-specific")
def test_linux_no_puid_runs_as_root(container: str) -> None:
"""With PUID/PGID unset, the daemon process stays as uid 0 (preserves today's default)."""
result = docker_exec(container, ["id", "-u"])
assert result.stdout.strip() == "0"
def test_docker_compose_smoke(docker_image: str, fixture_workspace: Path, tmp_path: Path) -> None:
"""`docker compose up -d` + `docker compose exec ccc search` round-trips."""
import os
import shutil
compose_src = Path(__file__).resolve().parents[2] / "docker" / "docker-compose.yml"
compose_dst = tmp_path / "docker-compose.yml"
shutil.copy2(compose_src, compose_dst)
# The compose file references cocoindex/cocoindex-code:latest (the published
# release image). Tag the locally-built pytest image under that name so
# `docker compose up` uses our local code instead of pulling from Docker Hub.
subprocess.run(
["docker", "tag", docker_image, "cocoindex/cocoindex-code:latest"],
check=True,
)
env = dict(os.environ)
env["COCOINDEX_HOST_WORKSPACE"] = str(fixture_workspace)
try:
subprocess.run(
["docker", "compose", "-f", str(compose_dst), "up", "-d"],
cwd=tmp_path,
env=env,
check=True,
capture_output=True,
)
# Wait for daemon readiness.
import time
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
check = subprocess.run(
[
"docker",
"compose",
"-f",
str(compose_dst),
"exec",
"-T",
"cocoindex-code",
"sh",
"-c",
"test -S /var/run/cocoindex_code/daemon.sock",
],
cwd=tmp_path,
env=env,
capture_output=True,
check=False,
)
if check.returncode == 0:
break
time.sleep(1)
else:
raise TimeoutError("Daemon did not become ready via compose")
# Index, then search.
subprocess.run(
[
"docker",
"compose",
"-f",
str(compose_dst),
"exec",
"-T",
"-e",
f"COCOINDEX_CODE_HOST_CWD={fixture_workspace}",
"cocoindex-code",
"ccc",
"init",
"-f",
],
cwd=tmp_path,
env=env,
check=True,
capture_output=True,
)
subprocess.run(
[
"docker",
"compose",
"-f",
str(compose_dst),
"exec",
"-T",
"-e",
f"COCOINDEX_CODE_HOST_CWD={fixture_workspace}",
"cocoindex-code",
"ccc",
"index",
],
cwd=tmp_path,
env=env,
check=True,
capture_output=True,
)
search = subprocess.run(
[
"docker",
"compose",
"-f",
str(compose_dst),
"exec",
"-T",
"-e",
f"COCOINDEX_CODE_HOST_CWD={fixture_workspace}",
"cocoindex-code",
"ccc",
"search",
"request",
"handler",
],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
check=True,
)
assert "src/handlers.py" in search.stdout
finally:
subprocess.run(
["docker", "compose", "-f", str(compose_dst), "down", "-v"],
cwd=tmp_path,
env=env,
capture_output=True,
check=False,
)