|
| 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