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

189 lines
6.8 KiB
Python

"""End-to-end tests for the CLI → daemon subprocess flow.
These tests start a real daemon subprocess via ``start_daemon()`` and interact
with it through the per-request client functions, mirroring how ``ccc index`` /
``ccc search`` actually work.
"""
from __future__ import annotations
import os
import tempfile
import time
from collections.abc import Iterator
from pathlib import Path
import pytest
from conftest import make_test_user_settings
from cocoindex_code import client
from cocoindex_code._version import __version__
from cocoindex_code.client import start_daemon, stop_daemon
from cocoindex_code.daemon import daemon_socket_path
from cocoindex_code.settings import (
default_project_settings,
save_project_settings,
save_user_settings,
)
SAMPLE_PY = '''\
"""Sample module."""
def calculate_fibonacci(n: int) -> int:
"""Calculate the nth Fibonacci number."""
if n <= 1:
return n
return calculate_fibonacci(n - 1) + calculate_fibonacci(n - 2)
'''
@pytest.fixture(scope="module")
def e2e_daemon() -> Iterator[tuple[str, Path]]:
"""Start a real daemon subprocess and return (sock_path, project_dir).
Uses COCOINDEX_CODE_DIR env var so the subprocess uses the temp directory.
"""
# Use a short temp dir to stay within AF_UNIX path limit
base_dir = Path(tempfile.mkdtemp(prefix="ccc_e2e_"))
project_dir = base_dir / "proj"
project_dir.mkdir()
(project_dir / "main.py").write_text(SAMPLE_PY)
# Set env var BEFORE calling any daemon/settings functions
old_env = os.environ.get("COCOINDEX_CODE_DIR")
os.environ["COCOINDEX_CODE_DIR"] = str(base_dir)
try:
save_user_settings(make_test_user_settings())
save_project_settings(project_dir, default_project_settings())
proc = start_daemon()
sock_path = daemon_socket_path()
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
log = base_dir / "daemon.log"
log_content = log.read_text() if log.exists() else "(no log)"
raise RuntimeError(f"Daemon process exited early.\nLog:\n{log_content}")
if os.path.exists(sock_path):
break
time.sleep(0.2)
else:
log = base_dir / "daemon.log"
log_content = log.read_text() if log.exists() else "(no log)"
raise TimeoutError(f"Daemon did not start.\nLog:\n{log_content}")
yield sock_path, project_dir
finally:
stop_daemon()
if old_env is None:
os.environ.pop("COCOINDEX_CODE_DIR", None)
else:
os.environ["COCOINDEX_CODE_DIR"] = old_env
def test_daemon_subprocess_starts(e2e_daemon: tuple[str, Path]) -> None:
"""The daemon should be reachable via a fresh connection after start_daemon()."""
resp = client.daemon_status()
assert resp.version == __version__
def test_index_and_search_via_client(e2e_daemon: tuple[str, Path]) -> None:
"""Index a project and search via the client, same as ccc index / ccc search."""
_, project_dir = e2e_daemon
resp = client.index(str(project_dir))
assert resp.success
status = client.project_status(str(project_dir))
assert status.total_chunks > 0
assert status.total_files > 0
search_resp = client.search(str(project_dir), query="fibonacci")
assert search_resp.success
assert len(search_resp.results) > 0
assert "main.py" in search_resp.results[0].file_path
# ---------------------------------------------------------------------------
# No-settings mode + host_path_mappings wiring
# ---------------------------------------------------------------------------
def test_daemon_starts_in_no_settings_mode_without_global_settings() -> None:
"""Daemon started against an empty COCOINDEX_CODE_DIR should come up without
creating ``global_settings.yml``. The file stays absent; the handshake reports
``mtime=None``. Project requests are rejected with a clear "run `ccc init`" error.
"""
from cocoindex_code.client import stop_daemon as _stop
from cocoindex_code.protocol import ProjectStatusRequest, encode_request
from cocoindex_code.settings import user_settings_path
base_dir = Path(tempfile.mkdtemp(prefix="ccc_nosettings_"))
old_env = os.environ.get("COCOINDEX_CODE_DIR")
os.environ["COCOINDEX_CODE_DIR"] = str(base_dir)
try:
assert not user_settings_path().is_file()
proc = start_daemon()
sock = daemon_socket_path()
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
log = base_dir / "daemon.log"
raise RuntimeError(
f"Daemon exited early. Log:\n{log.read_text() if log.exists() else '(none)'}"
)
if os.path.exists(sock):
break
time.sleep(0.2)
else:
raise TimeoutError("Daemon did not start in time")
# Daemon is up, but global_settings.yml stays absent — no auto-create.
assert not user_settings_path().is_file()
# Handshake works and reports mtime=None (no settings yet).
# Use the lower-level raw handshake so we can inspect the response
# directly; the high-level client would loop on mtime mismatch.
from cocoindex_code.client import _raw_connect_and_handshake
from cocoindex_code.protocol import decode_response
# _raw_connect_and_handshake does its own handshake read — but it also
# raises DaemonVersionError when the client-side mtime disagrees. With
# the file absent on both sides, mtime=None matches, so handshake OK.
conn = _raw_connect_and_handshake()
try:
# Send a project request — should get an ErrorResponse pointing at
# `ccc init`, not a crash.
conn.send_bytes(encode_request(ProjectStatusRequest(project_root=str(base_dir))))
resp = decode_response(conn.recv_bytes())
finally:
conn.close()
from cocoindex_code.protocol import ErrorResponse
assert isinstance(resp, ErrorResponse)
assert "ccc init" in resp.message
finally:
_stop()
if old_env is None:
os.environ.pop("COCOINDEX_CODE_DIR", None)
else:
os.environ["COCOINDEX_CODE_DIR"] = old_env
def test_daemon_env_response_includes_host_path_mappings(
e2e_daemon: tuple[str, Path],
) -> None:
"""``client.daemon_env`` surfaces the parsed COCOINDEX_CODE_HOST_PATH_MAPPING."""
_, _project_dir = e2e_daemon
# The session daemon was started without COCOINDEX_CODE_HOST_PATH_MAPPING,
# so this just verifies the field is exposed on the wire and defaults to empty.
resp = client.daemon_env()
assert hasattr(resp, "host_path_mappings")
assert resp.host_path_mappings == []