Skip to content

Commit 39d05b4

Browse files
committed
test: add corrupt-cache end-to-end reproduction
The unit + component tests exercise the cache-side heal, lockfile-side heal, and graph-integrity invariants in isolation with hand-crafted fixtures. They cannot reach the silent-vanishing path that conda#896 actually reports, because that path is only reachable through the full pipeline: ``LINK``-only dryrun -> ``reconstruct_fetch_actions`` -> ``get_repodata_record`` -> cache-side heal -> ``apply_categories`` forward walk -> ``assert_no_orphaned_conda_packages`` -> v1 serialization. Add ``tests/e2e/test_corrupt_repodata_repro.py`` to drive that whole pipeline against the corrupt-metadata fixtures committed in ``tests/test-corrupt-repodata/``. The test parametrizes over ``mamba`` and ``conda-standalone`` (both solvers were affected by conda#896 because both read leaked ``pkgs_dirs``) and asserts: 1. *Structural lockfile equivalence*: parsing both unified lockfiles via ``parse_conda_lock_file`` produces the same set of ``(name, version, category)`` tuples. Catches silent drops and any "rescue into main" miscategorization. 2. *Explicit-URL equivalence*: ``conda-lock render --kind=explicit`` against both unified lockfiles produces identical URL sets. PR conda#862 stage 05's whole point is that "packages are missing from the resulting environment if and only if they are missing from the explicit lockfile," so this is the user-visible bug surface. 3. *Empty-deps regression*: corrupt-cache run does not produce significantly more empty-``dependencies`` entries than the clean-cache run, proving the heal pipeline recovered them. The supporting fixture factory and harness wrappers live in ``tests.support.corrupt_repodata`` (added with the fixture data), so the test body stays focused on the assertions. The test is marked ``corrupt_cache_repro`` for CI isolation in the next commit; locally it runs in ~2-5 minutes per parametrization because it warms a real conda-forge cache and drives full ``conda-lock lock`` and ``conda-lock render`` invocations.
1 parent 1ec7f0e commit 39d05b4

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

tests/e2e/__init__.py

Whitespace-only changes.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""End-to-end reproduction of conda/conda-lock#896 from PR #862.
2+
3+
The harness committed in ``tests/test-corrupt-repodata/`` captures
4+
the *exact* failure: it pins the metadata that micromamba 2.1.1
5+
wrote to the cache when installing from an explicit lockfile, plus
6+
the 2.1.0 baseline for comparison. Driving conda-lock against that
7+
corrupt metadata is the only way to exercise the full chain
8+
(LINK-only dryrun -> ``reconstruct_fetch_actions`` ->
9+
``get_repodata_record`` -> cache-side heal ->
10+
``apply_categories`` forward walk -> ``assert_no_orphaned_conda_packages``
11+
-> v1 serialization). A unit-style test that hand-crafts a corrupt
12+
``LockedDependency`` cannot reach the silent-vanishing path because
13+
it bypasses ``apply_categories`` and ``to_v1()`` entirely.
14+
15+
The supporting fixtures (cache warming, corrupt metadata overlay,
16+
``conda-lock lock`` driver, explicit-render diff) live in
17+
``tests.support.corrupt_repodata`` so future component-level tests
18+
can reuse them.
19+
"""
20+
21+
# mypy: disable-error-code="arg-type,comparison-overlap"
22+
23+
from __future__ import annotations
24+
25+
import os
26+
import subprocess
27+
import sys
28+
29+
from pathlib import Path
30+
31+
import pytest
32+
33+
from conda_lock.invoke_conda import _ensureconda
34+
from tests.support.corrupt_repodata import (
35+
REPRO_DIR,
36+
conda_lock_lock_against,
37+
conda_lock_render_explicit,
38+
explicit_lockfile_urls,
39+
overlay_corrupt_metadata,
40+
parse_lockfile_packages,
41+
)
42+
43+
44+
@pytest.fixture(scope="module")
45+
def reproduces_corrupt_2_1_1_cache(tmp_path_factory):
46+
"""Reconstruct stage 04 of PR #862 *without Docker*:
47+
48+
- warm a fresh package cache by ``micromamba create`` from the
49+
committed ``01-explicit.lock`` (so all package tarballs land in
50+
the cache with whatever metadata the *current* solver writes,
51+
i.e. correct on mamba 2.6.0+);
52+
- extract the committed ``2.1.1-pkgs.tar.gz`` (only metadata --
53+
info/index.json + info/repodata_record.json, the latter exactly as
54+
mamba 2.1.1 wrote it);
55+
- copy the corrupt metadata over the warmed cache.
56+
57+
The result is a "hybrid" cache: real package files + bug-faithful
58+
corrupt metadata. Reused across tests in this module via module
59+
scope -- it takes ~30 seconds to warm.
60+
"""
61+
micromamba = _ensureconda(
62+
mamba=False, micromamba=True, conda=False, conda_exe=False
63+
)
64+
if micromamba is None:
65+
pytest.skip("micromamba not installed -- needed to warm cache")
66+
explicit_lock = REPRO_DIR / "01-explicit.lock"
67+
corrupt_archive = REPRO_DIR / "2.1.1-pkgs.tar.gz"
68+
if not explicit_lock.is_file() or not corrupt_archive.is_file():
69+
pytest.skip("PR #862 fixtures not present in tests/test-corrupt-repodata/")
70+
71+
work = tmp_path_factory.mktemp("repro-2.1.1")
72+
cache = work / "cache"
73+
prefix = work / "prefix"
74+
cache.mkdir()
75+
proc = subprocess.run(
76+
[
77+
str(micromamba),
78+
"create",
79+
"--prefix",
80+
str(prefix),
81+
"--yes",
82+
"--override-channels",
83+
"--channel",
84+
"conda-forge",
85+
"--file",
86+
str(explicit_lock),
87+
],
88+
env={
89+
**os.environ,
90+
"CONDA_PKGS_DIRS": str(cache),
91+
"CONDA_SUBDIR": "linux-64",
92+
},
93+
capture_output=True,
94+
text=True,
95+
timeout=600,
96+
)
97+
if proc.returncode != 0:
98+
pytest.skip(f"could not warm cache via {micromamba}: {proc.stderr[-2000:]}")
99+
100+
extracted = work / "2.1.1-pkgs"
101+
subprocess.run(
102+
["tar", "-xzf", str(corrupt_archive), "-C", str(work)],
103+
check=True,
104+
)
105+
overlaid = overlay_corrupt_metadata(extracted, cache)
106+
if overlaid == 0:
107+
pytest.skip(
108+
"no packages from 2.1.1-pkgs found in warmed cache; "
109+
"fixture and explicit lock have drifted apart"
110+
)
111+
112+
return cache
113+
114+
115+
@pytest.fixture(scope="module")
116+
def conda_solver_path():
117+
"""Locate ``conda-standalone`` (a single-file conda binary -- the
118+
one ``conda-lock --conda=...`` accepts when you don't have full
119+
conda installed). Stage 04 of PR #862 explicitly tests this solver
120+
in addition to mamba; conda-standalone reads the same leaked
121+
``pkgs_dirs`` and is therefore subject to the same #896 chain."""
122+
candidates = [
123+
Path(os.environ.get("CONDA", "") or "/opt/conda/standalone_conda/conda.exe"),
124+
Path(sys.prefix) / "standalone_conda" / "conda.exe",
125+
Path(sys.prefix) / "bin" / "conda",
126+
]
127+
if "MAMBA_ROOT_PREFIX" in os.environ:
128+
candidates.append(
129+
Path(os.environ["MAMBA_ROOT_PREFIX"])
130+
/ "envs"
131+
/ "conda-lock-dev"
132+
/ "standalone_conda"
133+
/ "conda.exe"
134+
)
135+
for c in candidates:
136+
if c.is_file() and os.access(c, os.X_OK):
137+
return c
138+
pytest.skip(
139+
"conda-standalone not installed; install via "
140+
"`micromamba install -c conda-forge conda-standalone`"
141+
)
142+
143+
144+
@pytest.fixture(scope="module")
145+
def mamba_solver_path():
146+
"""Locate ``mamba`` for the e2e -- separate fixture so the test can
147+
parametrize over solver choice and skip cleanly when one is
148+
absent."""
149+
mamba = _ensureconda(mamba=True, micromamba=False, conda=False, conda_exe=False)
150+
if mamba is None:
151+
pytest.skip("mamba not installed -- needed to drive conda-lock end-to-end")
152+
return mamba
153+
154+
155+
@pytest.mark.corrupt_cache_repro
156+
@pytest.mark.timeout(900)
157+
@pytest.mark.parametrize("solver", ["mamba", "conda-standalone"])
158+
def test_pr862_corrupt_2_1_1_cache_does_not_drop_packages(
159+
reproduces_corrupt_2_1_1_cache,
160+
request,
161+
tmp_path,
162+
solver,
163+
):
164+
"""PR #862 stage 04 + 05 against the **2.1.1 corrupt cache**.
165+
166+
Drives the *full* committed reproduction pipeline minus the Docker
167+
wrapper:
168+
169+
- Stage 04: ``conda-lock lock --conda=<solver>`` against the
170+
hybrid (real-files + corrupt-metadata) cache.
171+
- Stage 05: ``conda-lock render --kind=explicit`` to the explicit
172+
lockfile that ``conda-lock install`` consumes verbatim.
173+
174+
Both stages run in two parallel scenarios -- against the corrupt
175+
cache and against a fresh clean cache -- and the test asserts:
176+
177+
1. *Structural lockfile equivalence:* parsing both unified lockfiles
178+
via ``parse_conda_lock_file`` produces the same set of
179+
``(name, version, category)`` tuples. This catches both silent
180+
drops and any incorrect rescue-into-main implementation (which
181+
would manifest as the same name appearing in different
182+
categories between corrupt and clean runs).
183+
2. *Explicit-URL equivalence:* the rendered explicit lockfiles --
184+
what ``conda-lock install`` actually consumes -- have identical
185+
URL sets. PR #862 stage 05's whole point is that "packages are
186+
missing from the resulting environment if and only if they are
187+
missing from the explicit lockfile", so this is the user-visible
188+
surface.
189+
190+
Parametrized over both ``mamba`` and ``conda-standalone`` because
191+
stage 04 itself runs both: different conda-flavoured binaries
192+
handle corrupt metadata and leaked ``pkgs_dirs`` slightly
193+
differently, and the bug surfaces in both.
194+
"""
195+
if solver == "mamba":
196+
conda_exe = request.getfixturevalue("mamba_solver_path")
197+
elif solver == "conda-standalone":
198+
conda_exe = request.getfixturevalue("conda_solver_path")
199+
else:
200+
pytest.fail(f"unknown solver: {solver}")
201+
202+
dev_env = REPRO_DIR.parent.parent / "environments/dev-environment.yaml"
203+
if not dev_env.is_file():
204+
pytest.skip("dev-environment.yaml not present at repo root")
205+
206+
work = tmp_path
207+
out_corrupt = work / "unified-corrupt.yml"
208+
out_clean = work / "unified-clean.yml"
209+
210+
# Stage 04: lock against the corrupt cache.
211+
proc = conda_lock_lock_against(
212+
reproduces_corrupt_2_1_1_cache,
213+
conda_exe=str(conda_exe),
214+
source=dev_env,
215+
out_lockfile=out_corrupt,
216+
)
217+
assert proc.returncode == 0, (
218+
f"conda-lock against corrupt cache (solver={solver}) failed:\n"
219+
f"STDOUT:\n{proc.stdout[-2000:]}\n"
220+
f"STDERR:\n{proc.stderr[-2000:]}"
221+
)
222+
223+
# Live baseline: lock against an empty cache so package versions
224+
# match. The committed 2.1.0 fixture is months old and would drift
225+
# against current conda-forge state.
226+
clean_cache = work / "clean-cache"
227+
clean_cache.mkdir()
228+
proc_clean = conda_lock_lock_against(
229+
clean_cache,
230+
conda_exe=str(conda_exe),
231+
source=dev_env,
232+
out_lockfile=out_clean,
233+
)
234+
assert proc_clean.returncode == 0, (
235+
f"conda-lock against clean cache (solver={solver}) failed:\n"
236+
f"STDOUT:\n{proc_clean.stdout[-2000:]}\n"
237+
f"STDERR:\n{proc_clean.stderr[-2000:]}"
238+
)
239+
240+
# 1. Structural equivalence via the production parser.
241+
corrupt_pkgs = parse_lockfile_packages(out_corrupt)
242+
clean_pkgs = parse_lockfile_packages(out_clean)
243+
244+
def _key(p):
245+
# ``LockedDependency`` (v2 model): includes categories so a
246+
# category-mutation bug shows up here even if names match.
247+
return (p.manager, p.name, p.version, frozenset(p.categories))
248+
249+
corrupt_keys = {_key(p) for p in corrupt_pkgs}
250+
clean_keys = {_key(p) for p in clean_pkgs}
251+
252+
# The committed corrupt-reference lockfile from PR #862 (unfixed)
253+
# has 110 packages -- a useful sanity floor.
254+
pr862_corrupt_ref = REPRO_DIR / f"lockfile-2.1.1-pkgs-lock-with-{solver}.yml"
255+
pr862_corrupt_ref_count = (
256+
len(parse_lockfile_packages(pr862_corrupt_ref))
257+
if pr862_corrupt_ref.is_file()
258+
else 0
259+
)
260+
261+
assert corrupt_keys == clean_keys, (
262+
"structural mismatch between corrupt-cache and clean-cache "
263+
"lockfiles -- the cache-corruption mitigations did not produce "
264+
"an equivalent lockfile.\n"
265+
f" solver: {solver}\n"
266+
f" corrupt-cache packages: {len(corrupt_pkgs)}\n"
267+
f" clean-cache packages: {len(clean_pkgs)}\n"
268+
f" PR #862 unfixed ref: {pr862_corrupt_ref_count}\n"
269+
f" only in clean: {sorted(clean_keys - corrupt_keys)[:8]} ...\n"
270+
f" only in corrupt: {sorted(corrupt_keys - clean_keys)[:8]} ..."
271+
)
272+
273+
# 2. Stage 05: render explicit lockfiles and compare URL sets.
274+
explicit_corrupt = conda_lock_render_explicit(
275+
out_corrupt, work / "explicit-from-corrupt"
276+
)
277+
explicit_clean = conda_lock_render_explicit(out_clean, work / "explicit-from-clean")
278+
279+
corrupt_urls = explicit_lockfile_urls(explicit_corrupt)
280+
clean_urls = explicit_lockfile_urls(explicit_clean)
281+
assert corrupt_urls == clean_urls, (
282+
"explicit-render URL set differs between corrupt and clean cache "
283+
"-- this is the exact surface a `conda-lock install` consumes, "
284+
"so any difference here is a user-visible installation drift.\n"
285+
f" solver: {solver}\n"
286+
f" only in clean: {sorted(clean_urls - corrupt_urls)[:8]}\n"
287+
f" only in corrupt: {sorted(corrupt_urls - clean_urls)[:8]}"
288+
)
289+
290+
# 3. Empty-deps sanity. Without the fix, the committed reference
291+
# has ~78 entries with empty dependencies for the mamba run; with
292+
# the fix we should be in the same ballpark as the clean control.
293+
corrupt_empty = sum(1 for p in corrupt_pkgs if not p.dependencies)
294+
clean_empty = sum(1 for p in clean_pkgs if not p.dependencies)
295+
assert corrupt_empty <= clean_empty + 2, (
296+
f"corrupt-cache lockfile has {corrupt_empty} entries with empty "
297+
f"dependencies, well above the legitimate baseline of "
298+
f"{clean_empty} -- the healing pipeline did not recover all "
299+
f"corrupt depends. (solver={solver})"
300+
)

0 commit comments

Comments
 (0)