fix(ci): докер-секции прод-деплоев исключают друг друга через host-lock (#2950) #2955

Merged
bot-backend merged 1 commit from fix/2950-host-lock into main 2026-08-20 08:13:56 +00:00
3 changed files with 129 additions and 32 deletions

View file

@ -635,6 +635,31 @@ jobs:
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
# #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 так и вышло: run 8083 упал за 5с (прун соседа отработал через
# 0.4с после обрыва пула), прод остался на старом коде.
#
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
# concurrency не исполняется — проверено, обе цепочки стартовали на
# одном коммите одновременно. Она оставлена как декларация, которая
# заработает после обновления Forgejo; сегодня работает вот этот лок.
#
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
# докер-команды на хосте продолжат работу — и лок продолжит их
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
exec 9>/var/lock/gendesign-docker-deploy.lock
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
cd /opt/gendesign
# repo уже clone'ен — origin = Forgejo. Подтягиваем последний main.

View file

@ -481,6 +481,31 @@ jobs:
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS
script: |
set -euo pipefail
# #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 так и вышло: run 8083 упал за 5с (прун соседа отработал через
# 0.4с после обрыва пула), прод остался на старом коде.
#
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
# concurrency не исполняется — проверено, обе цепочки стартовали на
# одном коммите одновременно. Она оставлена как декларация, которая
# заработает после обновления Forgejo; сегодня работает вот этот лок.
#
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
# докер-команды на хосте продолжат работу — и лок продолжит их
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
exec 9>/var/lock/gendesign-docker-deploy.lock
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
cd /opt/gendesign
# Sync compose / Caddyfile / init scripts from the repo.

View file

@ -1,20 +1,25 @@
"""Гейт: оба прод-деплоя обязаны лежать в ОДНОЙ группе concurrency.
"""Гейт: докер-секции обоих прод-деплоев исключают друг друга (#2950).
Почему это тест, а не комментарий в yml (#2950). Деплой ПТИЦЫ и деплой Trade-In
ходят по SSH в один и тот же докер-демон стеки gendesign-* и tradein-* плюс сам
forgejo-runner живут на одной VM. Каждый в конце делает `docker image prune -af`.
Пока группы разные, Forgejo запускает их параллельно, и прун одного сносит leases
Что произошло. Деплой ПТИЦЫ и деплой 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 так и вышло: run 8083 упал за 5 секунд, прод остался на старом коде. Разводка
групп обратно ничего не сломает в CI и не даст ни одного сигнала отказ проявится
только в следующем совпадении окон двух мержей. Поэтому инвариант зафиксирован здесь.
20.08 деплой ПТИЦЫ упал за 5 секунд, прод остался на старом коде.
`cancel-in-progress: false` частью инварианта тоже является: прод-деплои обязаны
достаиваться в очереди, а не отменять друг друга отменённый деплой оставляет прод
на старом коде ровно так же, как упавший.
ЧЕМ ЭТО ЧИНИТСЯ СЕГОДНЯ 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
@ -28,35 +33,77 @@ WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
PROD_DEPLOYS = ("deploy.yml", "deploy-tradein.yml")
# Один и тот же путь у обоих — иначе взаимного исключения не выйдет.
LOCK_PATH = "/var/lock/gendesign-docker-deploy.lock"
def _concurrency(name: str) -> dict:
def _text(name: str) -> str:
path = WORKFLOWS / name
assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп"
return yaml.safe_load(path.read_text()).get("concurrency") or {}
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_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)."
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_prod_deploy_queues_instead_of_cancelling(name: str) -> None:
"""Раны достаиваются в очереди: отменённый деплой = прод на старом коде."""
assert _concurrency(name).get("cancel-in-progress") is False, (
f"{name}: cancel-in-progress должен быть false — иначе более новый мерж "
"отменит выкатку предыдущего и та молча не доедет"
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 — отменённый деплой оставляет "
"прод на старом коде ровно так же, как упавший"
)