ci: изолировать пайплайны на отдельной VM

- Woodpecker workflows требуют агент с меткой isolation=dedicated-vm
- шаги выполняются в lms-ci:1 с nested dockerd, без хост-Docker сокета
- ci/Dockerfile: прогретый immutable-образ (Java, Python, Node, Gradle-кэш)
- ci-dind.sh поднимает dockerd внутри privileged-шага; ci-build-image.sh собирает образ
- deploy/WOODPECKER.md: отдельная VM, firewall, отсутствие production-секретов,
  trusted repo, чек-лист безопасности
This commit is contained in:
2026-10-28 22:50:23 +03:00
parent 685916c4bd
commit f3d614bb33
15 changed files with 287 additions and 137 deletions

View File

@@ -1,54 +1,70 @@
# CI на Gitea + Woodpecker
# CI на Gitea + Woodpecker: изолированный контур
Репозиторий использует **Woodpecker CI 3.x**. Конфигурация лежит в
`.woodpecker/`:
Репозиторий использует **Woodpecker CI 3.x**. Конфигурация — в `.woodpecker/`:
| Workflow | Событие | Что делает |
|---|---|---|
| `check` | push, pull_request | `ci-preflight.sh`, `test ktlintCheck detekt evidenceCheck` |
| `check` | push, pull_request | тесты, ktlint, detekt, `evidenceCheck` |
| `smoke` | push, pull_request | после `check`: полный стек, health, вход админа, один курс |
| `release` | tag, manual | `npm ci`, preflight, полный `ldd-release.sh` (миграции, mutation, load, Chromium/Firefox/WebKit) |
| `release` | tag, manual | миграции, mutation, load, Chromium/Firefox/WebKit E2E, финальный evidence |
Все три workflow используют `concurrency: {limit: 1, group: lms-ci}`: smoke и
release поднимают реальный стек на портах 8080–8090, параллельный запуск занял
бы порты.
## 1. Модель изоляции
## Почему local backend
CI **не должен работать на хосте LMS и не должен иметь доступ к production**.
Разработчики не считаются доверенными: пайплайн может содержать произвольный
код, включая `privileged`-контейнеры.
`check` может работать в Docker-бэкенде, но `smoke`/`release` запускают
`docker compose` и Java-процессы на `localhost`, поэтому им нужен доступ к
Docker и портам хоста. Проще всего один агент с **local backend** на том же
сервере, где стоит Docker.
Схема:
> **Безопасность.** Local backend выполняет пайплайн прямо на хосте без
> изоляции. Он допустим только для **приватного** репозитория с доверенными
> авторами. Не включайте публичные форки/PR от недоверенных пользователей на
> этом агенте: код пайплайна сможет прочитать окружение агента. Если появится
> публичный контур — вынесите `check` на отдельный Docker-backend агент, а
> smoke/release оставьте на изолированной машине.
## 1. Требования к серверу агента
- Java 21 в `PATH` (не Java 25 — см. README).
- Docker Engine + Compose v2, пользователь агента в группе `docker`.
- Python 3, bash, git, curl, `ss` (`iproute2`), Node.js 22 + npm.
- Системные библиотеки для Chromium/Firefox/WebKit. На Fedora проще один раз
выполнить `sudo npx playwright install-deps`; WebKit при их отсутствии
автоматически запускается в Docker-образе Playwright.
- Свободные порты 8080–8090 на время smoke/release.
- Достаточно места: Gradle-кэш, Docker-образы, Playwright-браузеры.
Проверка окружения:
```bash
./scripts/ci-preflight.sh # для smoke/check
./scripts/ci-preflight.sh --release
```
Gitea + Woodpecker server отдельная CI VM
┌───────────────────────┐ ┌──────────────────────────────┐
│ Gitea (production) │ webhook │ Woodpecker agent (Docker) │
│ Woodpecker server │◄────────►│ └─ pipeline step │
│ │ gRPC │ └─ nested dockerd (dind)│
└───────────────────────┘ │ ├─ postgres │
│ ├─ LMS-сервисы │
│ └─ Playwright │
└──────────────────────────────┘
нет prod-сети и секретов
```
## 2. Woodpecker server
Правила:
Удобно запустить server в Docker рядом с Gitea. Пример
`docker-compose.woodpecker.yml` (не коммитьте секреты):
- **Отдельная VM** (или отдельный физический хост) для агента. Не запускайте
агент на сервере LMS/Gitea.
- На VM **нет** production-БД, production-томов, deploy-ключей и
`LMS_INTERNAL_TOKEN`/паролей production. Только одноразовые тестовые значения,
которые создаёт сам пайплайн.
- Сетевой firewall: VM может ходить в Gitea/Woodpecker и к registry/пакетным
зеркалам; **не** может в production-подсеть и к production-БД.
- VM должна пересоздаваться из чистого снапшота после релиза или по расписанию.
Всё, что осталось на диске, считается недоверенным.
- Пайплайн выполняется в образе `lms-ci:1` (на базе `docker:27-dind`) с
**собственным nested dockerd**.
Хост-Docker сокет в шаг не монтируется. `privileged` нужен только этому
вложенному демону; на отдельной VM это допустимо.
- Агент помечен `isolation=dedicated-vm`; workflows требуют эту метку. Это
не даёт им случайно попасть на агент, стоящий рядом с production.
- В Woodpecker не должно быть production-secrets. Если позже понадобится
деплой — отдельный защищённый агент/пайплайн с ручным подтверждением, не
этот.
> Важно: `privileged`-шаг может скомпрометировать **VM**, но не production.
> Именно поэтому VM обязательна. Не ослабляйте это правило.
## 2. Требования к CI VM
- Docker Engine + Compose v2.
- 4+ CPU, 8+ GB RAM, 40+ GB диска (Gradle, Docker-образы, браузеры).
- Исходящий доступ к Gitea, registry и пакетным зеркалам.
- Нет входящих портов, кроме SSH для администрирования.
- Нет монтирования production-томов и нет production-сетей.
## 3. Woodpecker server
Server можно держать рядом с Gitea (он не выполняет код пайплайнов). Пример
`docker-compose.woodpecker.yml` (секреты не коммитить):
```yaml
services:
@@ -84,81 +100,105 @@ volumes:
[webhook]
ALLOWED_HOST_LIST = external,loopback
```
Также проверьте, что Gitea отдаёт API-страницы не меньше 50 элементов
(значение по умолчанию подходит).
Проверьте, что Gitea отдаёт API-страницы не меньше 50 элементов.
4. Поднимите server и зайдите в UI через Gitea-аккаунт.
5. Включите репозиторий LMS в Woodpecker (Settings → Repositories).
6. В настройках репозитория включите **Trusted** (нужно для `privileged` и
volumes). Доверять можно только этому приватному репозиторию.
## 3. Агент на хосте
## 4. Агент на отдельной VM
Server в контейнере, агент — **на хосте**, иначе local backend не увидит
Java/Docker/Node хоста.
Агент — в Docker, backend `docker`, с доступом к Docker daemon **этой VM**.
Шаги пайплайна получают не хостовый сокет, а собственный nested dockerd.
```bash
sudo useradd --system --create-home --shell /bin/bash woodpecker
sudo usermod -aG docker woodpecker
sudo install -m 0755 woodpecker-agent /usr/local/bin/woodpecker-agent
```
`docker-compose.agent.yml` на CI VM:
`woodpecker-agent` скачивается из релизов Woodpecker (Linux amd64):
`https://github.com/woodpecker-ci/woodpecker/releases`.
`/etc/woodpecker-agent.env` (права `0600`, владелец `root`):
```dotenv
WOODPECKER_SERVER=localhost:9000
WOODPECKER_AGENT_SECRET=тот-же-секрет-что-у-server
WOODPECKER_BACKEND=local
WOODPECKER_MAX_WORKFLOWS=1
# Кэш Gradle и браузеров Playwright переживает запуски. Допустимо только для
# приватного репозитория с доверенными авторами (см. предупреждение выше).
WOODPECKER_BACKEND_LOCAL_ISOLATED_HOME=false
```
`/etc/systemd/system/woodpecker-agent.service`:
```ini
[Unit]
Description=Woodpecker CI agent (LMS)
After=network-online.target docker.service
Wants=network-online.target
[Service]
User=woodpecker
EnvironmentFile=/etc/woodpecker-agent.env
ExecStart=/usr/local/bin/woodpecker-agent
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```yaml
services:
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
environment:
WOODPECKER_SERVER: ${WOODPECKER_SERVER:?https://ci.example.ru:9000}
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:?}
WOODPECKER_BACKEND: docker
WOODPECKER_MAX_WORKFLOWS: "1"
# Только этот агент должен брать workflows с этой меткой.
WOODPECKER_AGENT_LABELS: isolation=dedicated-vm
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now woodpecker-agent
journalctl -u woodpecker-agent -f
docker compose -f docker-compose.agent.yml up -d
docker compose -f docker-compose.agent.yml logs -f
```
В UI агент появится через несколько секунд. Репозиторий должен быть помечен
как trusted, если вы захотите использовать volumes/privileged (текущему
конфигу они не нужны).
Дополнительно в UI откройте **Settings → Agents** и задайте агенту фильтр
`isolation=dedicated-vm` — это серверная проверка, которую агент не может
подделать.
## 4. Первый прогон
**Общего кэша между пайплайнами нет.** Каждый шаг получает чистый контейнер,
а вложенный Docker storage живёт только внутри него. Это важно: разработчик не
может оставить артефакт, который повлияет на следующий pipeline или релиз.
1. Push в ветку или MR → pipeline `check` → `smoke`.
2. Убедиться, что зелёные оба workflow.
3. Проверить ручной запуск: UI → Run pipeline → event `manual` → workflow
Пайплайны используют предварительно прогретый образ `lms-ci:1`. Соберите его
на VM из **проверенного администратором** коммита до первого запуска:
```bash
git checkout <проверенный-коммит>
./scripts/ci-build-image.sh lms-ci:1
```
Сборка образа выполняет Gradle-код из репозитория. Не собирайте образ из
непроверенной ветки/PR: это запуск недоверенного кода на CI VM.
Образ содержит Docker-in-Docker, Java 21, Python, bash, Node и прогретый
Gradle-кэш; исходники всё равно берутся из checkout. Пересобирайте образ при
изменении зависимостей или базовых версий. Не монтируйте в шаги общие
writable-кэши: это канал влияния одного пайплайна на другой.
## 5. Как устроены workflows
- `check`, `smoke` и `release` требуют `labels.isolation=dedicated-vm`.
- `smoke` ждёт успешного `check` (`depends_on`).
- Все workflows имеют `concurrency: {limit: 1, group: lms-ci}`, потому что
smoke/release поднимают стек на портах 8080–8090.
- `check` и `smoke`/`release` используют `lms-ci:1` + `./scripts/ci-dind.sh`:
скрипт поднимает **вложенный** dockerd. Образ собирается из `ci/Dockerfile`.
- `release` перед миграционным тестом делает `git fetch` master: CI-клон может
быть shallow.
- `smoke`/`release` не получают хост-Docker сокет; `docker compose` работает
с вложенным демоном.
## 6. Первый прогон
0. На CI VM соберите образ: `./scripts/ci-build-image.sh lms-ci:1`.
1. Push в ветку или MR → `check`, затем `smoke`.
2. Убедитесь, что оба workflow зелёные и выполнялись на агенте с меткой
`isolation=dedicated-vm`.
3. Проверьте ручной запуск: UI → Run pipeline → event `manual` → workflow
`release`.
4. Для релиза: `git tag vX.Y.Z && git push origin vX.Y.Z` → workflow
`release`; полный отчёт печатается в лог, файлы остаются в
`build/evidence/` рабочей копии агента.
4. Для релиза: `git tag vX.Y.Z && git push origin vX.Y.Z` → `release`.
5. После релиза/по расписанию пересоздайте VM из чистого снапшота.
Если pipeline не появляется:
- проверьте, что репозиторий включён в Woodpecker;
- проверьте `WOODPECKER_SERVER` и общий `WOODPECKER_AGENT_SECRET`;
- проверьте, что агент видит Docker (`docker compose version` от пользователя
`woodpecker`) и Java 21;
- проверьте, что путь конфигурации в настройках проекта — `.woodpecker/`
(по умолчанию).
- репозиторий включён в Woodpecker;
- у агента и server совпадает `WOODPECKER_AGENT_SECRET`;
- агент видит Docker: `docker compose version` на VM;
- метка агента `isolation=dedicated-vm` и серверный фильтр совпадают;
- путь конфигурации проекта — `.woodpecker/` (по умолчанию);
- образ `lms-ci:1` собран на VM (иначе шаг не стартует);
- репозиторий помечен trusted, иначе `privileged` будет отклонён.
## 7. Чек-лист безопасности
- [ ] Агент не на production-хосте.
- [ ] На VM нет production-секретов, БД и томов.
- [ ] Firewall запрещает VM → production.
- [ ] В Woodpecker нет production-secrets.
- [ ] Включён trusted только для LMS, не для чужих репозиториев.
- [ ] `WOODPECKER_AGENT_SECRET` уникален и хранится вне репозитория.
- [ ] VM пересоздаётся из снапшота; кэш не считается доверенным.
- [ ] `privileged` разрешён только на этой VM, не на общем runner.