From 34fbac205ea456bed3b93919a33f19af83ddfe47 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 12:53:28 +0500 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=D0=B4=D0=BE=D0=BA=D0=B5=D1=80-?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?-=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B5=D0=B2=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8E=D1=82=20=D0=B4=D1=80=D1=83?= =?UTF-8?q?=D0=B3=20=D0=B4=D1=80=D1=83=D0=B3=D0=B0=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20host-lock=20(#2950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поправка к #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 --- .forgejo/workflows/deploy-tradein.yml | 25 ++++ .forgejo/workflows/deploy.yml | 25 ++++ .../ops/test_2950_deploy_concurrency_group.py | 111 +++++++++++++----- 3 files changed, 129 insertions(+), 32 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 5720b1f3..e15d8290 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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. diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 64688aa3..92bb367b 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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. diff --git a/backend/tests/ops/test_2950_deploy_concurrency_group.py b/backend/tests/ops/test_2950_deploy_concurrency_group.py index 29ef0b01..d72c64f3 100644 --- a/backend/tests/ops/test_2950_deploy_concurrency_group.py +++ b/backend/tests/ops/test_2950_deploy_concurrency_group.py @@ -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 — отменённый деплой оставляет " + "прод на старом коде ровно так же, как упавший" ) -- 2.45.3