All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m13s
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 17m19s
20.08 деплой ПТИЦЫ (run 8083) упал за 5 секунд:
unable to lease content: lease does not exist: not found
Все 7 образов ушли в Interrupted через 0.8с после старта пула. Причина — не
в ПТИЦЕ: через 0.4с после обрыва соседний деплой Trade-In напечатал
«Deleted Images:» своего `docker image prune -af`.
Оба деплоя ходят по SSH в ОДИН докер-демон: стеки gendesign-* и tradein-*
плюс сам forgejo-runner живут на одной VM (проверено `docker ps` на хосте).
Группы были разные — deploy-prod и deploy-tradein-prod — поэтому Forgejo
запускал их параллельно. `cancel-in-progress: false` не спасал: он про раны
ВНУТРИ группы, а гонка была МЕЖДУ группами.
Последствие было тихим: голова main показывала success, но зелёным был
Trade-In'овый деплой этой головы, а деплой ПТИЦЫ висел на своём, более раннем
коммите с исходом failure. Прод остался на старом коде — #2946 и #2947 не
доехали и были выкачены отдельным workflow_dispatch.
Почему общая группа, а не flock на хосте. Host-lock сериализовал бы только
докер-секцию, не заставляя деплои ждать чужих билдов (~6 мин). Но у него своя
отказная мода: дочерний процесс наследует fd лока, и при аварийной смерти
job'а лок залипает. Проверено на самой VM: после kill -9 держателя следующий
претендент лок НЕ получил. Залипший лок блокирует прод-деплои на весь таймаут.
Планировщик Forgejo сериализует без лок-файла, залипать там нечему —
платим ожиданием, покупаем отсутствие целого класса отказов.
Гейт backend/tests/ops/test_2950_deploy_concurrency_group.py фиксирует
инвариант. Мутационно проверен: разводка групп обратно, cancel-in-progress:
true и удаление секции concurrency краснят его (1, 1 и 3 упавших теста),
контроль зелёный.
В фильтр changes добавлены сами deploy-workflow'ы — иначе правка, разводящая
группы, не трогает 'backend/**', backend-tests пропускается, и гейт не
исполняется ровно на той правке, от которой стережёт. Тот же класс, что #2587.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62 lines
3.5 KiB
Python
62 lines
3.5 KiB
Python
"""Гейт: оба прод-деплоя обязаны лежать в ОДНОЙ группе concurrency.
|
||
|
||
Почему это тест, а не комментарий в yml (#2950). Деплой ПТИЦЫ и деплой Trade-In
|
||
ходят по SSH в один и тот же докер-демон — стеки gendesign-* и tradein-* плюс сам
|
||
forgejo-runner живут на одной VM. Каждый в конце делает `docker image prune -af`.
|
||
Пока группы разные, Forgejo запускает их параллельно, и прун одного сносит leases
|
||
ещё не доехавшего `compose pull` другого:
|
||
|
||
unable to lease content: lease does not exist: not found
|
||
|
||
20.08 так и вышло: run 8083 упал за 5 секунд, прод остался на старом коде. Разводка
|
||
групп обратно ничего не сломает в CI и не даст ни одного сигнала — отказ проявится
|
||
только в следующем совпадении окон двух мержей. Поэтому инвариант зафиксирован здесь.
|
||
|
||
`cancel-in-progress: false` частью инварианта тоже является: прод-деплои обязаны
|
||
достаиваться в очереди, а не отменять друг друга — отменённый деплой оставляет прод
|
||
на старом коде ровно так же, как упавший.
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
# backend/tests/ops/<этот файл> → корень репозитория
|
||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
||
|
||
PROD_DEPLOYS = ("deploy.yml", "deploy-tradein.yml")
|
||
|
||
|
||
def _concurrency(name: str) -> dict:
|
||
path = WORKFLOWS / name
|
||
assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп"
|
||
return yaml.safe_load(path.read_text()).get("concurrency") or {}
|
||
|
||
|
||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||
def test_prod_deploy_declares_concurrency(name: str) -> None:
|
||
"""У каждого прод-деплоя concurrency вообще объявлена."""
|
||
assert _concurrency(name).get(
|
||
"group"
|
||
), f"{name}: пропала секция concurrency — деплои снова могут пойти параллельно"
|
||
|
||
|
||
def test_both_prod_deploys_share_one_group() -> None:
|
||
"""Группа у обоих одна и та же — иначе прун одного убьёт pull другого."""
|
||
groups = {name: _concurrency(name).get("group") for name in PROD_DEPLOYS}
|
||
assert len(set(groups.values())) == 1, (
|
||
f"прод-деплои разведены по разным группам concurrency: {groups}. "
|
||
"Они ходят в ОДИН докер-демон; `docker image prune -af` одного обрывает "
|
||
"`compose pull` другого (#2950)."
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||
def test_prod_deploy_queues_instead_of_cancelling(name: str) -> None:
|
||
"""Раны достаиваются в очереди: отменённый деплой = прод на старом коде."""
|
||
assert _concurrency(name).get("cancel-in-progress") is False, (
|
||
f"{name}: cancel-in-progress должен быть false — иначе более новый мерж "
|
||
"отменит выкатку предыдущего и та молча не доедет"
|
||
)
|