Перейти к содержанию
BusinessPad QA

CI и публикация

Pipeline публикации

qa_portal_verify проверяет синтаксис Bash/JavaScript, запускает pytest, Ruff, строгую сборку MkDocs и проверку artifact на symlink, hardlink и special files. Результат хранится семь дней.

После успешной проверки pipeline защищённой ветки develop автоматически запускает публикацию. Отдельная ручная job остаётся для диагностики:

Job Запуск Действие Меняет document root
qa_portal_preflight Вручную Проверяет HTTP marker, host key, lock, права, protocol и SHA-256 publisher Нет
qa_portal_publish Автоматически после qa_portal_verify Повторяет preflight, передаёт artifact, публикует и выполняет HTTP smoke Да

Preflight требует дополнительного подтверждения и имеет allow_failure: true. Publish является обязательной job: ошибка доставки или внешнего HTTP smoke завершает pipeline с ошибкой. Результат выпуска также фиксируется в deployment environment qa/test-2.

Зафиксированная цель

Из CI нельзя передать другой host или document root:

  • SSH host: 92.63.104.89:22;
  • public URL: http://92.63.104.89/;
  • document root: /var/www/test-2.business-pad.com;
  • отчёты: /var/www/test-2.business-pad.com/allure;
  • private marker: .businesspad-qa-portal-root со значением test-2.business-pad.com и одним переводом строки;
  • public marker: /.well-known/businesspad-qa-portal-origin с тем же точным значением.

Host и URL зафиксированы в tools/deploy_qa_portal.sh. Пути, markers и допустимые SSH-команды зафиксированы в root-owned копии tools/publish_qa_portal.py на сервере. Pipeline отправляет этому коду только ограниченный несжатый tar-архив статических файлов и его SHA-256.

Перед SSH wrapper обращается к фиксированному IP по HTTP и требует точное содержимое public marker. SSH-публикация остаётся зашифрованной и проверяет pinned host key, но содержимое портала при просмотре по HTTP не защищено от чтения и подмены в сети. Поэтому портал не должен принимать или показывать реальные пароли, токены, персональные либо финансовые данные.

Переменные GitLab

В Settings → CI/CD → Variables создать три переменные. Для всех установить Protect variable, отключить expansion и выбрать Environment scope qa/test-2.

Variable Type Visibility Значение
QA_PORTAL_DEPLOY_USER Variable Visible отдельный non-root пользователь, например bp_qa_portal
QA_PORTAL_SSH_PRIVATE_KEY File Visible приватная часть отдельного deploy key без passphrase
QA_PORTAL_SSH_KNOWN_HOSTS File Visible заранее проверенная запись именно для 92.63.104.89

Многострочный private key нельзя помечать Masked/Hidden: GitLab требует для такой File variable режим Visible. Последняя строка ключа должна заканчиваться LF. Значение по-прежнему защищено настройками protected branch, protected variable и environment scope; Visible не означает вывод в job log.

Пароль сервера, private key и другие секреты нельзя вводить в форму запуска manual preflight. Значения из этой формы доступны пользователям, способным повторно запустить job. Password-based SSH и StrictHostKeyChecking=no не поддерживаются.

Одноразовая подготовка сервера

Подготовку выполняет администратор отдельно от CI. Сначала подтверждается фактический Nginx root и снимается inventory:

find /var/www/test-2.business-pad.com -mindepth 1 -maxdepth 1 -printf '%f\n' | sort

Portal/unmanaged entries резервируются отдельно от большого allure; retention Allure остаётся ответственностью report producer. Если уже есть index.html, assets или другие имена будущей сборки, первый publish завершится ошибкой и ничего не перезапишет. Миграцию таких файлов проводят вручную только после проверенного backup и плана восстановления.

1. Отдельная учётная запись

Создать системного пользователя bp_qa_portal без sudo и с заблокированным паролем. Обычный root account и его ключи в GitLab не используются:

useradd --system --user-group --create-home \
  --home-dir /var/lib/bp_qa_portal --shell /bin/sh bp_qa_portal
passwd --lock bp_qa_portal

Если пользователь уже существует, его не пересоздают: сначала проверяют id, passwd -S bp_qa_portal и sudo -l -U bp_qa_portal. В списке sudo не должно быть разрешённых команд.

Document root должен быть root-owned, иметь точный режим 1775 и группу bp_qa_portal, единственным участником которой является одноимённый deploy user. Все ancestors (/, /var, /var/www) должны быть root-owned и не иметь group/ world write. Sticky bit не позволяет deploy user удалить root-owned markers или каталог Allure, принадлежащий другому пользователю.

После проверки Nginx и backup подготавливаются root и markers:

install -d -o root -g bp_qa_portal -m 1775 /var/www/test-2.business-pad.com
install -d -o root -g root -m 0755 \
  /var/www/test-2.business-pad.com/.well-known
printf 'test-2.business-pad.com\n' \
  > /var/www/test-2.business-pad.com/.businesspad-qa-portal-root
printf 'test-2.business-pad.com\n' \
  > /var/www/test-2.business-pad.com/.well-known/businesspad-qa-portal-origin
chown root:root \
  /var/www/test-2.business-pad.com/.businesspad-qa-portal-root \
  /var/www/test-2.business-pad.com/.well-known/businesspad-qa-portal-origin
chmod 0444 \
  /var/www/test-2.business-pad.com/.businesspad-qa-portal-root \
  /var/www/test-2.business-pad.com/.well-known/businesspad-qa-portal-origin

Каталог allure принадлежит отдельному report producer; bp_qa_portal не должен владеть им или иметь право записи. Его owner/group нельзя менять рекурсивной командой из этого runbook. Перед продолжением должны выполняться условия:

owner(/var/www/test-2.business-pad.com) = root
group(/var/www/test-2.business-pad.com) = bp_qa_portal
members(group bp_qa_portal)             = [bp_qa_portal]
mode(/var/www/test-2.business-pad.com)  = 1775
owner(/, /var, /var/www)                = root
group_or_world_write(/, /var, /var/www) = false
owner(.businesspad-qa-portal-root)      = root
mode(.businesspad-qa-portal-root)       = 0444
owner(.well-known)                      = root
mode(.well-known)                       = 0755
owner(public marker)                    = root
mode(public marker)                     = 0444
owner(allure)                           != bp_qa_portal
writable_by_bp_qa_portal(allure)        = false

Проверка выполняется через namei -l, stat -c '%U:%G %a %n' и негативные sudo -u bp_qa_portal test ! -w <path>. Отдельно подтверждается, что deploy user может создать и удалить свой пробный файл в document root, но не может изменить markers или allure.

CI намеренно не создаёт root, markers или allure и отклоняет запуск от root.

Для взаимного исключения всех forced-command процессов нужен отдельный lock вне document root. Каталог принадлежит root и недоступен для подмены deploy user, сам файл принадлежит только deploy user:

install -d -o root -g root -m 0755 /var/lib/businesspad-qa-portal
install -o bp_qa_portal -g bp_qa_portal -m 0600 /dev/null \
  /var/lib/businesspad-qa-portal/publish.lock
stat -c '%U:%G %a %n' \
  /var/lib/businesspad-qa-portal \
  /var/lib/businesspad-qa-portal/publish.lock

Одновременно выполняется только один preflight или publish. Повторный процесс завершается сразу с ошибкой, а не конкурирует за manifest и rename-операции.

2. Root-owned forced command

Из проверенного checkout администратор устанавливает publisher вне document root и сверяет digest:

install -d -o root -g root -m 0755 /usr/local/lib/businesspad-qa-portal
sha256sum tools/publish_qa_portal.py
install -o root -g root -m 0555 tools/publish_qa_portal.py \
  /usr/local/lib/businesspad-qa-portal/publish_qa_portal.py
sha256sum /usr/local/lib/businesspad-qa-portal/publish_qa_portal.py

Оба SHA-256 должны совпасть; digest установленной версии фиксируется в change record. Каждый CI preflight требует одновременно совпадения версии forced-command protocol и SHA-256 всей root-owned копии publisher. Поэтому после любого изменения файла его нужно переустановить до следующей публикации.

Для deploy key в /var/lib/bp_qa_portal/.ssh/authorized_keys указывается только forced command:

restrict,command="/usr/bin/python3 -I /usr/local/lib/businesspad-qa-portal/publish_qa_portal.py --forced-command" ssh-ed25519 AAAA... businesspad-qa-portal-ci

Каталог .ssh должен иметь режим 0700, authorized_keys0600, оба должны принадлежать bp_qa_portal. При стабильном исходящем IP runner к options ключа добавляется from="<runner-ip>/32".

Forced command принимает только preflight и publish <sha256>. Он не даёт CI shell, SCP, port forwarding или PTY. Изменение server-side publisher требует отдельного административного review и повторной установки root-owned файла. Негативная проверка ssh ... uname -a должна завершиться сообщением об unsupported QA portal SSH command, не выполняя uname.

3. Public route и host key

Nginx должен отдавать public marker как text/plain по HTTP на фиксированном IP без редиректа на другой host. Домен и сертификат не являются частью принятой схемы публикации внутреннего QA-портала.

В HTTP server block фиксируются document root, portal 404 и доступ только к публичному marker среди dotfiles:

root /var/www/test-2.business-pad.com;
error_page 404 /404.html;

location / {
    try_files $uri $uri/ =404;
}
location = /404.html {
    internal;
}
location = /.well-known/businesspad-qa-portal-origin {
    default_type text/plain;
    try_files $uri =404;
}
location ~ /\. {
    deny all;
}

После nginx -t проверяют, что неизвестный URL возвращает portal 404.html, public marker доступен, а private root marker, manifest, staging и backup не отдаются по HTTP.

ssh-keyscan можно использовать только для получения записи, но не как доказательство её подлинности. Fingerprint сверяется через независимый доверенный канал с администратором сервера, после чего строка для IP сохраняется в QA_PORTAL_SSH_KNOWN_HOSTS. Wrapper дополнительно требует, чтобы File variable содержала фиксированный host, игнорирует пользовательский SSH config и всегда использует strict host checking.

Настройки GitLab CE

Текущий сервер использует GitLab Community Edition 18.10. Для проекта нужны:

  1. Ветка develop защищена; merge в неё разрешён только тем, кто вправе публиковать портал. Защита обязательна: CI rule не запускает deploy-job для незащищённой ветки, а protected variables в неё не передаются.
  2. SSH job выполняет project runner с тегами docker и businesspad-qa-deploy. Он отмечен как Protected, закреплён за доверенным project/group, не использует privileged mode/Docker socket и имеет исходящий доступ к container/package registries, 92.63.104.89:22 и 92.63.104.89:80. Одна только текущая комбинация тегов не изолирует verify: runner с обоими тегами подходит и для job, требующей только docker. Для физического разделения нужен второй runner и отдельный тег businesspad-qa-verify; после его регистрации тег добавляется в verify-job, а deploy runner не получает этот тег. Название тега не заменяет проверку фактического executor type.
  3. Включена настройка Prevent outdated deployment jobs, а Allow job retries for rollback deployments отключена: возврат выполняется новым revert commit/pipeline, не повторным запуском старого artifact.
  4. Все три deploy variables имеют scope qa/test-2. Обе remote-job объявляют этот environment; preflight использует action: verify и не создаёт deployment.

Protected Environments относятся к GitLab Premium/Ultimate и в текущем CE не являются границей доступа. Если лицензия изменится, qa/test-2 дополнительно защищается списком Allowed to deploy. Если разработчикам нужен merge в develop, но не право публикации, deploy-job и deploy variables нужно вынести в отдельный проект с более узким membership: одной YAML-настройкой текущего CE эти роли не разделить.

Что происходит при публикации

  1. Verify-job создаёт artifact и проверяет его типы и лимиты.
  2. Publish-job повторяет локальную проверку, упаковывает только site/ в несжатый tar, считает SHA-256 и передаёт архив через stdin SSH forced command.
  3. Сервер ограничивает размер всего tar, суммарный payload и число файлов, запрещает path traversal, symlink, hardlink, FIFO, socket и device.
  4. Перед изменением повторно проверяются exact root, private/public markers, режимы, владельцы и отсутствие коллизий с unmanaged entries.
  5. Publisher заменяет через private staging/rename только имена из собственного manifest. При ошибке или обрабатываемом сигнале во время фазы замены до commit операция откатывается; allure и чужие имена не принимаются во владение автоматически. Если ошибка возникает уже во время rollback, publisher сохраняет private backup/staging, сообщает их имена и не удаляет единственную копию для ручного восстановления. Любое такое recovery-состояние блокирует следующие preflight/publish до решения администратора.
  6. CI скачивает site-version.json по HTTP и сравнивает его SHA-256 с artifact. Если этот внешний smoke не проходит, job завершается ошибкой, но уже завершённая серверная транзакция автоматически не откатывается.

Artifact доступен семь дней. Для возврата к старой версии создаётся новый revert commit в защищённой ветке develop и его новый pipeline; запуск старой deployment job не используется.

Аварийное восстановление

Если publisher сообщает recovery state, новые публикации не запускают. Сначала администратор сохраняет снимок document root и перечисляет только immediate private-каталоги текущей транзакции:

find /var/www/test-2.business-pad.com -mindepth 1 -maxdepth 1 \
  \( -name '.qa-portal-backup-*' -o -name '.qa-portal-staging-*' \) \
  -printf '%M %u:%g %f\n'

Содержимое backup, staging, live root и manifest сопоставляют с сообщением job. Нельзя автоматически удалять recovery-каталоги или повторять publish: backup может быть единственной копией предыдущей версии. После ручного восстановления index.html, всех manifest-owned entries и manifest проверяют markers, права и HTTP site-version.json; только затем удаляют конкретные уже использованные recovery-каталоги и повторяют preflight.

Текущая top-level схема даёт короткое окно 404/смешанных статических файлов между rename старой и новой версии. Для портала это принятый компромисс; полная read-atomic публикация потребует versioned release directories и одного переключения Nginx current, при отдельном alias для /allure/.