name: Deploy Infra Host # Синхронизация /opt/gendesign на ХОСТЕ, КОТОРЫЙ ОСТАЁТСЯ (#3059, #3057). # # ЗАЧЕМ. Сегодня Beget — и прод, и инфраструктура одновременно, поэтому его # рабочее дерево обновляет обычный `deploy.yml` (шаг `git reset --hard # origin/main` по SSH на `secrets.DEPLOY_HOST`). После переезда 30.08 # `DEPLOY_HOST` станет указывать на Selectel — и /opt/gendesign на Beget # перестанет обновляться СОВСЕМ. Молча. # # А из этого каталога на Beget продолжат работать: # - cron-скрипты бэкапов: ops/backup.sh, ops/backup-forgejo.sh, # ops/check-backup-staleness.sh, ops/lib-backup.sh, ops/docker-prune.sh # - docker-compose.prod.yml для Forgejo / GlitchTip / CouchDB # - Caddyfile + caddy/sites/infra.caddy — единственный публичный вход для # git.gendsgn.ru, errors.gendsgn.ru, obsidian.gendsgn.ru (#3062) # # То есть любая будущая правка этих файлов легла бы в main и никогда не доехала # до машины, которая их исполняет. Это ровно класс #2887 («скрипт запускается по # cron из /opt/gendesign, куда попадает только через git reset --hard шага # деплоя»), но не на уровне одного файла, а на уровне целого хоста. # # ПОЧЕМУ ОТДЕЛЬНЫЙ WORKFLOW, А НЕ JOB В deploy.yml. Разные адресаты и разные # вердикты: «выкатили приложение на Selectel» и «синхронизировали инфраструктуру # на Beget» — два независимых факта, и падение второго не должно читаться как # неудавшийся деплой продукта. Плюс триггеры разные: инфра-хосту не нужны # пересборки backend/frontend. # # ИНЕРТЕН, ПОКА НЕ ЗАДАН INFRA_DEPLOY_HOST. Сейчас, до переезда, Beget и есть # DEPLOY_HOST — второй проход по тому же хосту был бы лишним и мог бы состязаться # с основным деплоем за докер-демон (#2950). Поэтому job не делает ничего, пока # секрет пуст: включается ОДНОЙ настройкой в момент, когда хосты разъедутся. # ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── # Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на # Beget — SSH становится междоузловым, через интернет. Поэтому у вызова # appleboy/ssh-action ниже появился вход `fingerprint`. # ЧТО ЗАДАТЬ: секрет INFRA_DEPLOY_SSH_FINGERPRINT = # ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' # (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml). # ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод # строки при копипасте включает проверку и роняет ssh-шаг с `host key # fingerprint mismatch`. # ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy # v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR. # Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у # TRADEIN_INTERNAL_AUTH_SECRET (#2989). # ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] paths: # Ровно то, что исполняется НА ОСТАЮЩЕМСЯ хосте. Намеренно НЕ включены # backend/** и frontend/** — их образы туда не едут. - "ops/*.sh" # ops/*.cron — эталоны crontab. Деплой их не исполняет, но после # разъезда хостов (#3057) правка crontab-beget.cron иначе доезжала бы # только до продового хоста: строка ops/*.cron есть лишь в deploy.yml. # Одиночная звёздочка не пересекает `/`, поэтому это именно файлы в # корне ops/, как и ops/*.sh рядом. - "ops/*.cron" - "Caddyfile" - "caddy/**" - "docker-compose.prod.yml" - "docker-compose.obsidian.yml" - ".forgejo/workflows/deploy-infra.yml" workflow_dispatch: jobs: sync-infra-host: runs-on: ubuntu-latest steps: - name: Проверить, разъехались ли хосты id: gate env: INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} run: | set -euo pipefail if [ -z "${INFRA_HOST:-}" ]; then echo "enabled=false" >> "$GITHUB_OUTPUT" echo "INFRA_DEPLOY_HOST не задан — хосты ещё не разъехались." echo "Инфраструктуру обновляет обычный deploy.yml. Ничего не делаю." else echo "enabled=true" >> "$GITHUB_OUTPUT" echo "INFRA_DEPLOY_HOST задан — синхронизирую остающийся хост." fi # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — # «инертно по умолчанию, включается одной настройкой». Заведут секрет — # предупреждение исчезнет само. - name: Подлинность хоста — статус проверки (#3029) if: steps.gate.outputs.enabled == 'true' env: HOST_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} run: | set -euo pipefail if [ -n "${HOST_FINGERPRINT:-}" ]; then echo "Подлинность хоста: сверяется по INFRA_DEPLOY_SSH_FINGERPRINT." else echo '::warning title=SSH без проверки подлинности хоста::INFRA_DEPLOY_SSH_FINGERPRINT не задан — ключ остающегося хоста НЕ проверяется (#3029). Фолбэка на DEPLOY_SSH_FINGERPRINT здесь нет и быть не должно: это другая машина. По каналу едет INFRA_DEPLOY_SSH_KEY и выполняется git reset на /opt/gendesign. Как снять отпечаток — см. шапку этого файла.' echo '###############################################################' echo '# ВНИМАНИЕ (#3029): INFRA_DEPLOY_SSH_FINGERPRINT не задан.' echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' echo '# Как снять отпечаток — см. шапку этого файла.' echo '###############################################################' fi - name: Синхронизировать /opt/gendesign на остающемся хосте if: steps.gate.outputs.enabled == 'true' uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.INFRA_DEPLOY_HOST }} username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT }} # #3029: БЕЗ фолбэка на DEPLOY_SSH_FINGERPRINT — в отличие от user/key/port # выше. Те у двух хостов совпадают, а отпечаток — это идентичность # КОНКРЕТНОЙ машины: после переезда INFRA_DEPLOY_HOST=Beget, а # DEPLOY_HOST=Selectel, и фолбэк означал бы сверку ключа Beget'а с # отпечатком Selectel'а — гарантированный отказ ровно у того workflow, # который чинит остающийся хост. Пусто → проверка пропускается. fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} script: | set -euo pipefail cd /opt/gendesign git fetch origin main git reset --hard origin/main # Тот же chmod, что и в deploy.yml: cron зовёт скрипты через `bash`, # но restore-drill и ручные запуски рассчитывают на +x. chmod +x ops/*.sh 2>/dev/null || true # Caddy здесь несёт ТОЛЬКО остающиеся домены (CADDY_SITES=infra). # reload, а не recreate: конфиг примонтирован read-only, контейнер # читает тот же файл, который только что обновил git. Если конфиг # невалиден — reload откажет ГРОМКО, а старый останется работать, # то есть падение здесь не роняет git/errors/obsidian. if docker ps --format '{{.Names}}' | grep -q '^gendesign-caddy-1$'; then docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile echo "✓ конфиг прокси перезагружен" else echo "⚠ контейнер caddy не найден — пропускаю reload" fi # infra-postgres (#3061): применить изменения конфигурации сервиса. # После разъезда хостов этот workflow — ЕДИНСТВЕННОЕ, что доставляет # docker-compose.prod.yml на Beget, а лёгкий кластер БД описан именно # там. Без этой строки любой будущий бамп postgres:16-alpine, правка # mem_limit или healthcheck'а легли бы в main, workflow отрапортовал # бы «дерево синхронизировано», а контейнер продолжил бы жить со # старой конфигурацией по `restart: unless-stopped` — молча, ровно # класс #2887, ради которого этот workflow и написан. # # `up -d` с ЯВНЫМ именем сервиса, а не общий: трогается только # infra-postgres, до Forgejo / GlitchTip / CouchDB дела нет. Явное # имя заодно активирует профиль `infra` само по себе, без оглядки на # COMPOSE_PROFILES. Если конфигурация не менялась — compose ничего не # пересоздаёт, шаг стоит доли секунды. # # СОЗДАВАТЬ кластер отсюда мы НЕ хотим — отсюда проверка на # существующий контейнер. Первый старт — осознанный ручной шаг окна # переезда (шаг 2 в docker-compose.prod.yml), и делается он с уже # заполненными INFRA_PG_PASSWORD / FORGEJO_DB_PASS. Стартуй мы вслепую # — пустой пароль дал бы отравленный том, который потом не # переинициализировать. if docker ps -a --format '{{.Names}}' | grep -qx 'gendesign-infra-postgres'; then docker compose -p gendesign -f docker-compose.prod.yml up -d infra-postgres echo "✓ infra-postgres приведён к конфигурации из main" else echo "⚠ контейнер gendesign-infra-postgres не найден — кластер ещё не поднят вручную, пропускаю" fi # НАМЕРЕННО НЕ ДЕЛАЕТСЯ: # - docker image prune: конкурирует с деплоем продукта за leases # докер-демона (#2950). Прун на этом хосте остаётся за # еженедельным ops/docker-prune.sh. # - перезапуск Forgejo / GlitchTip / CouchDB: правка ops-скрипта # не повод ронять git. Их обновление — осознанное действие. echo "✓ рабочее дерево синхронизировано: $(git rev-parse --short HEAD)"