gendesign/backend/tests/test_2950_latest_image_revision_gate.py
bot-backend f2945b7157
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 57s
Deploy / build-backend (push) Successful in 1m2s
Deploy / build-frontend (push) Successful in 1m8s
Deploy / build-worker (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m58s
Deploy / deploy-status (push) Successful in 3s
Deploy Trade-In / build-frontend (push) Successful in 3m14s
Deploy / perimeter-smoke (push) Successful in 14s
Deploy Trade-In / test (push) Successful in 4m12s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 2m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
fix(ci): деплой падает громко, если :latest отстаёт от головы по компоненту (#2950) (#3023)
2026-08-21 12:19:07 +00:00

145 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Гард свежести :latest перед деплоем (#2950) — коды выхода по значению.
Сценарий, ради которого гард существует: Forgejo отменил ещё не стартовавший deploy
run'а A, run B (билды пропущены) катит :latest, а :latest несёт ревизию СТАРШЕ
последнего коммита по компоненту — билд A не доехал. Гард обязан ждать и, не
дождавшись, падать; а при ревизии не старше последнего коммита — пропускать.
`docker` подменяется шимом на PATH: он печатает конфиг образа с меткой ревизии из
переменной окружения FAKE_REV (пусто — метки нет; FAKE_DOCKER_FAIL=1 — registry не
ответил). Временный git-репозиторий даёт настоящие коммиты для `git log`/merge-base.
"""
from __future__ import annotations
import os
import stat
import subprocess
from pathlib import Path
import pytest
КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
_SHIM = """#!/usr/bin/env bash
# подменный docker: только `buildx imagetools inspect <img> --format ...`
if [ "${FAKE_DOCKER_FAIL:-}" = "1" ]; then echo "unauthorized" >&2; exit 1; fi
FMT='{"linux/amd64": {"config": {"Labels": {"org.opencontainers.image.revision": "%s"}}}}'
if [ -n "${FAKE_REV:-}" ]; then
printf "$FMT" "$FAKE_REV"
else
printf '{"linux/amd64": {"config": {"Labels": {}}}}'
fi
"""
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
},
).stdout.strip()
def _commit(repo: Path, rel: str, msg: str) -> str:
p = repo / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(msg, encoding="utf-8")
_git(repo, "add", rel)
_git(repo, "commit", "-q", "-m", msg)
return _git(repo, "rev-parse", "HEAD")
@pytest.fixture()
def repo(tmp_path: Path) -> dict[str, object]:
r = tmp_path / "repo"
r.mkdir()
_git(r, "init", "-q")
c1 = _commit(r, "backend/a.py", "c1 backend")
c2 = _commit(r, "frontend/b.ts", "c2 frontend")
c3 = _commit(r, "backend/c.py", "c3 backend")
c4 = _commit(r, "frontend/d.ts", "c4 frontend")
shim_dir = tmp_path / "bin"
shim_dir.mkdir()
shim = shim_dir / "docker"
shim.write_text(_SHIM, encoding="utf-8")
shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
return {"path": r, "c1": c1, "c2": c2, "c3": c3, "c4": c4, "bin": shim_dir}
def _run(
repo: dict[str, object], rev: str | None, *paths: str, fail: bool = False, timeout: str = "1"
) -> subprocess.CompletedProcess[str]:
env = {
**os.environ,
"PATH": f"{repo['bin']}{os.pathsep}{os.environ['PATH']}",
"CHECK_LATEST_SLEEP_S": "0.2",
"FAKE_REV": rev or "",
"FAKE_DOCKER_FAIL": "1" if fail else "",
}
return subprocess.run(
[str(СКРИПТ), "ghcr.io/x/img", timeout, "--", *paths],
cwd=repo["path"],
env=env,
capture_output=True,
text=True,
)
def test_fresh_latest_passes(repo: dict[str, object]) -> None:
""":latest собран из последнего коммита по backend (c3) → 0."""
r = _run(repo, str(repo["c3"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
assert "содержит последний коммит" in r.stdout
def test_newer_revision_passes(repo: dict[str, object]) -> None:
"""Ревизия новее последнего коммита по компоненту (dispatch собрал голову c4) → 0."""
r = _run(repo, str(repo["c4"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
def test_stale_latest_fails_after_timeout(repo: dict[str, object]) -> None:
"""Головной: :latest из c1, а по backend последний — c3 → ждёт, затем 1 с ::error."""
# timeout=3: дедлайн считается целыми секундами (`date +%s`), и при 1 с первый
# опрос может прийти уже за дедлайном — гард честно падает сразу, а тест ждёт
# строки ожидания. 3 с при паузе 0.2 с дают её гарантированно.
r = _run(repo, str(repo["c1"]), "backend", timeout="3")
assert r.returncode == 1, r.stdout + r.stderr
assert "::error::" in r.stderr and "отстаёт" in r.stderr
assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу"
def test_revision_from_other_component_does_not_cover(repo: dict[str, object]) -> None:
"""Контроль путей: для frontend последний — c4; :latest из c3 (до c4) → 1."""
r = _run(repo, str(repo["c3"]), "frontend")
assert r.returncode == 1, r.stdout + r.stderr
def test_missing_label_fails(repo: dict[str, object]) -> None:
"""Без метки ревизии нельзя утверждать свежесть → 1, а не молчаливый пропуск."""
r = _run(repo, None, "backend")
assert r.returncode == 1, r.stdout + r.stderr
assert "метки нет" in r.stderr
def test_registry_error_fails(repo: dict[str, object]) -> None:
"""Registry не отвечает → 1 (не «проверить нельзя — значит можно»)."""
r = _run(repo, str(repo["c3"]), "backend", fail=True)
assert r.returncode == 1, r.stdout + r.stderr
def test_usage_errors_are_distinct(repo: dict[str, object]) -> None:
"""Ошибка вызова — код 2, чтобы её не приняли за «отстаёт»."""
r = subprocess.run([str(СКРИПТ), "img"], cwd=repo["path"], capture_output=True, text=True)
assert r.returncode == 2