All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
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) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 17m17s
Поправка к #2952. Там я свёл обе группы concurrency к одной и написал, что это
сериализует деплои. Проверил сразу после мержа — не сериализует.
Коммит 2a01dea1 трогает оба деплой-workflow, поэтому запустил обе цепочки. При
работающей общей группе вторая не стартовала бы вовсе. На раннере в этот момент:
TASK-21431_WORKFLOW-Deploy-Trade-In_JOB-build-frontend
TASK-21430_WORKFLOW-Deploy-Trade-In_JOB-test
TASK-21428_WORKFLOW-Deploy_JOB-build-frontend
TASK-21427_WORKFLOW-Deploy_JOB-build-worker
TASK-21426_WORKFLOW-Deploy_JOB-build-backend
Обе идут бок о бок. Forgejo 10.0.3 (gitea-1.22), runner v6.3.1 — workflow-level
concurrency здесь не исполняется. Значит и прежние deploy-prod /
deploy-tradein-prod никогда ничего не делали: причиной гонки было не различие
имён групп, а отсутствие взаимного исключения как такового.
Отдельно — flock я в #2952 отверг по неверному основанию. Я написал, что при
аварийной смерти job'а лок залипает. Перепроверил на настоящем сценарии (обрыв
ssh-сессии, а не kill -9 родителя в отрыве): лок держит живой потомок скрипта —
/run/lock/gendesign-docker-deploy.lock:
gendesign 1968950 F.... bash
gendesign 1968953 F.... sleep
но ведь и докер-команды после обрыва сессии продолжают работать на хосте, так
что отпускать лок в этот момент как раз НЕЛЬЗЯ. Это не дефект, а нужная
семантика: исключение действует ровно пока жив тот, кто мутирует докер.
Правка: обе докер-секции берут общий лок на хосте перед работой. Ожидание
ограничено 900с и падает с сообщением, где написано, чем посмотреть держателя.
Второй ssh-шаг (перезагрузка прокси) лок не берёт — он образов не тянет, пруну
там нечего портить.
Секция concurrency оставлена: заработает при обновлении Forgejo. Но гейт теперь
не выдаёт её за действующий механизм — проверки разделены на обязательные (лок)
и декларативные (группа), и в шапке написано, почему.
Гейт мутационно проверен: убрать flock → 2 failed, развести пути локов →
2 failed, убрать сообщение о таймауте → 1 failed, развести группы → 1 failed,
контроль → 7 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
6.2 KiB
Python
109 lines
6.2 KiB
Python
"""Гейт: докер-секции обоих прод-деплоев исключают друг друга (#2950).
|
||
|
||
Что произошло. Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в один и тот же
|
||
докер-демон — стеки gendesign-*, tradein-* и сам forgejo-runner живут на одной
|
||
VM. Каждый в конце делает `docker image prune -af`, и прун одного сносит leases
|
||
ещё не доехавшего `compose pull` другого:
|
||
|
||
unable to lease content: lease does not exist: not found
|
||
|
||
20.08 деплой ПТИЦЫ упал за 5 секунд, прод остался на старом коде.
|
||
|
||
ЧЕМ ЭТО ЧИНИТСЯ СЕГОДНЯ — host-lock, а НЕ секцией `concurrency`. На Forgejo
|
||
10.0.3 (gitea-1.22) workflow-level concurrency не исполняется: после мержа
|
||
правки, сводившей обе группы к `deploy-prod`, обе цепочки стартовали на одном
|
||
коммите ОДНОВРЕМЕННО (`docker ps` на раннере показал их build-джобы бок о бок).
|
||
Секция оставлена как декларация на будущее — она заработает при обновлении
|
||
Forgejo, — но выдавать её за действующий механизм нельзя, поэтому проверки
|
||
разделены: про лок — обязательные, про группу — про декларацию.
|
||
|
||
Разводка групп обратно или пропажа `flock` не ломают ни один прогон CI и не
|
||
дают ни одного сигнала: отказ проявится только в следующем совпадении окон двух
|
||
мержей. Поэтому инвариант зафиксирован здесь.
|
||
"""
|
||
|
||
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")
|
||
|
||
# Один и тот же путь у обоих — иначе взаимного исключения не выйдет.
|
||
LOCK_PATH = "/var/lock/gendesign-docker-deploy.lock"
|
||
|
||
|
||
def _text(name: str) -> str:
|
||
path = WORKFLOWS / name
|
||
assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп"
|
||
return path.read_text()
|
||
|
||
|
||
def _deploy_script(name: str) -> str:
|
||
"""Тело ssh-шага, который реально мутирует докер (в нём есть агрессивный прун).
|
||
|
||
Именно этот блок обязан брать лок. Второй ssh-шаг (перезагрузка прокси)
|
||
образов не тянет, пруну там нечего портить — от него лок не требуется.
|
||
"""
|
||
text = _text(name)
|
||
marker = "docker image prune -af"
|
||
assert marker in text, f"{name}: пропал `{marker}` — гейт опирается на него, перепроверить"
|
||
return text
|
||
|
||
|
||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||
def test_prod_deploy_takes_the_host_lock(name: str) -> None:
|
||
"""Деплой берёт лок на хосте перед докер-секцией."""
|
||
script = _deploy_script(name)
|
||
assert f"exec 9>{LOCK_PATH}" in script, (
|
||
f"{name}: не берёт host-lock {LOCK_PATH}. Без него `docker image prune -af` "
|
||
"соседнего деплоя оборвёт наш `compose pull` (#2950)."
|
||
)
|
||
assert "flock -w" in script, (
|
||
f"{name}: лок открывается, но не захватывается через `flock -w` — "
|
||
"открытый fd сам по себе ничего не исключает"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||
def test_lock_wait_is_bounded_and_loud(name: str) -> None:
|
||
"""Ожидание ограничено и провал слышен: молча ждать вечно — не вариант."""
|
||
script = _deploy_script(name)
|
||
assert "не дождался лока докер-деплоя" in script, (
|
||
f"{name}: нет внятного сообщения на исчерпание ожидания лока — "
|
||
"деплой упадёт с голым кодом возврата flock"
|
||
)
|
||
|
||
|
||
def test_both_prod_deploys_use_the_same_lock_path() -> None:
|
||
"""Путь лока общий. Разные пути = два независимых лока = нет исключения."""
|
||
holders = {name: f"exec 9>{LOCK_PATH}" in _deploy_script(name) for name in PROD_DEPLOYS}
|
||
assert all(holders.values()), (
|
||
f"лок берут не все прод-деплои: {holders}. Взаимное исключение работает, "
|
||
"только когда ОБА ходят через один и тот же файл."
|
||
)
|
||
|
||
|
||
# ── Ниже — про секцию concurrency. Сегодня она НЕ исполняется (см. шапку), ────
|
||
# поэтому это проверки декларации, а не работающего механизма.
|
||
|
||
|
||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||
def test_prod_deploy_declares_shared_concurrency_group(name: str) -> None:
|
||
"""Группа объявлена и общая — заработает при обновлении Forgejo.
|
||
|
||
Отдельно от лока: если однажды эта секция начнёт исполняться, разведённые
|
||
группы снова разрешат параллельный запуск.
|
||
"""
|
||
conc = yaml.safe_load(_text(name)).get("concurrency") or {}
|
||
assert (
|
||
conc.get("group") == "deploy-prod"
|
||
), f"{name}: группа concurrency = {conc.get('group')!r}, ожидалась общая 'deploy-prod'"
|
||
assert conc.get("cancel-in-progress") is False, (
|
||
f"{name}: cancel-in-progress должен быть false — отменённый деплой оставляет "
|
||
"прод на старом коде ровно так же, как упавший"
|
||
)
|