"""Per-run ABRA_DIR isolation (concurrency-restructure plan, cases 17-19). Real directories, real symlinks, real git — abra itself is replaced by a recording stub where a CLI call is involved (case 17), because these cases test OUR dir/env plumbing, not abra.""" from __future__ import annotations import os import stat import subprocess import sys sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) import run_recipe_ci # noqa: E402 from concutil import wait_marker # noqa: E402 from harness import abra # noqa: E402 RECIPE = "fakerecipe" def _git(cwd, *args): subprocess.run( ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], cwd=cwd, check=True, capture_output=True, ) def _make_fake_home(tmp_path): """A fake $HOME with a canonical ~/.abra: servers/default + catalogue dirs, and a recipe git repo with two tags whose data.txt differs (v1 -> 'one', v2 -> 'two', HEAD at v2).""" home = tmp_path / "home" (home / ".abra" / "servers" / "default").mkdir(parents=True) (home / ".abra" / "catalogue").mkdir(parents=True) repo = home / ".abra" / "recipes" / RECIPE repo.mkdir(parents=True) _git(repo, "init", "-q") (repo / "data.txt").write_text("one\n") _git(repo, "add", "data.txt") _git(repo, "commit", "-qm", "v1") _git(repo, "tag", "v1") (repo / "data.txt").write_text("two\n") _git(repo, "add", "data.txt") _git(repo, "commit", "-qm", "v2") _git(repo, "tag", "v2") return home def test_17_per_run_dir_built_and_exported_before_abra(tmp_path, monkeypatch): """Case 17: setup_run_abra_dir builds the per-run dir correctly (servers/catalogue symlinks resolve to the canonical tree, recipes/ empty + writable) and $ABRA_DIR is exported before the first abra call — proven by a stub `abra` on PATH that records the env it saw.""" home = _make_fake_home(tmp_path) monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("CCCI_RUNS_DIR", str(tmp_path / "runs")) monkeypatch.setenv("DRONE_BUILD_NUMBER", "777") monkeypatch.setenv("ABRA_DIR", "sentinel-to-be-overwritten") # so monkeypatch restores it d = run_recipe_ci.setup_run_abra_dir() assert d == str(tmp_path / "runs" / "777" / "abra") assert os.environ["ABRA_DIR"] == d assert os.readlink(os.path.join(d, "servers")) == str(home / ".abra" / "servers") assert os.readlink(os.path.join(d, "catalogue")) == str(home / ".abra" / "catalogue") # symlinks RESOLVE (targets exist) and recipes/ is empty + writable assert os.path.isdir(os.path.join(d, "servers", "default")) assert os.path.isdir(os.path.join(d, "catalogue")) assert os.listdir(os.path.join(d, "recipes")) == [] probe = os.path.join(d, "recipes", ".write-probe") open(probe, "w").close() os.remove(probe) # idempotent re-entry (Drone build-number retry): must not raise on existing symlinks assert run_recipe_ci.setup_run_abra_dir() == d # stub abra records $ABRA_DIR at call time; fetch_recipe's catalogue branch invokes it stub_dir = tmp_path / "bin" stub_dir.mkdir() log = tmp_path / "abra-env.log" stub = stub_dir / "abra" stub.write_text(f'#!/bin/sh\necho "$ABRA_DIR" >> {log}\nexit 0\n') stub.chmod(stub.stat().st_mode | stat.S_IEXEC) monkeypatch.setenv("PATH", f"{stub_dir}{os.pathsep}{os.environ['PATH']}") monkeypatch.delenv("CCCI_SKIP_FETCH", raising=False) run_recipe_ci.fetch_recipe(RECIPE, None, None) assert log.read_text().strip() == d, "abra was called without the per-run ABRA_DIR exported" def test_18_concurrent_same_recipe_fetch_no_cross_talk(tmp_path, monkeypatch, pool): """Case 18: two CONCURRENT fetch+checkout flows of the SAME recipe into different ABRA_DIRs produce two correct, divergent trees (v1 vs v2) — the old shared-tree corruption scenario, now structurally safe with no lock. The canonical staged clone is untouched.""" home = _make_fake_home(tmp_path) canonical_repo = home / ".abra" / "recipes" / RECIPE head_before = subprocess.run( ["git", "-C", canonical_repo, "rev-parse", "HEAD"], capture_output=True, text=True ).stdout.strip() runs = {} for name, ref in (("runA", "v1"), ("runB", "v2")): abra_dir = tmp_path / name / "abra" abra_dir.mkdir(parents=True) _, out = pool.spawn( "fetch-checkout", RECIPE, ref, env_extra={ "HOME": str(home), "ABRA_DIR": str(abra_dir), "CCCI_SKIP_FETCH": "1", }, ) runs[name] = (out, ref, abra_dir) expect = {"v1": "one", "v2": "two"} for name, (out, ref, abra_dir) in runs.items(): line = wait_marker(out, "RESULT", timeout=30) assert line, f"{name} never produced a RESULT" _, head, content = line.split() assert content == expect[ref], f"{name}@{ref}: tree content {content!r}" tree = abra_dir / "recipes" / RECIPE assert (tree / "data.txt").read_text().strip() == expect[ref] assert ( head == subprocess.run( ["git", "-C", tree, "rev-parse", "HEAD"], capture_output=True, text=True ).stdout.strip() ) # the two trees genuinely diverge AND the canonical staged clone is untouched a = (runs["runA"][2] / "recipes" / RECIPE / "data.txt").read_text() b = (runs["runB"][2] / "recipes" / RECIPE / "data.txt").read_text() assert a != b head_after = subprocess.run( ["git", "-C", canonical_repo, "rev-parse", "HEAD"], capture_output=True, text=True ).stdout.strip() assert head_after == head_before, "canonical clone must not be touched by per-run fetches" def test_19_env_written_through_servers_symlink_lands_canonical(tmp_path, monkeypatch): """Case 19: an app .env written through the per-run servers/ symlink (what abra does under $ABRA_DIR) lands in the CANONICAL shared path — so janitor discovery and every expanduser('~/.abra/servers/...') reader keep working unchanged.""" home = _make_fake_home(tmp_path) monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("CCCI_RUNS_DIR", str(tmp_path / "runs")) monkeypatch.setenv("DRONE_BUILD_NUMBER", "778") monkeypatch.setenv("ABRA_DIR", "sentinel-to-be-overwritten") d = run_recipe_ci.setup_run_abra_dir() domain = "test-abc123.ci.commoninternet.net" via_symlink = os.path.join(d, "servers", "default", f"{domain}.env") with open(via_symlink, "w") as f: f.write("TYPE=fakerecipe:1.0.0\nDOMAIN=placeholder\n") canonical = home / ".abra" / "servers" / "default" / f"{domain}.env" assert canonical.is_file(), ".env written via the symlink must land in the canonical path" # the canonical-path readers/writers (abra.env_get/env_set use ~/.abra) see the same file assert abra.env_get(domain, "TYPE") == "fakerecipe:1.0.0" abra.env_set(domain, "DOMAIN", domain) with open(via_symlink) as f: assert f"DOMAIN={domain}" in f.read() def test_18b_run_id_manual_fallback_is_per_process(tmp_path, monkeypatch): """Companion to case 18: two concurrent MANUAL runs (no DRONE_BUILD_NUMBER) must not share an abra dir either — the manual fallback is pid-suffixed.""" home = _make_fake_home(tmp_path) monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("CCCI_RUNS_DIR", str(tmp_path / "runs")) monkeypatch.delenv("DRONE_BUILD_NUMBER", raising=False) monkeypatch.delenv("CCCI_APP_DOMAIN", raising=False) monkeypatch.delenv("CCCI_RUN_ID", raising=False) monkeypatch.setenv("ABRA_DIR", "sentinel-to-be-overwritten") d = run_recipe_ci.setup_run_abra_dir() assert f"manual-{os.getpid()}" in d