"""Unit tests for HQ1 image pre-pull (lifecycle.prepull_images) — deterministic, mocked docker. Proves the pre-pull is non-vacuous (the Adversary's criteria, REVIEW-2 754f508): - present image → SKIP (no `docker pull`, zero network — the warm-cache property). - missing image → `docker pull` it. - a pull FAILURE → RAISE a clear pull error (so a bad tag fails fast PRE-deploy, not as a converge timeout). NOT vacuous. - no images resolved → best-effort skip (deploy pulls as usual), no raise. And that resolution uses the recipe's COMPOSE_FILE via `docker compose config --images` (not grep). """ from __future__ import annotations import os import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import lifecycle as lc # noqa: E402 class _R: def __init__(self, stdout="", stderr="", returncode=0): self.stdout, self.stderr, self.returncode = stdout, stderr, returncode def _patch_paths(monkeypatch): monkeypatch.setattr(os.path, "isdir", lambda p: True) monkeypatch.setattr(os.path, "isfile", lambda p: True) def _runner(monkeypatch, *, images="img-a:1\nimg-b:2\n", present=(), pull_rc=0, pull_err=""): """Install a fake subprocess.run; record calls; return the calls list.""" calls: list[list[str]] = [] def fake_run(args, **kw): calls.append(list(args)) if args[0] == "bash": return _R(stdout="compose.yml") # COMPOSE_FILE eval if "config" in args and "--images" in args: return _R(stdout=images) if args[:3] == ["docker", "image", "inspect"]: return _R(returncode=0 if args[3] in present else 1) if args[:2] == ["docker", "pull"]: return _R(returncode=pull_rc, stderr=pull_err) return _R() monkeypatch.setattr(lc.subprocess, "run", fake_run) return calls def test_prepull_skips_present_images_zero_network(monkeypatch): _patch_paths(monkeypatch) calls = _runner(monkeypatch, present=("img-a:1", "img-b:2")) lc.prepull_images("r", "d") # both present → no pull assert not any(c[:2] == ["docker", "pull"] for c in calls), "must NOT pull a present image" # it DID resolve via `docker compose ... config --images` assert any("config" in c and "--images" in c for c in calls) def test_prepull_pulls_missing_image(monkeypatch): _patch_paths(monkeypatch) calls = _runner(monkeypatch, present=("img-a:1",)) # img-b:2 missing lc.prepull_images("r", "d") pulled = [c[2] for c in calls if c[:2] == ["docker", "pull"]] assert pulled == ["img-b:2"], f"should pull only the missing image; pulled={pulled}" def test_prepull_raises_clear_error_on_pull_failure(monkeypatch): _patch_paths(monkeypatch) _runner(monkeypatch, present=(), pull_rc=1, pull_err="manifest unknown: bad tag") with pytest.raises(RuntimeError, match="clear pull error BEFORE deploy"): lc.prepull_images("r", "d") def test_prepull_skips_when_no_images_resolved(monkeypatch): _patch_paths(monkeypatch) calls = _runner(monkeypatch, images="") # config --images returns nothing lc.prepull_images("r", "d") # no raise assert not any(c[:2] == ["docker", "pull"] for c in calls)