mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 11:22:21 +02:00
* fix(docker): mount the ChromaDB volume where the image actually persists
chromadb/chroma runs `chroma run /config.yaml`, and that config is one line:
persist_path: "/data". The image declares no VOLUME, so everything the store
writes lives in the container's writable layer. The chromadb-data volume was
mounted at /chroma/chroma — where pre-1.0 images kept the store — so it caught
nothing and the entire vector store was destroyed on every container recreate.
Nothing errors when that happens. The collection survives with a count of 0, so
retrieval simply returns nothing from then on, which reads as "no match".
docs/backup-restore.md tells users to archive the chromadb-data volume, which
means the documented backup was empty too.
Fixed in all three compose files. The volume was never written to, so there is
nothing to migrate.
* fix(tests,docs): harden volume-path test helper and document upgrade migration
tests/test_chromadb_volume_path.py
- _chromadb_volumes() now normalises long-form dict volume entries
(source/target/type map) to 'source:target' strings, preventing
AttributeError crashes if a future Compose change uses that syntax.
- test_chromadb_does_not_use_the_pre_1_0_path now uses split(':')[1]
comparison instead of endswith(), so mount-option suffixes (:z, :ro)
cannot mask a wrong legacy target path.
- Three new unit tests validate the helper against both YAML syntaxes
and the mount-option edge case explicitly.
docs/chromadb-volume-migration.md (new)
- Step-by-step guide for users upgrading from pre-#6166 Docker installs
whose ChromaDB data accumulated in the container's writable layer.
- Covers: detecting the situation, backing up via 'docker cp', seeding
the named volume before recreating the container, verification, and
rollback. Validated with disposable Docker volumes.
- Distinguishes fresh installs (no action needed) from existing
installs with live writable-layer data (action needed before recreate).
docs/backup-restore.md
- Extended ChromaDB caveat block to cross-reference the new migration
guide for users upgrading from pre-#6166.
Closes the P1 documentation gap and P2/P3 test-robustness findings
from the PR #6166 adversarial review.
* fix(docs,tests): eliminate ChromaDB migration data-loss risks and enforce safe cutover
- Require service quiescence before cold database copy to prevent torn SQLite pages
- Mandate container preservation via immutable Docker image commit before recreation
- Enforce positive empty check on destination volume prior to seeding
- Add staging container validation on an isolated loopback port before Compose cutover
- Provide verified rollback procedures prohibiting return to legacy /chroma/chroma mount
- Add regression test coverage for all migration safety invariants
---------
Co-authored-by: PR #6166 Reviewer <reviewer@odysseus-review.local>
221 lines
9.1 KiB
Python
221 lines
9.1 KiB
Python
"""Regression guard for issue #6132 — the ChromaDB named volume was mounted at a
|
|
path the image never writes to, so the whole vector store lived in the
|
|
container's writable layer and was destroyed on every recreate. Silently: the
|
|
collection survived with a count of 0, and retrieval just stopped returning
|
|
anything.
|
|
|
|
``chromadb/chroma`` runs ``chroma run /config.yaml``, and that config is a single
|
|
line — ``persist_path: "/data"``. The image declares no ``VOLUME``, so nothing
|
|
outside a mount survives. ``/chroma/chroma`` is where pre-1.0 images kept the
|
|
store.
|
|
|
|
The documented backup procedure (website/backup-restore.md) archives the
|
|
``chromadb-data`` volume, so a wrong mount point also makes that tarball empty.
|
|
|
|
Pure YAML — no Docker required.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
COMPOSE_FILES = [
|
|
ROOT / "docker-compose.yml",
|
|
ROOT / "docker-compose.gpu-nvidia.yml",
|
|
ROOT / "docker-compose.gpu-amd.yml",
|
|
]
|
|
|
|
# `persist_path` from the image's own /config.yaml.
|
|
CHROMA_PERSIST_PATH = "/data"
|
|
LEGACY_PERSIST_PATH = "/chroma/chroma"
|
|
VOLUME_NAME = "chromadb-data"
|
|
|
|
|
|
def _chromadb_volumes(path: Path) -> list[str]:
|
|
"""Return a normalised list of ``source:target`` strings for the chromadb service.
|
|
|
|
Docker Compose accepts volumes in two syntaxes:
|
|
|
|
* Short-form string: ``"chromadb-data:/data"`` or ``"chromadb-data:/data:ro"``
|
|
* Long-form mapping::
|
|
|
|
- source: chromadb-data
|
|
target: /data
|
|
type: volume
|
|
|
|
Both forms are normalised to ``"<source>:<target>"`` so that callers can
|
|
use ``split(":")`` to extract either part without worrying about the original
|
|
syntax or trailing mount-option fields (e.g. ``:z``, ``:ro``).
|
|
"""
|
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
service = compose["services"]["chromadb"]
|
|
raw = list(service.get("volumes") or [])
|
|
result: list[str] = []
|
|
for entry in raw:
|
|
if isinstance(entry, dict):
|
|
# Long-form: normalise to "source:target" for uniform handling.
|
|
source = entry.get("source", "")
|
|
target = entry.get("target", "")
|
|
result.append(f"{source}:{target}")
|
|
else:
|
|
# Short-form string: keep as-is; split(":")[1] is still the target
|
|
# even when a third field (mount options like :z, :ro) is present.
|
|
result.append(str(entry))
|
|
return result
|
|
|
|
|
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
|
def test_chromadb_volume_is_mounted_at_the_persist_path(path: Path):
|
|
mounts = _chromadb_volumes(path)
|
|
targets = {m.split(":")[1] for m in mounts if m.startswith(f"{VOLUME_NAME}:")}
|
|
assert targets, f"{path.name}: no {VOLUME_NAME} mount on the chromadb service"
|
|
assert targets == {CHROMA_PERSIST_PATH}, (
|
|
f"{path.name}: {VOLUME_NAME} is mounted at {sorted(targets)}, but the image "
|
|
f"persists to {CHROMA_PERSIST_PATH!r}. Anything written outside a mount is "
|
|
"lost when the container is recreated (issue #6132)."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
|
def test_chromadb_does_not_use_the_pre_1_0_path(path: Path):
|
|
"""Verify that no mount places chromadb-data at the pre-1.0 store path.
|
|
|
|
Uses ``split(":")[1]`` rather than ``endswith()`` so that mount-option
|
|
suffixes (e.g. ``:z``, ``:ro``) do not mask a wrong target path.
|
|
"""
|
|
mounts = _chromadb_volumes(path)
|
|
legacy = [m for m in mounts if ":" in m and m.split(":")[1] == LEGACY_PERSIST_PATH]
|
|
assert not legacy, (
|
|
f"{path.name}: {LEGACY_PERSIST_PATH} is the pre-1.0 store location; "
|
|
f"current images never write there. Offending entries: {legacy}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
|
def test_chromadb_data_volume_is_declared(path: Path):
|
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
assert VOLUME_NAME in (compose.get("volumes") or {}), (
|
|
f"{path.name}: the {VOLUME_NAME} volume is mounted but not declared"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests for _chromadb_volumes() — parse-correctness, not Compose state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakePath:
|
|
"""Minimal Path stand-in that returns a fixed YAML string."""
|
|
|
|
def __init__(self, yaml_text: str):
|
|
self._text = yaml_text
|
|
|
|
def read_text(self, encoding: str = "utf-8") -> str: # noqa: ARG002
|
|
return self._text
|
|
|
|
|
|
def test_helper_normalises_long_form_volume_dict():
|
|
"""Long-form dict volume entries must not raise AttributeError."""
|
|
p = _FakePath(yaml.dump({
|
|
"services": {"chromadb": {"volumes": [
|
|
{"source": "chromadb-data", "target": "/data", "type": "volume"},
|
|
]}},
|
|
"volumes": {"chromadb-data": None},
|
|
}))
|
|
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
|
assert result == ["chromadb-data:/data"]
|
|
|
|
|
|
def test_helper_passes_short_form_string_unchanged():
|
|
"""Short-form strings (with or without mount options) are returned as-is."""
|
|
p = _FakePath(yaml.dump({
|
|
"services": {"chromadb": {"volumes": ["chromadb-data:/data:z"]}},
|
|
"volumes": {"chromadb-data": None},
|
|
}))
|
|
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
|
assert result == ["chromadb-data:/data:z"]
|
|
# split(":")[1] still extracts the correct target regardless of :z suffix
|
|
assert result[0].split(":")[1] == "/data"
|
|
|
|
|
|
def test_helper_legacy_check_catches_path_with_mount_option_suffix():
|
|
"""The split-based legacy check catches the old path even with :z appended."""
|
|
mount = f"{VOLUME_NAME}:{LEGACY_PERSIST_PATH}:z"
|
|
# Confirm endswith() would miss it (the bug we fixed)
|
|
assert not mount.endswith(f":{LEGACY_PERSIST_PATH}"), (
|
|
"endswith() should NOT catch ':z'-suffixed legacy path — that was the bug"
|
|
)
|
|
# Confirm split()[1] does catch it
|
|
assert mount.split(":")[1] == LEGACY_PERSIST_PATH, (
|
|
"split(':')[1] must still identify the legacy target path with :z suffix"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression tests for ChromaDB migration safety invariants (PR #6166 audit)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MIGRATION_DOC = ROOT / "docs" / "chromadb-volume-migration.md"
|
|
|
|
|
|
def test_migration_guide_requires_quiescence_before_backup():
|
|
"""Step 2 must stop ChromaDB before running docker cp to prevent torn SQLite pages."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "docker compose stop chromadb" in text, (
|
|
"Migration guide must require stopping ChromaDB before database backup"
|
|
)
|
|
# Confirm stopping step occurs prior to the cold backup step
|
|
stop_step = text.find("Step 2: Quiesce Writers and Gracefully Stop ChromaDB")
|
|
backup_step = text.find("Step 4: Take a Cold File Backup")
|
|
assert stop_step != -1 and backup_step != -1 and stop_step < backup_step, (
|
|
"Quiescence/stop step must precede cold backup step in migration sequence"
|
|
)
|
|
|
|
|
|
def test_migration_guide_mandates_container_preservation_via_commit():
|
|
"""Guide must preserve stopped container as an immutable image before any recreation."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "docker commit" in text, (
|
|
"Migration guide must instruct operators to commit the container to an immutable backup image"
|
|
)
|
|
assert "com.docker.compose.project" in text, (
|
|
"Guide must explain that Compose discovers containers by internal labels, not name alone"
|
|
)
|
|
|
|
|
|
def test_migration_guide_requires_sqlite_and_hnsw_integrity_verification():
|
|
"""Guide must mandate SQLite PRAGMA integrity_check and checking HNSW index files."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "PRAGMA integrity_check;" in text, (
|
|
"Migration guide must require SQLite PRAGMA integrity_check"
|
|
)
|
|
assert "data_level0.bin" in text or "*.bin" in text, (
|
|
"Migration guide must require verifying HNSW segment index binary files"
|
|
)
|
|
|
|
|
|
def test_migration_guide_requires_empty_volume_precheck():
|
|
"""Guide must verify destination volume is strictly empty prior to seeding."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "ls -A" in text or "empty and ready for seeding" in text, (
|
|
"Migration guide must check that the destination named volume is empty before seeding"
|
|
)
|
|
|
|
|
|
def test_migration_guide_includes_staging_validation():
|
|
"""Guide must validate the seeded volume with a staging container before Compose cutover."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "chromadb-staging" in text, (
|
|
"Migration guide must include staging container validation step"
|
|
)
|
|
|
|
|
|
def test_migration_guide_warns_against_reverting_to_broken_mount():
|
|
"""Rollback instructions must explicitly prohibit git checkout to pre-#6166 broken mount."""
|
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
|
assert "Do not `git checkout` the pre-#6166 Compose file" in text or "broken" in text.lower(), (
|
|
"Rollback instructions must explain why checking out old Compose file breaks volume mount"
|
|
)
|