Files
han-app/VM2_services/codebase/services/deployment/RUNBOOK.ru.md
T

1131 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не
разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды
ниже — операторские; создание репозитория их не выполняет.
Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и
deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды
ниже предполагают текущий каталог `VM2_services`, если не указано иное.
## Блокеры production перед первым запуском
1. Замените каждый плейсхолдер в `.env` на проверенные несекретные значения.
Держите `BITRIX_SYNC_ENABLED=false`, пока не подписаны миграции, гранты,
поля портала, контракты роботов и cutover.
(для этого нужно еще образы отправить в conteiner registry, пункт 2)
2. Заполните каждую переменную `*_IMAGE` проверенным digest из registry.
Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые
теги не являются доказательством для production.
3. Устанавливайте production-файлы от `root:root`; пользователь `deploy` не
должен входить в группу `docker` и не должен иметь возможность писать
Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов.
(смысл: заходим под админом, sudo -i)
4. Заполните отдельные проверенные активные CIDR-файлы из двух `.template`.
Их закоммиченные активные версии намеренно содержат `deny all`.
(в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса)
5. Выпустите публичный ACME-сертификат в host-каталог `/etc/letsencrypt`.
Разместите CA управляемой PostgreSQL в `/etc/han/ca`. Выпустите
сертификат внутренней CA, SAN которого совпадает с приватным именем VM2.
Разрешайте хостовый порт `8443` только из SG VM1 и, при необходимости,
одобренных приватных/VPN-сетей операторов.
(выпуск сертификатов)
6. Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только
имена из `deployment/secrets/config.example.json`. Никогда не
переиспользуйте принципал VM1.
(отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member)
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
открывать неаутентифицированный `PING` только для health и
защищённого паролем пользователя `safety`, ограниченного необходимыми
ключами/командами `han:safety:*`.
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
8. Выделите отдельные учётные данные БД для runtime и миграций.
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
владеет DDL, а `BITRIX_SYNC_DATABASE_URL` — runtime-роль с минимальными
привилегиями. Учётные данные миграций монтируются только в jobs профиля
`ops`.
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
production ограничьте egress правилами Selectel SG/NAT/proxy до
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
источников ClamAV. Registry/package repositories оставляйте только на
controlled maintenance window.
## Кто что выполняет
- **Локальный компьютер оператора:** создаёт архив релиза и передаёт его на
VM2. Локальные команды ниже показаны для PowerShell.
- **`root` на VM2:** только bootstrap host OS, активация проверенного релиза,
установка root-owned файлов, настройка `.env`, secret mapping, credentials,
TLS/allow-list, миграции и первый старт.
- **`deploy` на VM2:** принимает релиз только в
`/var/lib/han-deploy/incoming`, проверяет статус/логи и запускает уже
установленные fixed systemd operations через точные sudo-правила.
`deploy` не запускает `docker`, не редактирует `/opt/han-chat/services` и не
входит в группу `docker`.
- **`admin` на VM2:** персональная break-glass роль с отдельным SSH-ключом и
отдельным локальным паролем для `sudo`. Не используется для штатного деплоя,
не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной
операцией.
## Предварительные условия (до §1)
Этот runbook описывает операции **на уже созданной VM2**. До bootstrap
подготовьте вне Compose:
1. **Инфраструктура Selectel** — VPC/subnet, SG (`80/443/22` public;
`8443` только private; egress default-deny после bootstrap), sizing
(4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см.
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
public и private IP VM2, DNS A-запись `PROCESSING_PUBLIC_HOST`.
2. **Managed PostgreSQL** — schemas/roles для `message_safety` и
`bitrix_sync`, отдельные migration/runtime DSN; см.
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
clamav, otel-collector).
4. **Selectel Secrets Manager** — заполнить все remote names из
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
VM2 с read-only доступом только к этим именам.
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
PEM хранится в Secrets Manager, не в каталоге релиза.
Порядок разделов §1–§5 → Gates 19 → §7 (post-acceptance). `systemctl enable`
и `systemctl start han-processing.service` — **только после успешного Gate 5**.
## 1. Bootstrap свежей VM2
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
остаются только у соответствующих операторов и никогда не передаются на VM:
```powershell
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy `
-C "han-vm2-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin `
-C "han-vm2-break-glass-admin"
```
Для production ключ `admin` должен принадлежать отдельному назначенному
break-glass оператору и храниться отдельно от deploy key. Если команды
выполняет один человек на этапе bootstrap, это всё равно две разные key pairs
с раздельной последующей передачей/ротацией.
Скопируйте setup-скрипт и только публичные части ключей во временный root
каталог:
```powershell
# текущий каталог: ...\HAN_chat_specification\VM2_services
scp -i C:\Users\MI\.ssh\hansel `
.\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel `
C:\Users\MI\.ssh\han_vm2_deploy.pub `
C:\Users\MI\.ssh\han_vm2_admin.pub `
root@<VM2_PUBLIC_IP>:/root/
```
На VM2 в текущей root-сессии задайте приватный CIDR VM1. `/0` скрипт отклоняет:
```sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
/root/setup-vm2.sh
```
Скрипт устанавливает Ubuntu-пакеты, Docker Engine + Compose plugin, UFW,
fail2ban, unattended upgrades, swap, sysctl и цепочку `DOCKER-USER`; создаёт
`deploy`, break-glass `admin`, staging и root-owned production-каталог. Скрипт
не запускает Compose/контейнеры. `80/443` и SSH открываются публично; SSH
остаётся key-only и защищён fail2ban. `8443` доступен только на приватном IP
VM2 из `VM1_PRIVATE_CIDRS`. Если оператору нужен прямой доступ к внутреннему
API через приватный маршрут или VPN, дополнительно передайте необязательный
`OPS_CIDRS='<OPS_PRIVATE_OR_VPN_CIDR>'`.
В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не
разрешает password SSH: пароль нужен только после входа по admin key:
```sh
passwd admin
```
Не закрывая root-сессию, на локальном компьютере проверьте оба входа:
```powershell
ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@<VM2_PUBLIC_IP>
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@<VM2_PUBLIC_IP>
```
В admin-сессии проверьте запрос именно admin-пароля и получение root shell,
после чего сразу выйдите из него:
```sh
sudo -v
sudo -i
id
exit
```
Только после успешной проверки `deploy`, `admin` и `sudo` повторите на VM2
под `root`:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`. Ещё раз откройте
обе новые SSH-сессии после reload и только затем закрывайте старую root.
Публичные bootstrap-копии после проверки можно удалить под `admin`:
```sh
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
## 2. Передача релиза под `deploy`
На локальном компьютере из каталога `VM2_services`:
```powershell
$Release = "<VERSION_OR_GIT_SHA>"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C .\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" `
deploy@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/
```
Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным:
```sh
RELEASE='<VERSION_OR_GIT_SHA>'
cd /var/lib/han-deploy/incoming
sha256sum "vm2-services-${RELEASE}.tar.gz"
tar -tzf "vm2-services-${RELEASE}.tar.gz"
```
На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз
через `sudo` и не копируйте его в production от имени `deploy`.
## 3. Активация и установка файлов под `root`
Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте,
если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект:
```sh
RELEASE='<VERSION_OR_GIT_SHA>'
EXPECTED_SHA256='<SHA256_С_ЛОКАЛЬНОЙ_МАШИНЫ>'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env \
--chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
```
Повторите setup под `root`: теперь он установит helpers и units из активного
релиза. Приложение всё ещё не запускается:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Скрипт устанавливает:
- `/usr/local/lib/han-secrets-vm2/{secrets_loader.py,han-secrets}`;
- `/usr/local/sbin/han-vm2-compose`;
- `/usr/local/sbin/han-message-safety-mode`;
- `/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`;
- `/etc/systemd/system/{han-secrets-vm2,han-processing}.service`;
- `/etc/sudoers.d/{han-vm2-deploy,deploy-message-safety-mode}`;
- группу `han-message-safety` с GID `10001`;
- стандартный `/etc/han-chat/message-safety-mode.env` с правами
`root:han-message-safety 0640`.
## 4. Несекретная конфигурация и Selectel под `root`
`APP_ENV` определяет имя loader config. При значении из `.env.example`
`APP_ENV=production-like` файл обязан называться
`/etc/han/secrets/vm2-production-like.selectel.json`:
```sh
cd /opt/han-chat/services
install -m 0600 -o root -g root .env.example .env
editor .env
install -m 0600 -o root -g root \
deployment/secrets/config.example.json \
/etc/han/secrets/vm2-production-like.selectel.json
editor /etc/han/secrets/vm2-production-like.selectel.json
```
В `.env` заменяются только несекретные плейсхолдеры и image digests. Значения
DSN, token, password, access/secret key туда не записываются. Для Selectel
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
из mapping.
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
его в аргументы или history:
```sh
read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/vm2.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/vm2.selectel-password.cred
chmod 0600 /etc/han/credentials/vm2.selectel-password.cred
```
Активные nginx allow-list файлы редактирует только `root`; последней строкой
обязательно остаётся `deny all;`. До cutover Bitrix public allow-list должен
оставаться закрытым:
```sh
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
editor /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
chown root:root /opt/han-chat/services/nginx/allowlists/*.conf
chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf
```
Для контролируемого восстановления без провайдера используйте явный `file`
config и root-only каталог `0700` с одним файлом на ключ. При сбое Selectel
автоматический fallback запрещён.
## 5. Сертификат PostgreSQL и первоначальный выпуск public TLS
### CA управляемой PostgreSQL
Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во
временный путь. Под `root` установите сертификат вне каталога релиза:
```sh
install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
/tmp/<PROVIDER_POSTGRESQL_CA_FILE> \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<PROVIDER_POSTGRESQL_CA_FILE>
```
В `.env` должно быть:
```dotenv
PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem
```
Compose монтирует этот файл read-only во все runtime и migration контейнеры как
`/run/config/postgresql-ca.pem`. DB-клиенты создают обязательный TLS context с
проверкой цепочки и имени сервера по этому CA. Не добавляйте libpq-параметры
`sslmode`/`sslrootcert` в SQLAlchemy `postgresql+asyncpg` URL: asyncpg получает
SSL context отдельно, а такие query-параметры могут быть переданы как
неподдерживаемые keyword arguments. DSN в Secrets Manager имеет обычный вид:
```text
postgresql+asyncpg://<USER>:<PASSWORD>@<MANAGED_POSTGRES_HOST>:<PORT>/<DATABASE>
```
### Первоначальный выпуск Let's Encrypt
`PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает
на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт
`80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx.
Под `root` задайте значения только для текущей shell-сессии и подготовьте
постоянный webroot:
```sh
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
ACME_EMAIL='<ADMIN_EMAIL>'
install -d -m 0755 -o root -g root /var/lib/han-chat/acme
getent ahostsv4 "$PUBLIC_HOST"
ss -lntp | grep -E ':80[[:space:]]' && {
echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2
exit 1
} || true
```
Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует:
```sh
certbot certonly --standalone --preferred-challenges http \
--staging \
-d "$PUBLIC_HOST" \
--cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
```
После успешного staging-теста выпустите production-сертификат:
```sh
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot certificates
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem"
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem"
getent group han-nginx-tls
test -d /var/lib/han-chat/public-tls
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
/var/lib/han-chat/public-tls/fullchain.pem
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
/var/lib/han-chat/public-tls/privkey.pem
```
Nginx с primary GID `11001` получает только подготовленные public certificate
и private key из `/var/lib/han-chat/public-tls` с host read-only. Исходный
`/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private
key в каталог релиза и не делайте его world-readable.
## 6. Gates 19: preflight, миграции и первый запуск под `root`
Выполняйте gates **строго по порядку** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9.
Не включайте `han-processing.service` и не делайте `systemctl enable`, пока
Gate 5 не завершился успешно.
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями `root`.
После Gate 5 штатный restart/status/logs для `deploy` — см. блок
«Дальнейшие штатные операции» ниже.
### Gate 1 — секреты материализованы
Под `root` на VM2:
```sh
systemctl restart han-secrets-vm2.service
systemctl is-active han-secrets-vm2.service
journalctl --no-pager -u han-secrets-vm2.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
```
Ожидается `active`; журнал не содержит значений секретов; последняя команда
показывает только имена всех ключей из mapping. Не выполняйте `cat` файлов
секретов и не вставляйте реальные значения в terminal history.
### Gate 2 — статический preflight и Compose
Под `root` на VM2:
```sh
cd /opt/han-chat/services
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose config --services
/usr/local/sbin/han-vm2-compose config --images
```
Все команды должны завершиться с кодом `0`. В списке services нет PostgreSQL,
а все production images содержат `@sha256:`. Вывод полного resolved Compose в
файл не сохраняйте.
### Gate 3 — миграции и активный Message Safety config
Под `root`. Перед первым `bitrix-sync-migrate` владелец `han_app` или
администратор БД выдаёт Bitrix migration-role временный read-only доступ к
legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO <BITRIX_SYNC_MIGRATION_ROLE>;
```
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
других согласованных операций:
```sql
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
```
Проверьте head revision и активную config version:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
message-safety-migrate current
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
bitrix-sync-migrate current
```
Ожидается по одной head revision каждого сервиса. `create --version 1`
выполняется только при первом развёртывании. Для следующего конфига используйте
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
```sh
NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose --profile ops pull \
message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
validate /app/app/artifacts/seed-config.yaml
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml \
--version "$NEXT_VERSION" --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
unset NEXT_VERSION
```
Старый image, schema которого ограничивает поле значением `168`, нельзя
оставлять после активации значения `240`: сначала обновите
`MESSAGE_SAFETY_IMAGE` на новый digest и проверьте `config --quiet`.
### Gate 4 — конфигурация nginx до запуска
После выпуска public TLS в `/etc/letsencrypt` и материализации internal TLS
secrets. В Selectel значения `VM2_INTERNAL_TLS_CERTIFICATE` и
`VM2_INTERNAL_TLS_PRIVATE_KEY` сохраняются как исходный PEM с настоящими
переводами строк, не как повторный base64 и не как строка с литералами `\n`.
После изменения remote secret перезапустите `han-secrets-vm2.service`; preflight
проверит формат PEM и соответствие certificate/key без вывода их содержимого:
```sh
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose run --rm --no-deps \
-e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \
-e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \
nginx \
nginx -t -c /etc/nginx/nginx.conf
```
Базовый `nginx.conf` подключает обязательный
`/etc/nginx/conf.d/10-vm2.conf`, поэтому команда завершится ошибкой, если
entrypoint не создал конфигурацию из шаблона. Временные значения upstream
нужны только для проверки до первого запуска backend-контейнеров; production
Compose подставляет DNS-имена сервисов. Ожидается `syntax is ok` и `test is
successful`; ошибок `conf.d is not writable` и предупреждения о превышении
open-file limit быть не должно. Ошибка отсутствующего сертификата является
блокером, а не основанием временно убрать TLS.
### Gate 5 — упорядоченный первый запуск
Под `root` на VM2:
```sh
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation
/usr/local/sbin/han-vm2-compose up -d nginx
/usr/local/sbin/han-vm2-compose ps
```
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
сам Collector стартует только после этого.
Healthcheck nginx использует встроенный `nginx -t`: утверждённый
`nginx-unprivileged` image не содержит `wget`/`curl`. `ExitCode 127` с
сообщением `wget: not found` означает, что на VM2 остался старый Compose.
Если запуск выполнялся со старым релизом и Message Safety уже попал в
permission/restart loop, после активации исправленного релиза под `root`
восстановите контракт файла и пересоздайте затронутые контейнеры:
```sh
getent group 10001 >/dev/null ||
groupadd --system --gid 10001 han-message-safety
test "$(getent group han-message-safety | cut -d: -f3)" = 10001
chown root:han-message-safety /etc/han-chat/message-safety-mode.env
chmod 0640 /etc/han-chat/message-safety-mode.env
install -m 0755 -o root -g root \
/opt/han-chat/services/deployment/han-message-safety-mode \
/usr/local/sbin/han-message-safety-mode
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
otel-queue-init otel-collector
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps
```
Не заменяйте это на `chmod 0644/0666`, запуск контейнеров от root или
рекурсивный `chown` Docker volumes. Если после восстановления прав nginx
остаётся в `Restarting`, это отдельная ошибка конфигурации/TLS, а не права
Message Safety; проверьте её без вывода секретов:
```sh
/usr/local/sbin/han-vm2-compose logs --tail 100 nginx otel-collector
```
Дождитесь `healthy` у сервисов с healthcheck. Не продолжайте при
`Restarting`, `unhealthy`, OOM или неожиданном `Exited`. После успешного
первого запуска передайте дальнейший lifecycle systemd:
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
journalctl --no-pager -u han-processing.service
```
Дальнейшие штатные операции может выполнить `deploy`:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
### Gate 6 — host ports и сертификаты
Под `root` на VM2:
```sh
ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]'
/usr/local/sbin/han-vm2-compose ps --format json | jq .
```
Ожидаются host listeners только nginx: public `80`, `443` и private-bound
`8443` на `PROCESSING_PRIVATE_BIND_ADDRESS`. `6379`, container `8080` и OTLP
`4317/4318` на host отсутствуют.
С доверенной рабочей станции проверьте public chain:
```sh
openssl s_client -connect <PROCESSING_PUBLIC_HOST>:443 \
-servername <PROCESSING_PUBLIC_HOST> \
-verify_hostname <PROCESSING_PUBLIC_HOST> -verify_return_error </dev/null
```
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
```sh
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile <INTERNAL_CA_FILE> -verify_return_error </dev/null
```
Обе команды должны завершить certificate verification без ошибки.
Переключите renewal с первоначального `standalone` на webroot, который nginx
обслуживает по `/.well-known/acme-challenge/`. `certbot reconfigure` сам
проверит новый способ через staging CA:
```sh
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
certbot reconfigure \
--cert-name "$PUBLIC_HOST" \
--authenticator webroot \
--webroot-path /var/lib/han-chat/acme
```
Повторный setup после активации релиза устанавливает deploy-hook
`/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`: после успешного
обновления он атомарно размещает certificate/key с группой `han-nginx-tls` в
`/var/lib/han-chat/public-tls`, проверяет конфигурацию nginx и отправляет
контейнеру `HUP`.
Проверьте полный цикл и включите штатное расписание Certbot:
```sh
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl --no-pager status certbot.timer
systemctl list-timers certbot.timer
```
`certbot.timer` проверяет необходимость продления дважды в сутки; сертификат
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
блокер. Итог `Congratulations, all simulated renewals succeeded` означает
успешный dry-run. Старый hook мог при этом дать ложное
`Hook 'deploy-hook' ran with error output`: Compose писал `Killing/Killed`, а
успешный `nginx -t``syntax is ok` в stderr. Исправленный hook показывает
вывод config test только при ненулевом exit code и использует тихий
`docker kill --signal HUP`. Порт `80` после этого остаётся доступен для
HTTP-01 renewal.
### Gate 7 — public routing
С внешней тестовой машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
```
Ожидаемые коды по порядку: `308`, `404`, `426`, `404`, `405`. Для HTTPS
используйте только валидный public certificate, без `-k`.
POST к webhook с адреса вне Bitrix allow-list должен получить `403`; если
cloud firewall настроен на drop, допустим timeout. Затем повторите с
разрешённого source IP и заведомо неверным receiver token: upstream должен
ответить `403`, не `2xx`.
### Gate 8 — private API только с VM1/ops
Следующие команды выполняются **на VM1** или approved ops host, не на VM2.
Используйте private DNS/SAN и внутреннюю CA.
Safety status:
```sh
curl --fail --silent --show-error \
--cacert <INTERNAL_CA_FILE> \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
```
Benign text check через тот же private listener:
```sh
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "<INTERNAL_CA_FILE>"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
В standard mode ожидается `HTTP 200`, `verdict=allow` и непустые
`config_version`/`rules_version`. Проверку `202 → Location → task GET`
выполняйте отдельным file smoke только с реальным versioned quarantine object:
выдуманные S3 key/version/ETag не являются валидным тестом.
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в
переменную и передайте curl config через stdin, чтобы значение не попало в
argv/history:
```sh
BITRIX_TOKEN="$(cat <BITRIX_SYNC_SERVICE_TOKEN_FILE_ON_VM1>)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
--write-out '%{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/sync/v1/status"
cacert = "<INTERNAL_CA_FILE>"
header = "Authorization: Bearer ${BITRIX_TOKEN}"
EOF
unset BITRIX_TOKEN
cat /tmp/vm2-sync-status.json
rm -f /tmp/vm2-sync-status.json
```
При `BITRIX_SYNC_ENABLED=false` ожидается закрытая/неготовая синхронизация, а
не ложный успешный full-mode status. С машины вне `VM1_PRIVATE_CIDRS` и
необязательных приватных/VPN-сетей `OPS_CIDRS` подключение к `8443` должно
завершиться timeout/reject.
### Gate 9 — canary на отсутствие секретов в логах и traces
Создайте **фейковый**, не production token marker и отправьте его с
разрешённого тестового source IP:
```sh
CANARY="HAN_VM2_REDACTION_$(date +%s)"
curl -sS -o /dev/null \
-H "Authorization: Bearer ${CANARY}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "auth[application_token]=${CANARY}" \
"https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact?token=${CANARY}"
```
На VM2 под `root`:
```sh
CANARY='<ЗНАЧЕНИЕ_CANARY_С_ТЕСТОВОЙ_МАШИНЫ>'
if /usr/local/sbin/han-vm2-compose logs --no-color \
nginx bitrix-sync message-safety-api otel-collector |
grep -F -- "$CANARY"; then
echo 'FAIL: canary попал в логи' >&2
exit 1
fi
unset CANARY
```
В SigNoz выполните поиск этого же marker по logs и span attributes за окно
теста: результат должен быть пустым. Отдельными фейковыми markers повторите
проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для
такой проверки не используйте.
Не открывайте webhook-трафик, пока `bitrix-sync` отключён. Отключённый или
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## 7. После Gate 9 — post-acceptance
Gate 9 завершает техническую приёмку VM2, но **не** означает production
cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync.
Дальнейший порядок:
1. Проверить автозапуск:
```sh
systemctl is-enabled \
docker.service \
han-chat-vm2-docker-firewall.service \
han-secrets-vm2.service \
han-processing.service \
certbot.timer
systemctl is-active \
docker.service \
han-chat-vm2-docker-firewall.service \
han-processing.service \
certbot.timer
/usr/local/sbin/han-vm2-compose ps
```
2. Провести reboot-gate. Только после проверки отдельного входа `admin` и доступа к консоли Selectel:
```sh
systemctl reboot
```
После переподключения повторить команды выше и кратко Gate 68: HTTPS, firewall, private Safety API.
3. Зафиксировать итог релиза:
```sh
/usr/local/sbin/han-vm2-compose config --images
/usr/local/sbin/han-vm2-compose ps
systemctl list-timers certbot.timer
journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
```
Сохранить версии образов, дату приёмки и результаты gates без значений секретов.
4. Настроить эксплуатационный мониторинг:
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
- недоступность private Safety API.
5. Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки.
6. `bitrix-sync` пока оставить:
```dotenv
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
```
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после
выполнения gates [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md):
поля портала, webhooks, migrations, grants, backfill/watermark и rollback
rehearsal.
После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте
приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам
по себе не разрешает переключать caller.
### Controlled cutover Message Safety на VM1
Cutover выполняется в согласованное окно совместно с Safety Service,
Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и
предыдущий schema-compatible immutable release VM1, ответственного за rollback
и stop conditions. `bitrix-sync` в это окно не включается.
#### 1. Предварительные условия
До изменения VM1 должны быть выполнены все условия:
- reboot-gate VM2 и повторные Gates 6–8 успешны;
- Safety работает в `standard`, не в `mock`;
- active config и rules bundle утверждены, Safety v2 migrations находятся на
ожидаемом head;
- `text`, `links`, `files`, `worker` имеют состояние `ready`;
- VM2 имеет только read-only доступ к versioned S3 quarantine objects;
- performance, egress negative tests и redaction Gate 9 закрыты;
- private DNS VM2 резолвится с VM1 только в private VPC address;
- security group/firewall разрешает `8443` от VM1 и approved ops, но не из
интернета;
- на VM1 подготовлен release без local `message-safety`, Redis DB2, local rules
env и stub fallback;
- один и тот же production service token подготовлен в раздельных secret
bundles VM1 и VM2; значение токена не печатается и не копируется в
`/etc/han/vm1.env`.
На VM1 под `root` повторите проверку TLS и readiness:
```sh
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile /etc/han/ca/vm2-internal-ca.pem \
-verify_return_error </dev/null
curl --fail --silent --show-error \
--cacert /etc/han/ca/vm2-internal-ca.pem \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
```
В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются
`processing_mode=standard`, непустой `config_version` и
`text|links|files|worker=ready`. `stub`, `mock`, `not_ready` или недоступная
capability — stop condition.
#### 2. Прямой pre-cutover smoke с VM1
Прямой smoke доказывает route, CA и paired token до перезапуска caller:
```sh
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "/etc/han/ca/vm2-internal-ca.pem"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "X-Request-ID: ${MESSAGE_ID}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM1 to VM2 cutover canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
Ожидается `HTTP 200`, `verdict=allow`, `processing_mode=standard` и непустые
`config_version`/`rules_version`. Отдельный запрос с фейковым token marker
должен вернуть `401`; production token для negative test не изменяйте.
До переключения также выполните через API VM2:
- deny smoke на утверждённом безопасном corpus case — ожидается `403`;
- повтор запроса с тем же `message_id` и тем же body — тот же sticky result;
- тот же `message_id` с другим body — `409`;
- file smoke только с реальным versioned quarantine object:
`202 + Location + Retry-After`, затем terminal `200` или `403`;
- lease/fencing smoke с остановкой/возвратом worker по утверждённому test case:
task не исполняется двумя владельцами и сохраняет sticky terminal result.
Не используйте выдуманные S3 key/version/ETag и не загружайте EICAR в
production bucket вне согласованного security test.
#### 3. Переключение caller на VM1
На VM1 установите и проверьте internal CA по процедуре
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
§6. В `/etc/han/vm1.env` должны быть:
```dotenv
MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS_NAME>:8443
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
```
В root-owned Selectel secret mapping VM1 переменная
`MESSAGE_SAFETY_SERVICE_TOKEN` должна ссылаться на согласованный remote secret.
После review config и mapping:
```sh
cd /opt/han-chat/current/backend
./scripts/validate-env /etc/han/vm1.env
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
./deployment/preflight.sh
/usr/local/sbin/han-vm1-compose config --quiet
/usr/local/sbin/han-vm1-compose config --services
/usr/local/sbin/han-vm1-compose config --images
```
В `config --services` не должно быть local `message-safety`, `bitrix-sync`,
Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите
secret values. Если preflight успешен, переключите caller:
```sh
systemctl restart han-stack@production.service
systemctl is-active han-stack@production.service
/usr/local/sbin/han-vm1-compose ps
```
#### 4. Post-cutover проверки через caller
Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к
VM2:
1. benign text проходит Safety и отправляется ровно один раз;
2. утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic
`message_blocked` без internal `rule_id`;
3. сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая
private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки;
4. реальный quarantine file проходит `pending` и terminal result, после allow
продвигается штатным caller flow; deny-файл не продвигается;
5. повтор client/idempotency request не создаёт второе сообщение или второй
Safety task;
6. correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения,
token, object key и других секретов.
Затем согласованным способом кратко сделайте VM2 недоступной **только для
test request** и подтвердите fail-closed: VM1 не отправляет и не продвигает
контент, возвращает контролируемую retryable ошибку, а local/stub fallback не
активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте
отказ остановкой всей VM2, если на ней уже есть другой production traffic.
#### 5. Rollback rehearsal
Rollback caller — только на заранее проверенный предыдущий
schema-compatible immutable release VM1 по
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
§12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные
Safety tasks:
```sh
PREVIOUS='<PREVIOUS_COMPATIBLE_GIT_SHA>'
test -d "/opt/han-chat/releases/${PREVIOUS}/backend"
ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
systemctl daemon-reload
systemctl restart han-secrets@production.service
/opt/han-chat/current/backend/deployment/preflight.sh
systemctl restart han-stack@production.service
```
После rehearsal повторите public smoke, верните approved current release тем же
атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open,
переключение на local stub или обход Safety. При несовместимой migration
rollback запрещён: используйте forward fix либо заранее согласованный recovery
plan.
#### 6. Фиксация cutover
Сохраните без secret values:
- VM1/VM2 release и image digests, Safety schema head;
- private certificate fingerprint/expiry, `config_version` и `rules_version`;
- результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks;
- firewall counters и доказательство недоступности `8443` с запрещённого
source;
- traces/log search и результат redaction canary;
- фактическое время переключения и rollback rehearsal;
- approvals Safety Service, Rule Pack, Security, Product и Operations.
Только после успешного выполнения всех пунктов Message Safety cutover считается
завершённым. Cutover `bitrix-sync` остаётся отдельным изменением по
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md).
## Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.