All checks were successful
continuous-integration/drone/push Build is passing
tests/concurrency/ — NOT in the default `pytest tests/unit` gate; run explicitly with `pytest tests/concurrency -q`. flock/prctl/alarm are never mocked: helper subprocesses (helpers.py) hold real locks and install the real lifetime guards; locks live in a per-test tmp dir via CCCI_APP_LOCK_DIR; every helper (and recorded grandchild) is reaped by fixture cleanup. - test_locks.py (cases 1-4): SIGKILL auto-release; LOCK_NB held/unheld semantics; PEP 446 fd-not-inherited (holder's child survives, lock still releases); same-domain second acquire blocks until first holder exits. - test_janitor.py (cases 5-12): orphan reaped once + lockfile unlinked; live holder never reaped + logged; new-run acquire blocks until a slow reap completes (reap-under-probe-lock); two overlapping janitors -> exactly one reaps (flock arbitration); reboot sim (no lockfile) reaps immediately with no age wait; >120min-held lock flagged 'possible leaked run' and NOT stolen; warm/canonical names never probed (no lockfile even created); directory-as-lockfile and missing lock dir degrade to skip+log, never crash. - test_lifetime.py (cases 13-16): PDEATHSIG (wrapper parent SIGKILL'd -> guarded child TERM'd, teardown marker, lock released); already-orphaned helper REFUSES to run (ppid race); 2s deadline alarm -> teardown + exit 142 + lock released; SIGTERM -> teardown + exit 143 + lock released. - test_abra_dir.py (cases 17-19 + 18b): per-run dir built + $ABRA_DIR exported before the first abra call (recording stub abra on PATH); two CONCURRENT same-recipe fetch+checkout flows into different ABRA_DIRs -> divergent correct trees, canonical staged clone untouched; .env written through the servers/ symlink lands in the canonical path (env_get/env_set agree); manual runs get pid-suffixed dirs. On cc-ci: pytest tests/concurrency -q -> 20 passed; tests/unit -> 138 passed; lint PASS.
176 lines
7.7 KiB
Python
176 lines
7.7 KiB
Python
"""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
|