lifecycle.prepull_images(recipe, domain): resolve images via docker compose config --images (COMPOSE_FILE from the app .env — handles $VERSION interpolation + multi-compose) → docker pull each, skip-if-present (zero network for cached pinned tags). Called in deploy_app before the (unchanged, real) abra.deploy AND in generic.perform_upgrade before the chaos redeploy (warms new-version images). A pull failure RAISES a clear pre-deploy error (not a converge timeout); deploy path unchanged (no docker service update/scale). Removes PULL time not app-INIT time. 4 unit tests (tests/unit/test_prepull.py): present→skip, missing→ pull, pull-fail→raise, no-images→skip. NOT claimed yet — validating cold-verify criteria next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
3.2 KiB
Python
82 lines
3.2 KiB
Python
"""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)
|