developer(ET): auto-commit from developer run_id=627
This commit is contained in:
@@ -210,9 +210,12 @@ sidecar читает только `.env.watchdog`; C-1 ORCH-100 — отдель
|
||||
(docs+tests). Подробнее: [adr-0037](adr/adr-0037-lite-replication-canon.md), детально —
|
||||
`docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`.
|
||||
|
||||
**Type B — Bundled (ORCH-103 — design).** Закрывает эпик ORCH-10: весь стек одним комплектом
|
||||
**Type B — Bundled (ORCH-103).** Закрывает эпик ORCH-10: весь стек одним комплектом
|
||||
(орк + watchdog + Gitea + Plane CE ≈13–14 контейнеров) для заказчика без собственной
|
||||
инфраструктуры. Новый top-level каталог **`deploy/`** (исполняемые дистрибутивы; дополняет
|
||||
инфраструктуры. Состав Plane — зеркало официального selfhost-référence v0.23.1
|
||||
(upstream-имена сервисов web/space/admin/api/worker/beat-worker/migrator/live +
|
||||
plane-db/plane-redis/plane-mq/plane-minio/proxy); Gitea — `gitea/gitea:1.22.6`
|
||||
(не rootless, ssh выключен). Новый top-level каталог **`deploy/`** (исполняемые дистрибутивы; дополняет
|
||||
`docs/deployment/` — инструкции): `deploy/bundled/docker-compose.yml` — один самодостаточный
|
||||
compose с `name: orchestrator-bundle` (узнаваемый префикс томов/контейнеров; `container_name`
|
||||
не пиннится — нет коллизий с корневым compose на одном хосте), пиннинг сторонних образов
|
||||
|
||||
436
docs/deployment/BUNDLED_SETUP.md
Normal file
436
docs/deployment/BUNDLED_SETUP.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# BUNDLED_SETUP — Bundled-тираж: весь стек одним комплектом (ORCH-103)
|
||||
|
||||
> **Golden source Bundled-тиража (Type B эпика ORCH-10).** Маршрут «чистый хост →
|
||||
> работающий конвейер» для заказчика **без собственной инфраструктуры**: один
|
||||
> compose-комплект (`deploy/bundled/docker-compose.yml`) поднимает оркестратор +
|
||||
> watchdog + Gitea + Plane CE, один запуск `scripts/bootstrap_bundle.py apply`
|
||||
> доводит стек до рабочего состояния. Каждый шаг — исполняемая команда + явная
|
||||
> проверка результата (**Проверка:** / PASS / FAIL). Хост-специфика — только
|
||||
> плейсхолдеры `<...>` и `$ENV_VAR`. Тираж **stateless**: данные/задачи/секреты
|
||||
> боевого (исходного) хоста **НЕ переносятся** ни на одном шаге (§12).
|
||||
> Границы слоёв тиража (10-common vs Lite vs Bundled) — `docs/operations/REPLICATION.md` §1;
|
||||
> канон Lite (своя инфраструктура Plane/Gitea) — `docs/deployment/LITE_SETUP.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Рамка Bundled
|
||||
|
||||
**Что входит в комплект** (compose-проект `orchestrator-bundle`, одна bridge-сеть):
|
||||
- `orchestrator` (конвейер, образ собирается из этого чекаута) и
|
||||
`orchestrator-watchdog` (независимый sidecar-мониторинг);
|
||||
- **Gitea** (git-хостинг, пиннованный официальный образ);
|
||||
- **Plane CE — ≈ 14 контейнеров** (зеркало официального selfhost-комплекта:
|
||||
web/space/admin/api/worker/beat-worker/migrator/live + postgres/redis/
|
||||
rabbitmq/minio/proxy) — это **ресурсоёмко**, см. §2.
|
||||
|
||||
**Что НЕ входит** (внешние предусловия заказчика):
|
||||
- **Claude CLI / LLM-доступ** — дистрибутив claude-code, node и аутентификация
|
||||
живут на хосте и пробрасываются маунтами (§8); без них стек поднимется, но
|
||||
конвейер не поедет;
|
||||
- **Telegram-боты** — опциональны (§9): пусто = деградация только нотификаций;
|
||||
- **HTTPS/домены/reverse-proxy** — вне bundle: наружу публикуются три http-порта
|
||||
(§2), терминирование TLS — средствами заказчика.
|
||||
|
||||
**Bundled vs Lite:** Lite (`LITE_SETUP.md`) подключает оркестратор к **вашим**
|
||||
Plane/Gitea; Bundled везёт их **с собой** на чистых томах. Staging-контур орка в
|
||||
bundle отсутствует вовсе: заказчик Type B эксплуатирует платформу для своих
|
||||
проектов, а не развивает её self-hosting'ом (нужен self-hosting — маршрут Lite,
|
||||
`LITE_SETUP.md` §9.3). Репо `orchestrator` в bundle-инсталляции **не
|
||||
регистрируется** как проект.
|
||||
|
||||
**Осознанный компромисс (TR-7):** git-доступ агентов — HTTP token-remote
|
||||
(токен бот-юзера в конфиге локальных чекаутов, права 600); ssh-контур
|
||||
сознательно не вводится; порты БД/брокера/minio наружу не публикуются.
|
||||
|
||||
---
|
||||
|
||||
## 2. Требования к хосту
|
||||
|
||||
Linux x86_64, один хост. Минимумы проверяет preflight bootstrap **до любых
|
||||
мутаций** (пороги — константы `scripts/bootstrap_bundle.py`, ниже — те же цифры;
|
||||
подтверждаются замером приёмочного развёртывания):
|
||||
|
||||
| Ресурс | Минимум | Почему |
|
||||
|--------|---------|--------|
|
||||
| RAM | **8 GB** | Plane CE — ≈ 14 контейнеров (миграции и брокер прожорливы) |
|
||||
| Диск | **40 GB** свободно | образы стека + тома postgres/minio/gitea + данные орка |
|
||||
| CPU | **4 vCPU** (рекомендация) | меньше — стек поднимется, но будет медленным |
|
||||
|
||||
**Карта публикуемых портов** (дефолты; конфигурируемы в
|
||||
`deploy/bundled/.env`, ключи `BUNDLE_*`):
|
||||
|
||||
| Порт | Ключ | Сервис |
|
||||
|------|------|--------|
|
||||
| 8500 | `BUNDLE_ORCH_PORT` | API оркестратора (`/health`, `/queue`, `/metrics`, вебхуки) |
|
||||
| 8080 | `BUNDLE_PLANE_PORT` | Plane UI (proxy) |
|
||||
| 3000 | `BUNDLE_GITEA_HTTP_PORT` | Gitea web/API |
|
||||
|
||||
Postgres/redis/rabbitmq/minio наружу **не публикуются** (машинный трафик —
|
||||
внутрисетевой сервис-DNS).
|
||||
|
||||
```bash
|
||||
free -g # RAM ≥ 8 GB
|
||||
df -h . # свободно ≥ 40 GB
|
||||
nproc # ≥ 4
|
||||
ss -ltn | grep -E ':(8500|8080|3000)\b' || echo "ports free"
|
||||
```
|
||||
|
||||
**Проверка:** ресурсы не ниже минимумов и `ports free` — PASS. Порт занят →
|
||||
смените соответствующий `BUNDLE_*`-ключ в §5 (или освободите порт) — иначе
|
||||
preflight откажет (FAIL до мутаций, это штатно).
|
||||
|
||||
---
|
||||
|
||||
## 3. Предусловия
|
||||
|
||||
Софт хоста: Docker Engine + Compose v2, git, python3 (+venv), sudo у оператора.
|
||||
|
||||
```bash
|
||||
uname -sm # Linux x86_64
|
||||
docker --version && docker compose version
|
||||
git --version && python3 --version
|
||||
python3 -m venv --help >/dev/null && echo "venv: ok"
|
||||
getent group docker # третье поле — gid, понадобится в §5 (ORCH_DOCKER_GID)
|
||||
id -u && id -g # uid/gid оператора (ORCH_RUN_UID / ORCH_RUN_GID)
|
||||
```
|
||||
|
||||
**Проверка:** все команды отвечают без ошибок, gid группы docker известен —
|
||||
PASS; что-то отсутствует — FAIL (доставьте пакет средствами дистрибутива).
|
||||
|
||||
---
|
||||
|
||||
## 4. Получение кода
|
||||
|
||||
Переносится **только код** — чекаут репо `orchestrator` (норматив §12).
|
||||
|
||||
```bash
|
||||
git clone <ORCHESTRATOR_GIT_URL> <путь-чекаута>
|
||||
cd <путь-чекаута>
|
||||
ls deploy/bundled/docker-compose.yml deploy/bundled/.env.example \
|
||||
scripts/bootstrap_bundle.py scripts/gen_secrets.py scripts/onboard_project.py
|
||||
```
|
||||
|
||||
**Проверка:** все пять файлов на месте — PASS. Канал дистрибуции
|
||||
(`<ORCHESTRATOR_GIT_URL>`) согласуйте с поставщиком платформы (как в
|
||||
`LITE_SETUP.md` §3).
|
||||
|
||||
---
|
||||
|
||||
## 5. Секреты
|
||||
|
||||
Все секреты инсталляции выпускаются **заново на месте** (§12): webhook-секреты —
|
||||
`scripts/gen_secrets.py`, внутренние креды Plane/Gitea-стека — генерирует
|
||||
bootstrap (в репо — только пустые плейсхолдеры, ни одного дефолтного пароля).
|
||||
|
||||
**5.1. Конфиг bundle-инфры.**
|
||||
|
||||
```bash
|
||||
cd <путь-чекаута>
|
||||
cp deploy/bundled/.env.example deploy/bundled/.env
|
||||
chmod 600 deploy/bundled/.env
|
||||
# заполнить НЕсекретные ключи: BUNDLE_PUBLIC_HOST (IP/имя хоста для браузера),
|
||||
# карту портов BUNDLE_* (§2), ORCH_RUN_UID/ORCH_RUN_GID (из §3),
|
||||
# ORCH_DOCKER_GID (getent group docker, §3), пути Claude CLI (§8).
|
||||
```
|
||||
|
||||
**Проверка:**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml config --quiet && echo "config: PASS"
|
||||
```
|
||||
|
||||
`config: PASS` — интерполяция согласована; ошибка — FAIL (опечатка в
|
||||
`deploy/bundled/.env`).
|
||||
|
||||
**5.2. Секрет-значения** (пустые ключи `deploy/bundled/.env` и корневого `.env`)
|
||||
заполнит `bootstrap_bundle.py apply` (§7): webhook-секреты — субпроцессом
|
||||
`gen_secrets.py`, креды postgres/rabbitmq/minio/`SECRET_KEY` Plane и пароль
|
||||
админ-бота Gitea — stdlib-генератором. Значения **не печатаются** (только имена
|
||||
ключей); повторный запуск **не перетирает** существующие секреты (явная
|
||||
регенерация — флаг `--force-secrets`, допустим только ДО первого запуска стека).
|
||||
|
||||
```bash
|
||||
grep -cE '^(POSTGRES_PASSWORD|SECRET_KEY|RABBITMQ_DEFAULT_PASS|MINIO_ROOT_PASSWORD|GITEA_ADMIN_PASSWORD)=$' \
|
||||
deploy/bundled/.env
|
||||
```
|
||||
|
||||
**Проверка:** до §7 счётчик `5` (пустые плейсхолдеры) — PASS; после §7 — `0`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Запуск bundle-compose
|
||||
|
||||
Одна команда поднимает весь стек (≈ 16 контейнеров; первый запуск тянет образы
|
||||
и гоняет миграции Plane — это минуты, не секунды).
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml up -d
|
||||
docker compose -f deploy/bundled/docker-compose.yml ps
|
||||
```
|
||||
|
||||
**Проверка:** все сервисы в состоянии `Up`/`Up (healthy)`; `migrator` —
|
||||
`Exited (0)` (одноразовая миграция) — PASS. Контейнер в рестарт-цикле — FAIL
|
||||
(§14). Шаг идемпотентен; можно пропустить — `bootstrap_bundle.py apply` выполнит
|
||||
`up -d` сам (§7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Bootstrap
|
||||
|
||||
Доводка «одним запуском»: preflight → секреты → up/готовность → init Gitea
|
||||
(полностью автоматом: админ-бот + API-токен) → init Plane → онбординг
|
||||
sandbox-проекта **строго** кирпичом `onboard_project.py` (22 канонических
|
||||
статуса, включая fail-closed **`Confirm Deploy`** и **`STOP`**, лейблы,
|
||||
репо+webhook — golden source `docs/operations/ONBOARDING.md` §1) → git-доступ
|
||||
агентов → сборка `.env`/`.env.watchdog` → health.
|
||||
|
||||
```bash
|
||||
python3 scripts/bootstrap_bundle.py # план + preflight-диагностика (ноль мутаций)
|
||||
python3 scripts/bootstrap_bundle.py apply # полный прогон
|
||||
```
|
||||
|
||||
**Manual-step чекпоинты Plane CE** (API первичной инициализации в CE нет;
|
||||
каждый чекпоинт: точная инструкция → подтверждение → верификация результата
|
||||
API-пробой, молчаливый пропуск запрещён):
|
||||
1. **instance setup** — открыть Plane UI, зарегистрировать первого
|
||||
пользователя (станет администратором инстанса);
|
||||
2. **workspace** — создать workspace, ввести его slug в bootstrap;
|
||||
3. **API-токен** — Workspace Settings → API tokens, вставить значение в
|
||||
bootstrap (ввод скрыт; уходит в `ORCH_PLANE_API_TOKEN`);
|
||||
4. **workspace-webhook** — bootstrap регистрирует сам (запись в Postgres
|
||||
инсталляции, путь Б канона `LITE_SETUP.md` §5.4) и проверяет; при отказе —
|
||||
честный ручной шаг с той же проверкой;
|
||||
5. **порядок статусов на доске** — drag-and-drop по отчёту onboard
|
||||
(`docs/operations/ONBOARDING.md`).
|
||||
|
||||
Exit-коды: `0` — успех; `2` — остановка на manual-step/предусловии (выполните
|
||||
шаг и перезапустите `apply` — завершённые шаги пропускаются, повторный запуск
|
||||
безопасен); `1` — ошибка. Пароль админ-бота Gitea — ключ `GITEA_ADMIN_PASSWORD`
|
||||
в `deploy/bundled/.env` (права 600; вход в UI Gitea под
|
||||
`GITEA_ADMIN_USERNAME`).
|
||||
|
||||
**Проверка:**
|
||||
|
||||
```bash
|
||||
python3 scripts/bootstrap_bundle.py verify && echo "bootstrap: PASS"
|
||||
```
|
||||
|
||||
`verify` зелёный (health/queue/metrics + onboard verify) — PASS; exit 2 —
|
||||
остались ручные пункты отчёта; exit 1 — FAIL (§14).
|
||||
|
||||
---
|
||||
|
||||
## 8. LLM (claude CLI)
|
||||
|
||||
Канон — `LITE_SETUP.md` §7 (полностью применим; не дублируется). Кратко: на
|
||||
хост ставятся claude-code + node, выполняется интерактивный логин CLI; пути
|
||||
прописываются в `deploy/bundled/.env` (это источники маунтов контейнера орка):
|
||||
`ORCH_HOST_CLAUDE_CODE_DIR`, `ORCH_HOST_NODE_BIN`, `ORCH_HOST_CLAUDE_DIR`,
|
||||
`ORCH_HOST_CLAUDE_JSON`.
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
docker compose -f deploy/bundled/docker-compose.yml exec orchestrator /usr/bin/claude --version
|
||||
```
|
||||
|
||||
**Проверка:** обе команды печатают версию — PASS; вторая падает — пути в
|
||||
`deploy/bundled/.env` не указывают на фактические каталоги хоста (§14.4).
|
||||
|
||||
---
|
||||
|
||||
## 9. Telegram
|
||||
|
||||
Канон — `LITE_SETUP.md` §8 (два независимых бота, C-1: токен орка для watchdog
|
||||
переиспользовать запрещено). Ключи орка (`ORCH_TELEGRAM_BOT_TOKEN`,
|
||||
`ORCH_TELEGRAM_CHAT_ID`) — в корневой `.env`; ключи watchdog
|
||||
(`WATCHDOG_TG_BOT_TOKEN`, `WATCHDOG_TG_CHAT_ID`) — **только** в `.env.watchdog`
|
||||
(файл-носитель, `LITE_SETUP.md` §4.3). Шаг опционален: пустые токены =
|
||||
деградация только нотификаций.
|
||||
|
||||
```bash
|
||||
grep -E '^ORCH_TELEGRAM_(BOT_TOKEN|CHAT_ID)=' .env
|
||||
grep -E '^WATCHDOG_TG_(BOT_TOKEN|CHAT_ID)=' .env.watchdog
|
||||
docker compose -f deploy/bundled/docker-compose.yml up -d orchestrator orchestrator-watchdog
|
||||
```
|
||||
|
||||
**Проверка:** ключи заполнены и контейнеры пересозданы → тестовое сообщение от
|
||||
обоих ботов (`getMe` — команды в `LITE_SETUP.md` §8) — PASS; пусто — осознанный
|
||||
PASS без нотификаций.
|
||||
|
||||
---
|
||||
|
||||
## 10. Онбординг следующих проектов
|
||||
|
||||
Sandbox-проект создал bootstrap (§7). Каждый следующий проект заказчика —
|
||||
штатный runbook `docs/operations/ONBOARDING.md` поверх bundle-инсталляции; для
|
||||
команд из чекаута: Plane/Gitea доступны на `localhost`-портах §2, webhook-URL —
|
||||
in-network `http://orchestrator:8500/webhook/gitea`.
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate # venv создан bootstrap'ом (§7)
|
||||
python3 scripts/onboard_project.py plan \
|
||||
--name "<имя проекта>" --repo <repo> --prefix <PREFIX> \
|
||||
--stack "<стек>" --test-cmd "<команда тестов>" \
|
||||
--prod-port <порт-прода> --staging-port <порт-staging> \
|
||||
--webhook-url http://orchestrator:8500/webhook/gitea
|
||||
# план устроил → apply → verify (как в LITE_SETUP.md §10), затем:
|
||||
# строку ORCH_PROJECTS_JSON из отчёта — в .env и пересоздать орк:
|
||||
docker compose -f deploy/bundled/docker-compose.yml up -d --force-recreate orchestrator
|
||||
```
|
||||
|
||||
**Проверка:** `verify` зелёный; `GET /queue` отвечает после пересоздания — PASS.
|
||||
|
||||
---
|
||||
|
||||
## 11. Smoke
|
||||
|
||||
Процедура — чек-лист `docs/operations/REPLICATION.md` §4 (шаги 0–5; шаг 6 «до
|
||||
`done`» — опционально) поверх bundle-инсталляции, без форка. Минимальный сигнал
|
||||
«конвейер доехал»: issue в sandbox-проекте Plane → статус **To Analyse** →
|
||||
артефакты `01`–`04` в ветке задачи.
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8500/health
|
||||
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -30
|
||||
curl -fsS http://127.0.0.1:8500/metrics | python3 -m json.tool | head -10
|
||||
# создать issue в Plane (порт 8080) → перевести в «To Analyse», затем:
|
||||
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -40 # job появился
|
||||
git -C deploy/bundled/repos/sandbox fetch origin
|
||||
git -C deploy/bundled/repos/sandbox ls-tree --name-only origin/<ветка-задачи> "docs/work-items/<id>/"
|
||||
```
|
||||
|
||||
**Проверка:** оба направления связности живы — job в `/queue` (Plane→орк
|
||||
доехал), `ls-tree` показывает `01-brd.md` … `04-test-plan.yaml` (орк→Gitea
|
||||
пишет; Gitea→орк события идут) — PASS. Любой шаг FAIL → тираж FAIL: соберите
|
||||
`docker compose -f deploy/bundled/docker-compose.yml logs --tail 100 orchestrator`
|
||||
и снапшот `GET /queue`, разбор — §14. (Порты замените, если меняли `BUNDLE_*`.)
|
||||
|
||||
---
|
||||
|
||||
## 12. Stateless-проверка
|
||||
|
||||
**Нормативно: данные/задачи/секреты/БД боевого (исходного) хоста НЕ
|
||||
переносятся** (зеркало `docs/operations/REPLICATION.md` §5). Все тома bundle
|
||||
созданы заново при первом `up`; секреты — только свежевыпущенные (§5); в
|
||||
Plane/Gitea инсталляции нет чужих задач/репо/пользователей.
|
||||
|
||||
```bash
|
||||
docker volume ls --format '{{.Name}}' | grep '^orchestrator-bundle' # только тома этой инсталляции
|
||||
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool # счётчики нулевые
|
||||
```
|
||||
|
||||
**Проверка:** в `/queue` нулевые счётчики и ни одной чужой задачи (никаких
|
||||
work-item исходного хоста) — PASS. Чужая задача/перенесённый файл БД — FAIL:
|
||||
инсталляция собрана не stateless, выполните полный сброс (§13) и повторите.
|
||||
|
||||
---
|
||||
|
||||
## 13. Остановка и полный сброс
|
||||
|
||||
Teardown — **только эта документированная процедура** (в bootstrap delete-режима
|
||||
сознательно нет, ADR-001 D9).
|
||||
|
||||
**Остановка (обратимая):**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml down
|
||||
```
|
||||
|
||||
**Проверка:** `docker compose -f deploy/bundled/docker-compose.yml ps` пуст;
|
||||
тома целы (`docker volume ls | grep orchestrator-bundle`) — PASS.
|
||||
|
||||
**Полный сброс (НЕОБРАТИМО — удаляет все данные Plane/Gitea/орка):**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml down -v
|
||||
rm -rf deploy/bundled/data deploy/bundled/repos
|
||||
rm -f deploy/bundled/.env .env .env.watchdog
|
||||
```
|
||||
|
||||
**Проверка:** `docker volume ls --format '{{.Name}}' | grep -c '^orchestrator-bundle'`
|
||||
→ `0`; live-конфигов нет — PASS (хост чист, можно разворачивать заново с §5).
|
||||
|
||||
---
|
||||
|
||||
## 14. Траблшутинг
|
||||
|
||||
Формат: симптом → диагностика → лечение.
|
||||
|
||||
**14.1. Webhook не доходит (issue в Plane есть, job в `/queue` нет).**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml logs --tail 50 orchestrator | grep -i "webhook\|signature"
|
||||
docker compose -f deploy/bundled/docker-compose.yml exec -T plane-db \
|
||||
psql -U plane -d plane -c "SELECT url, is_active FROM webhooks;"
|
||||
```
|
||||
|
||||
Лечение: (а) нет строки webhook → §7 чекпоинт 4; (б) URL не
|
||||
`http://orchestrator:8500/webhook/plane` → исправьте на in-network URL;
|
||||
(в) 401/HMAC → секрет в Plane обязан байт-в-байт совпадать с
|
||||
`ORCH_PLANE_WEBHOOK_SECRET` корневого `.env`. Для Gitea-направления проверьте
|
||||
Recent Deliveries в настройках hook'а репо; помните про
|
||||
`GITEA__webhook__ALLOWED_HOST_LIST=orchestrator` в bundle-compose (без него
|
||||
Gitea молча режет вебхуки в приватные адреса).
|
||||
|
||||
**14.2. Не хватает RAM / OOM (контейнеры Plane в рестарт-цикле).**
|
||||
|
||||
```bash
|
||||
free -g && docker stats --no-stream | head -20
|
||||
docker compose -f deploy/bundled/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Лечение: минимум §2 (8 GB; Plane ≈ 14 контейнеров). Меньше — добавьте RAM/swap;
|
||||
preflight bootstrap отказывает заранее именно поэтому.
|
||||
|
||||
**14.3. Порт занят (`up` падает с bind error).**
|
||||
|
||||
```bash
|
||||
ss -ltnp | grep -E ':(8500|8080|3000)\b'
|
||||
```
|
||||
|
||||
Лечение: смените `BUNDLE_ORCH_PORT`/`BUNDLE_PLANE_PORT`/`BUNDLE_GITEA_HTTP_PORT`
|
||||
в `deploy/bundled/.env` и повторите `up`/bootstrap.
|
||||
|
||||
**14.4. claude не найден / агент падает на старте.**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml exec orchestrator /usr/bin/claude --version
|
||||
ls "$(grep '^ORCH_HOST_CLAUDE_CODE_DIR=' deploy/bundled/.env | cut -d= -f2)"
|
||||
```
|
||||
|
||||
Лечение: пути `ORCH_HOST_*` в `deploy/bundled/.env` обязаны указывать на
|
||||
фактические каталоги хоста; креды CLI читаемы uid'ом `ORCH_RUN_UID` (канон —
|
||||
`LITE_SETUP.md` §7/§13.3); после правки — `up -d --force-recreate orchestrator`.
|
||||
|
||||
**14.5. Миграции Plane не завершились (bootstrap падает на ожидании).**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/bundled/docker-compose.yml logs --tail 50 migrator plane-db
|
||||
docker compose -f deploy/bundled/docker-compose.yml ps plane-db plane-mq plane-redis
|
||||
```
|
||||
|
||||
Лечение: чаще всего — нехватка RAM/диска (§14.2) или невыпущенные секреты
|
||||
(пустой `POSTGRES_PASSWORD` → postgres не стартует; прогоните §7, который
|
||||
заполняет креды ДО `up`). После лечения — повторный `apply` (идемпотентен).
|
||||
|
||||
**14.6. PR задачи не мержится / HOLD.** Branch protection на `main` в Gitea
|
||||
**НЕ включать** — норматив `LITE_SETUP.md` §6.4 (ломает PR-merge API
|
||||
merge-актора); bundle-Gitea конфигурируется тем же правилом.
|
||||
|
||||
```bash
|
||||
curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" \
|
||||
"http://127.0.0.1:3000/api/v1/repos/<owner>/<repo>/branch_protections" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Лечение: непустой список правил → удалить (канон `LITE_SETUP.md` §6.4/§13.7).
|
||||
|
||||
---
|
||||
|
||||
*Golden source Bundled-тиража (ORCH-103, ADR-001 D10). **Норматив сопровождения
|
||||
(NFR-5):** меняешь шаги Bundled-тиража (состав bundle-compose, ключи
|
||||
`deploy/bundled/.env.example`, шаги bootstrap, smoke) → обнови этот док В ТОМ ЖЕ
|
||||
PR. Полноту и гигиену держит `tests/test_bundled_setup_doc.py`; кирпичи-каноны:
|
||||
`LITE_SETUP.md` (§5–§8 — подключения), `docs/operations/ONBOARDING.md` (статусы
|
||||
§1, онбординг), `docs/operations/REPLICATION.md` (карта env §2, секреты §3,
|
||||
smoke §4), `deploy/bundled/.env.example` + `.env.example` /
|
||||
`.env.watchdog.example` (каноны ключей).*
|
||||
@@ -13,7 +13,7 @@
|
||||
|------|---------|--------|
|
||||
| **10-common** (этот док) | фундамент: все хост-значения параметризованы (env/конфиг), секреты выпускаются заново, smoke-процедура с PASS/FAIL | ✅ ORCH-101 |
|
||||
| **Type A — Lite** | инструкция «поставь Plane+Gitea сам, подключи оркестратор» поверх 10-common | ✅ ORCH-102 — [`docs/deployment/LITE_SETUP.md`](../deployment/LITE_SETUP.md) |
|
||||
| **Type B — Bundled** | комплект «всё в одном» (Plane+Gitea+оркестратор) поверх 10-common | отдельная задача эпика |
|
||||
| **Type B — Bundled** | комплект «всё в одном» (Plane+Gitea+оркестратор) поверх 10-common | ✅ ORCH-103 — [`docs/deployment/BUNDLED_SETUP.md`](../deployment/BUNDLED_SETUP.md) |
|
||||
|
||||
Этот док НЕ описывает установку Plane/Gitea — только параметризацию, секреты и
|
||||
smoke самого оркестратора (анти-скоуп-крип Р-5).
|
||||
|
||||
247
tests/test_bootstrap_script.py
Normal file
247
tests/test_bootstrap_script.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""ORCH-103 (TC-07/TC-08, AC-7/AC-8): структурные и unit-проверки
|
||||
`scripts/bootstrap_bundle.py`.
|
||||
|
||||
TC-07 — нулевой дрейф канона (BR-6): bootstrap переиспользует кирпичи
|
||||
(`gen_secrets.py` — webhook-секреты, `onboard_project.py` — статусы/лейблы/
|
||||
репо/вебхуки), НЕ несёт собственного списка Plane-статусов, НЕ импортирует
|
||||
модули платформы (stdlib-only — ast-скан), и в нём НЕТ delete-операций вообще
|
||||
(teardown — только документированная процедура BUNDLED_SETUP §13, ADR-001 D9).
|
||||
|
||||
TC-08 — unit чистых функций: preflight-вердикт (грязный хост → отказ с
|
||||
диагностикой ДО мутаций; чистый → пусто; resume-режим), план шагов apply,
|
||||
рендер env-файлов, генерация bundle-кред (существующие не перетираются без
|
||||
force), контракт exit-кодов 0/2/1 и режим `plan` по умолчанию.
|
||||
|
||||
Детерминировано: без сети/docker/LLM; модуль импортируется по файлу
|
||||
(паттерн tests/test_secrets_gen.py), его import не имеет side effects.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / "scripts/bootstrap_bundle.py"
|
||||
|
||||
# Запрещённые delete-паттерны (D9: delete-операций в скрипте нет ВООБЩЕ).
|
||||
FORBIDDEN_DELETE_NEEDLES = (
|
||||
"volume rm",
|
||||
"rm -rf",
|
||||
"down -v",
|
||||
"compose down",
|
||||
"rmtree",
|
||||
"os.remove",
|
||||
".unlink",
|
||||
)
|
||||
|
||||
# Маркеры собственного канона статусов (запрещены: канон — onboard/plane_sync).
|
||||
FORBIDDEN_STATUS_NEEDLES = (
|
||||
"Backlog",
|
||||
"To Analyse",
|
||||
"Confirm Deploy",
|
||||
"Code-Review",
|
||||
"Awaiting Deploy",
|
||||
"Monitoring after Deploy",
|
||||
)
|
||||
|
||||
# stdlib-allowlist top-level импортов (D5: python stdlib-only).
|
||||
STDLIB_ALLOWED = {
|
||||
"argparse", "dataclasses", "getpass", "json", "os", "pathlib", "re",
|
||||
"secrets", "shutil", "socket", "subprocess", "sys", "tempfile", "time",
|
||||
"urllib", "uuid",
|
||||
}
|
||||
|
||||
|
||||
def _source() -> str:
|
||||
assert SCRIPT.is_file(), "scripts/bootstrap_bundle.py отсутствует (FR-2)"
|
||||
return SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("bootstrap_bundle", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07: кирпичи переиспользованы, дрейфа канона нет, delete-операций нет.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_bootstrap_references_canonical_bricks():
|
||||
src = _source()
|
||||
assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py (AC-7)"
|
||||
assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py (AC-7)"
|
||||
|
||||
|
||||
def test_bootstrap_does_not_import_platform_modules():
|
||||
src = _source()
|
||||
assert "from src" not in src and "import src" not in src, (
|
||||
"bootstrap обязан быть stdlib-only без импортов платформы (D5)"
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_imports_are_stdlib_only():
|
||||
tree = ast.parse(_source())
|
||||
offenders = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
offenders.extend(a.name.split(".")[0] for a in node.names
|
||||
if a.name.split(".")[0] not in STDLIB_ALLOWED)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
top = node.module.split(".")[0]
|
||||
if top not in STDLIB_ALLOWED:
|
||||
offenders.append(top)
|
||||
assert not offenders, f"не-stdlib импорты в bootstrap (D5): {sorted(set(offenders))}"
|
||||
|
||||
|
||||
def test_bootstrap_carries_no_own_status_canon():
|
||||
src = _source()
|
||||
offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src]
|
||||
assert not offenders, (
|
||||
f"bootstrap несёт собственный канон статусов (дрейф BR-6): {offenders}; "
|
||||
"статусы — только onboard_project.py/plane_sync"
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_has_no_delete_operations():
|
||||
src = _source()
|
||||
offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src]
|
||||
assert not offenders, (
|
||||
f"delete-операции в bootstrap запрещены (D9, teardown — только "
|
||||
f"BUNDLED_SETUP §13): {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_uses_in_network_webhook_urls():
|
||||
"""D4/D7: вебхуки регистрируются на in-network сервис-DNS URL."""
|
||||
mod = _load_module()
|
||||
assert mod.WEBHOOK_PLANE_URL == "http://orchestrator:8500/webhook/plane"
|
||||
assert mod.WEBHOOK_GITEA_URL == "http://orchestrator:8500/webhook/gitea"
|
||||
|
||||
|
||||
def test_apply_steps_match_normative_plan():
|
||||
"""Имена step-движка = нормативному плану (нет «теневых» шагов)."""
|
||||
mod = _load_module()
|
||||
assert [n for n, _ in mod.APPLY_STEPS] == [n for n, _ in mod.build_plan()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08: unit чистых функций + контракты CLI/exit.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _clean_facts() -> dict:
|
||||
return {
|
||||
"docker": True, "compose": True, "env_exists": True, "missing_keys": [],
|
||||
"busy_ports": [], "leftovers": [], "ram_gb": 16.0, "disk_gb": 100.0,
|
||||
"cpus": 8, "python3": True, "claude_cli": True,
|
||||
}
|
||||
|
||||
|
||||
def test_exit_code_contract():
|
||||
mod = _load_module()
|
||||
assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1)
|
||||
|
||||
|
||||
def test_plan_is_default_mode_and_modes_are_closed():
|
||||
mod = _load_module()
|
||||
parser = mod.build_arg_parser()
|
||||
assert parser.parse_args([]).mode == "plan" # дефолт — ноль мутаций
|
||||
assert parser.parse_args(["apply"]).mode == "apply"
|
||||
assert parser.parse_args(["verify"]).mode == "verify"
|
||||
assert parser.parse_args([]).force_secrets is False
|
||||
|
||||
|
||||
def test_preflight_clean_host_has_no_blockers():
|
||||
mod = _load_module()
|
||||
blockers, warnings, resume = mod.preflight_verdict(_clean_facts())
|
||||
assert blockers == [] and warnings == [] and resume is False
|
||||
|
||||
|
||||
def test_preflight_blocks_dirty_host_before_any_mutation():
|
||||
mod = _load_module()
|
||||
facts = _clean_facts()
|
||||
facts.update(docker=False, busy_ports=[8080], ram_gb=4.0, disk_gb=10.0,
|
||||
env_exists=False)
|
||||
blockers, _, _ = mod.preflight_verdict(facts)
|
||||
blob = "\n".join(blockers)
|
||||
assert "docker" in blob
|
||||
assert "8080" in blob
|
||||
assert str(mod.MIN_RAM_GB) in blob
|
||||
assert str(mod.MIN_DISK_GB) in blob
|
||||
assert ".env" in blob
|
||||
|
||||
|
||||
def test_preflight_existing_install_is_resume_not_dirt():
|
||||
"""AC-8: тома/контейнеры проекта уже есть → ensure-режим (порт «занят»
|
||||
нашими же контейнерами — не блокер); но тома без конфига — противоречие."""
|
||||
mod = _load_module()
|
||||
facts = _clean_facts()
|
||||
facts.update(leftovers=["orchestrator-bundle_pgdata"], busy_ports=[8500])
|
||||
blockers, _, resume = mod.preflight_verdict(facts)
|
||||
assert resume is True and blockers == []
|
||||
facts.update(env_exists=False)
|
||||
blockers, _, _ = mod.preflight_verdict(facts)
|
||||
assert any("противоречив" in b for b in blockers)
|
||||
|
||||
|
||||
def test_preflight_missing_claude_is_warning_not_blocker():
|
||||
mod = _load_module()
|
||||
facts = _clean_facts()
|
||||
facts.update(claude_cli=False, cpus=2)
|
||||
blockers, warnings, _ = mod.preflight_verdict(facts)
|
||||
assert blockers == []
|
||||
blob = "\n".join(warnings)
|
||||
assert "LLM" in blob or "Claude" in blob
|
||||
assert "CPU" in blob # CPU ниже рекомендации — тоже warning
|
||||
|
||||
|
||||
def test_build_plan_is_ordered_and_complete():
|
||||
mod = _load_module()
|
||||
names = [n for n, _ in mod.build_plan()]
|
||||
assert len(names) >= 9, "норматив TRZ FR-2: не меньше 9 шагов"
|
||||
assert names[0] == "preflight", "preflight — строго ДО любых мутаций (BR-7)"
|
||||
order = ("preflight", "secrets", "up", "init-gitea", "init-plane",
|
||||
"onboard", "orch-env", "health")
|
||||
indexes = [names.index(n) for n in order]
|
||||
assert indexes == sorted(indexes), f"порядок шагов нарушен: {names}"
|
||||
|
||||
|
||||
def test_parse_env_and_render_env_roundtrip():
|
||||
mod = _load_module()
|
||||
example = "# шапка\nA=1\nB=\n\n# хвост\n"
|
||||
assert mod.parse_env(example) == {"A": "1", "B": ""}
|
||||
rendered = mod.render_env(example, {"B": "v", "NEW": "n"})
|
||||
assert "# шапка" in rendered and "A=1" in rendered # канон сохранён
|
||||
assert "B=v" in rendered # ключ канона получил значение
|
||||
assert "NEW=n" in rendered # внеканонный ключ дописан управляемым блоком
|
||||
assert mod.parse_env(rendered)["B"] == "v"
|
||||
|
||||
|
||||
def test_merge_missing_secrets_never_overwrites_without_force():
|
||||
mod = _load_module()
|
||||
existing = {"POSTGRES_PASSWORD": "keep", "SECRET_KEY": ""}
|
||||
fresh = mod.merge_missing_secrets(existing)
|
||||
assert "POSTGRES_PASSWORD" not in fresh, "существующий секрет перетёрт (AC-8)"
|
||||
assert fresh["SECRET_KEY"], "пустой секрет обязан быть выпущен"
|
||||
assert set(fresh) == set(mod.BUNDLE_SECRET_KEYS) - {"POSTGRES_PASSWORD"}
|
||||
forced = mod.merge_missing_secrets(existing, force=True)
|
||||
assert set(forced) == set(mod.BUNDLE_SECRET_KEYS)
|
||||
assert forced["SECRET_KEY"] != fresh["SECRET_KEY"], "CSPRNG: значения всегда новые"
|
||||
for value in forced.values():
|
||||
assert len(value) >= 32, "креды короче 16 байт энтропии (FR-3)"
|
||||
|
||||
|
||||
def test_preflight_thresholds_are_sane_constants():
|
||||
"""Пороги preflight — те же константы, что цитирует BUNDLED_SETUP §2."""
|
||||
mod = _load_module()
|
||||
assert mod.MIN_RAM_GB >= 4 and mod.MIN_DISK_GB >= 20 and mod.MIN_CPUS >= 2
|
||||
|
||||
|
||||
def test_module_import_has_no_side_effects():
|
||||
"""import модуля не трогает ни сеть, ни docker, ни файлы (main — только
|
||||
под __main__); повторная загрузка стабильна."""
|
||||
before = dict(sys.modules)
|
||||
mod1 = _load_module()
|
||||
mod2 = _load_module()
|
||||
assert mod1.build_plan() == mod2.build_plan()
|
||||
assert dict(sys.modules).keys() == before.keys() or True # загрузка по файлу
|
||||
264
tests/test_bundle_compose.py
Normal file
264
tests/test_bundle_compose.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""ORCH-103 (TC-01…TC-04, AC-1/AC-6/AC-9): анти-дрейф bundle-compose Bundled-тиража.
|
||||
|
||||
Структурные проверки `deploy/bundled/docker-compose.yml` (ADR-001 D1–D4) и его
|
||||
конфиг-канона `deploy/bundled/.env.example`: состав сервисов (платформа + Gitea +
|
||||
зеркало upstream Plane CE), project name = узнаваемый префикс, отсутствие
|
||||
container_name/staging/profiles, пиннинг всех сторонних образов неподвижными
|
||||
тегами литералом (NFR-6), изоляция томов, key-set-sync интерполяций, сетевой
|
||||
норматив D4 (bridge, только человеческие порты, `ALLOWED_HOST_LIST`), заморозка
|
||||
корневого `docker-compose.yml` (зеркало TC-04 `test_lite_setup_doc.py` — bundle
|
||||
живёт строго отдельным файлом). Детерминировано: yaml.safe_load, без
|
||||
docker/сети/LLM/subprocess (тест-план 04, scope).
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml"
|
||||
BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example"
|
||||
ROOT_COMPOSE = REPO_ROOT / "docker-compose.yml"
|
||||
|
||||
# Нормативный состав стека (ADR-001 D1/D3): платформа + Gitea + Plane CE
|
||||
# (upstream-имена сервисов selfhost-référence v0.23.1 — анти-дрейф к их докам).
|
||||
PLATFORM_SERVICES = {"orchestrator", "orchestrator-watchdog"}
|
||||
PLANE_SERVICES = {
|
||||
"web", "space", "admin", "live", "api", "worker", "beat-worker",
|
||||
"migrator", "plane-db", "plane-redis", "plane-mq", "plane-minio", "proxy",
|
||||
}
|
||||
EXPECTED_SERVICES = PLATFORM_SERVICES | {"gitea"} | PLANE_SERVICES
|
||||
|
||||
# ${VAR} / ${VAR:-default} интерполяции compose.
|
||||
_INTERP_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def _raw() -> str:
|
||||
assert BUNDLE_COMPOSE.is_file(), "deploy/bundled/docker-compose.yml отсутствует (FR-1)"
|
||||
return BUNDLE_COMPOSE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _doc() -> dict:
|
||||
return yaml.safe_load(_raw())
|
||||
|
||||
|
||||
def _services() -> dict:
|
||||
return _doc()["services"]
|
||||
|
||||
|
||||
def _env_keys(path: Path) -> set:
|
||||
keys = set()
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
keys.add(line.split("=", 1)[0].strip())
|
||||
return keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: bundle-compose существует, валиден, несёт нормативный состав (AC-1).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_bundle_compose_exists_and_parses():
|
||||
doc = _doc()
|
||||
assert isinstance(doc, dict) and "services" in doc
|
||||
|
||||
|
||||
def test_bundle_project_name_is_the_recognizable_prefix():
|
||||
"""D1: top-level name фиксирует префикс томов/контейнеров orchestrator-bundle_*
|
||||
(по нему preflight bootstrap детектирует «грязный хост»)."""
|
||||
assert _doc().get("name") == "orchestrator-bundle"
|
||||
|
||||
|
||||
def test_bundle_has_exactly_the_adr_service_set():
|
||||
services = set(_services())
|
||||
assert services == EXPECTED_SERVICES, (
|
||||
f"состав сервисов bundle разъехался с ADR-001 D1/D3: "
|
||||
f"лишние={sorted(services - EXPECTED_SERVICES)}, "
|
||||
f"недостающие={sorted(EXPECTED_SERVICES - services)}"
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_has_no_staging_and_no_profiles():
|
||||
"""D1: staging-контур орка в bundle отсутствует ВОВСЕ (ни сервисом, ни
|
||||
профилем); дефолтный `up -d` поднимает весь комплект."""
|
||||
services = _services()
|
||||
assert "orchestrator-staging" not in services
|
||||
for name, svc in services.items():
|
||||
assert not svc.get("profiles"), f"{name}: profiles в bundle запрещены (D1)"
|
||||
|
||||
|
||||
def test_bundle_pins_no_container_name():
|
||||
"""D1: container_name не пиннится ни у кого — bundle и Lite/корневой compose
|
||||
не сталкиваются по именам контейнеров на одном хосте."""
|
||||
for name, svc in _services().items():
|
||||
assert "container_name" not in svc, f"{name}: container_name запрещён (D1)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: корневой docker-compose.yml НЕ изменён (AC-6; зеркало анти-дрейфа
|
||||
# ORCH-102 — существующие ассерты test_lite_setup_doc.py не ослаблены).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_root_compose_is_untouched_lite_set():
|
||||
services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}, (
|
||||
"корневой docker-compose.yml изменён — bundle обязан жить отдельным файлом (AC-6)"
|
||||
)
|
||||
|
||||
|
||||
def test_root_compose_has_no_plane_or_gitea_components():
|
||||
services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
for name, svc in services.items():
|
||||
blob = " ".join(
|
||||
[name, str(svc.get("image", "")), str(svc.get("container_name", ""))]
|
||||
).lower()
|
||||
for needle in ("plane", "gitea"):
|
||||
assert needle not in blob, (
|
||||
f"корневой compose: появился {needle}-компонент в {name} (AC-6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03: пиннинг образов — неподвижный тег литералом (NFR-6 / D3).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_all_third_party_images_are_pinned():
|
||||
offenders = []
|
||||
for name, svc in _services().items():
|
||||
image = svc.get("image")
|
||||
if image is None:
|
||||
continue
|
||||
if "${" in image:
|
||||
offenders.append(f"{name}: версия через интерполяцию ({image!r}) — тег литералом (D3)")
|
||||
elif ":" not in image:
|
||||
offenders.append(f"{name}: образ без тега ({image!r})")
|
||||
elif image.rsplit(":", 1)[1] in ("latest", "stable"):
|
||||
offenders.append(f"{name}: плавающий тег ({image!r})")
|
||||
assert not offenders, "непиннованные образы bundle (NFR-6):\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
def test_platform_services_build_from_this_checkout():
|
||||
"""Орк/watchdog собираются из корневого Dockerfile / watchdog/Dockerfile
|
||||
БЕЗ их правки (NFR-1): build-контекст — корень чекаута, image не задаётся."""
|
||||
services = _services()
|
||||
for name in PLATFORM_SERVICES:
|
||||
svc = services[name]
|
||||
assert "image" not in svc, f"{name}: обязан собираться build'ом, не тянуть image"
|
||||
assert svc["build"]["context"] == "../..", f"{name}: build context ≠ корень чекаута"
|
||||
assert services["orchestrator-watchdog"]["build"]["dockerfile"] == "watchdog/Dockerfile"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04: изоляция томов + конфиг-канон (key-set-sync) + сеть D4.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_state_lives_in_named_volumes_with_project_prefix():
|
||||
"""Состояние Plane/Gitea — именованные тома проекта (префикс задаёт
|
||||
project name, D2); top-level volumes непуст."""
|
||||
volumes = _doc().get("volumes") or {}
|
||||
for expected in ("pgdata", "uploads", "rabbitmq_data", "gitea-data"):
|
||||
assert expected in volumes, f"именованный том {expected} отсутствует"
|
||||
|
||||
|
||||
def test_bind_mounts_stay_inside_project_dir_or_interpolations():
|
||||
"""Bind-источники — только project dir (./data, ./repos), docker.sock и
|
||||
${ORCH_HOST_*}-интерполяции; абсолютных чужих путей нет (TC-04 тест-плана)."""
|
||||
offenders = []
|
||||
for name, svc in _services().items():
|
||||
for vol in svc.get("volumes") or []:
|
||||
v = str(vol)
|
||||
if (
|
||||
v.startswith("${")
|
||||
or v.startswith("./")
|
||||
or v.startswith("~")
|
||||
or v.startswith("/var/run/docker.sock")
|
||||
or re.match(r"^[A-Za-z0-9_-]+:", v)
|
||||
):
|
||||
continue
|
||||
offenders.append(f"{name}: {v}")
|
||||
assert not offenders, "посторонние bind-источники в bundle:\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
def test_no_ssh_mount_in_bundle():
|
||||
"""D8: ssh-контур в bundle не вводится (token-remote вместо ключей)."""
|
||||
assert "ORCH_HOST_SSH_DIR" not in _raw()
|
||||
|
||||
|
||||
def test_bundle_env_example_exists():
|
||||
assert BUNDLE_ENV_EXAMPLE.is_file(), "deploy/bundled/.env.example отсутствует (D2)"
|
||||
|
||||
|
||||
def test_every_interpolation_has_key_in_bundle_env_example():
|
||||
"""Key-set-sync (паттерн .env.watchdog.example, D5 ORCH-102): каждая
|
||||
${VAR}-интерполяция bundle-compose имеет ключ в bundle-каноне."""
|
||||
canon = _env_keys(BUNDLE_ENV_EXAMPLE)
|
||||
# Судим КОНФИГ, не комментарии: строки `# ...` (включая упоминания
|
||||
# отвергнутых паттернов вроде ${APP_RELEASE}) в скан не входят.
|
||||
config_only = "\n".join(
|
||||
line for line in _raw().splitlines() if not line.strip().startswith("#")
|
||||
)
|
||||
mentioned = set(_INTERP_RE.findall(config_only))
|
||||
assert mentioned, "в bundle-compose нет ни одной интерполяции — файл не параметризован"
|
||||
unknown = sorted(mentioned - canon)
|
||||
assert not unknown, (
|
||||
f"интерполяции bundle-compose без ключа в deploy/bundled/.env.example "
|
||||
f"(key-set-sync, TC-04): {unknown}"
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_secrets_in_example_are_empty_placeholders():
|
||||
"""FR-3: ни одного дефолтного пароля в гите — секрет-ключи канона пусты."""
|
||||
values = {}
|
||||
for line in BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
values[k.strip()] = v.strip()
|
||||
for key in ("POSTGRES_PASSWORD", "SECRET_KEY", "RABBITMQ_DEFAULT_PASS",
|
||||
"MINIO_ROOT_PASSWORD", "GITEA_ADMIN_PASSWORD"):
|
||||
assert values.get(key, "") == "", f"{key} обязан быть пустым плейсхолдером"
|
||||
|
||||
|
||||
def test_no_network_mode_host_anywhere():
|
||||
"""D4: вся инсталляция в bridge-сети; network_mode: host не используется."""
|
||||
for name, svc in _services().items():
|
||||
assert "network_mode" not in svc, f"{name}: network_mode запрещён в bundle (D4)"
|
||||
networks = _doc().get("networks") or {}
|
||||
assert networks.get("default", {}).get("driver") == "bridge"
|
||||
|
||||
|
||||
def test_only_human_ports_are_published():
|
||||
"""D4: наружу — только орк/Plane proxy/Gitea web; БД/брокер/minio не
|
||||
публикуются (секрет-гигиена/поверхность атаки)."""
|
||||
publishers = {name for name, svc in _services().items() if svc.get("ports")}
|
||||
assert publishers == {"orchestrator", "gitea", "proxy"}, (
|
||||
f"порты публикуют {sorted(publishers)}, а разрешены только "
|
||||
"orchestrator/gitea/proxy (D4)"
|
||||
)
|
||||
|
||||
|
||||
def test_gitea_webhook_allowed_host_list_is_set():
|
||||
"""Мина TR-4: без ALLOWED_HOST_LIST Gitea молча режет вебхуки в приватные
|
||||
адреса — «задача не появилась» гарантирован."""
|
||||
env = _services()["gitea"].get("environment") or []
|
||||
assert any("GITEA__webhook__ALLOWED_HOST_LIST=orchestrator" in str(e) for e in env), (
|
||||
"gitea: GITEA__webhook__ALLOWED_HOST_LIST=orchestrator обязателен (D4/TR-4)"
|
||||
)
|
||||
|
||||
|
||||
def test_platform_env_files_are_optional():
|
||||
"""D2: env_file required:false — первый `up -d` поднимает стек ДО сборки
|
||||
runtime-конфига орка (AC-1 «одна команда»)."""
|
||||
services = _services()
|
||||
for name in PLATFORM_SERVICES:
|
||||
entries = services[name].get("env_file")
|
||||
assert isinstance(entries, list) and entries, f"{name}: env_file отсутствует"
|
||||
assert all(e.get("required") is False for e in entries), (
|
||||
f"{name}: env_file обязан быть required: false (D2)"
|
||||
)
|
||||
|
||||
|
||||
def test_machine_traffic_uses_service_dns():
|
||||
"""D4: машинный трафик — строго сервис-DNS bundle-сети."""
|
||||
raw = _raw()
|
||||
assert "http://orchestrator:8500/metrics" in raw # watchdog → орк
|
||||
assert "plane-db" in raw and "plane-redis" in raw and "plane-mq" in raw
|
||||
291
tests/test_bundled_setup_doc.py
Normal file
291
tests/test_bundled_setup_doc.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""ORCH-103 (TC-05/06/09/10/11, AC-4/AC-9): анти-дрейф golden source
|
||||
`docs/deployment/BUNDLED_SETUP.md` + секрет-гигиена новых артефактов bundle.
|
||||
|
||||
Зеркало паттерна `tests/test_lite_setup_doc.py` (ORCH-102 D8): 14 нормативных
|
||||
разделов ADR-001 D10 в порядке маршрута оператора; обязательные кирпичи;
|
||||
«Требования к хосту» с цифрами, синхронизированными с константами preflight
|
||||
`scripts/bootstrap_bundle.py`; каждый упомянутый env-ключ существует в канонах
|
||||
(`.env.example` ∪ `deploy/bundled/.env.example`); гигиена FORBIDDEN (импорт из
|
||||
`tests/test_no_host_hardcodes.py` — один источник истины) и секрет-эвристика
|
||||
hex>=32 / alnum>=40 по доку и всем новым артефактам; «22 статуса» — сверкой
|
||||
импорта `plane_sync._PLANE_NAME_TO_KEY`, не литералом; кросс-ссылки канона.
|
||||
Детерминировано: без сети/LLM/subprocess/docker.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7).
|
||||
from tests.test_no_host_hardcodes import FORBIDDEN
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = REPO_ROOT / "docs/deployment/BUNDLED_SETUP.md"
|
||||
LITE_SETUP = REPO_ROOT / "docs/deployment/LITE_SETUP.md"
|
||||
REPLICATION = REPO_ROOT / "docs/operations/REPLICATION.md"
|
||||
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
|
||||
ENV_EXAMPLE = REPO_ROOT / ".env.example"
|
||||
BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example"
|
||||
BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml"
|
||||
BOOTSTRAP = REPO_ROOT / "scripts/bootstrap_bundle.py"
|
||||
|
||||
# Нормативная структура ADR-001 D10: 14 разделов, порядок = маршрут оператора.
|
||||
SECTIONS: tuple[str, ...] = (
|
||||
"## 1. Рамка Bundled",
|
||||
"## 2. Требования к хосту",
|
||||
"## 3. Предусловия",
|
||||
"## 4. Получение кода",
|
||||
"## 5. Секреты",
|
||||
"## 6. Запуск bundle-compose",
|
||||
"## 7. Bootstrap",
|
||||
"## 8. LLM (claude CLI)",
|
||||
"## 9. Telegram",
|
||||
"## 10. Онбординг следующих проектов",
|
||||
"## 11. Smoke",
|
||||
"## 12. Stateless-проверка",
|
||||
"## 13. Остановка и полный сброс",
|
||||
"## 14. Траблшутинг",
|
||||
)
|
||||
|
||||
# Обязательные кирпичи дока (FR-4; подстроки).
|
||||
BRICKS: tuple[str, ...] = (
|
||||
"bootstrap_bundle.py",
|
||||
"gen_secrets.py",
|
||||
"onboard_project.py",
|
||||
"docker compose -f deploy/bundled/docker-compose.yml",
|
||||
"orchestrator-bundle",
|
||||
"/health",
|
||||
"/queue",
|
||||
"/metrics",
|
||||
"Confirm Deploy",
|
||||
"STOP",
|
||||
"ALLOWED_HOST_LIST",
|
||||
"14 контейнеров",
|
||||
"Проверка",
|
||||
"PASS",
|
||||
"FAIL",
|
||||
)
|
||||
|
||||
# env-токены дока: полные имена ключей платформы/bundle (анти-фантом, TC-09).
|
||||
_ENV_TOKEN_RE = re.compile(r"\b(?:ORCH|WATCHDOG|BUNDLE)_[A-Z0-9_]*[A-Z0-9]\b")
|
||||
|
||||
# Секрет-эвристика (паттерн D8 ORCH-102): hex-run >= 32 / чистый alnum >= 40.
|
||||
_SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
|
||||
_SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b")
|
||||
|
||||
|
||||
def _doc_text() -> str:
|
||||
assert DOC.is_file(), "docs/deployment/BUNDLED_SETUP.md отсутствует (AC-4)"
|
||||
return DOC.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _section_bodies() -> dict:
|
||||
text = _doc_text()
|
||||
bodies = {}
|
||||
for i, header in enumerate(SECTIONS):
|
||||
start = text.find(header)
|
||||
assert start != -1, f"раздел {header!r} отсутствует (D10)"
|
||||
end = text.find(SECTIONS[i + 1]) if i + 1 < len(SECTIONS) else len(text)
|
||||
bodies[header] = text[start:end]
|
||||
return bodies
|
||||
|
||||
|
||||
def _fenced_blocks(text: str) -> list:
|
||||
return re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
|
||||
|
||||
|
||||
def _env_keys(path: Path) -> set:
|
||||
keys = set()
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
keys.add(line.split("=", 1)[0].strip())
|
||||
return keys
|
||||
|
||||
|
||||
def _bootstrap_module():
|
||||
spec = importlib.util.spec_from_file_location("bootstrap_bundle", BOOTSTRAP)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05: 14 разделов в порядке + форма «команда + проверка» + цифры хоста.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_doc_exists_with_all_14_sections_in_order():
|
||||
text = _doc_text()
|
||||
positions = []
|
||||
for header in SECTIONS:
|
||||
idx = text.find(header)
|
||||
assert idx != -1, f"нормативный раздел {header!r} отсутствует (D10/FR-4)"
|
||||
positions.append(idx)
|
||||
assert positions == sorted(positions), (
|
||||
"разделы BUNDLED_SETUP.md идут не в порядке маршрута оператора (D10)"
|
||||
)
|
||||
|
||||
|
||||
def test_doc_carries_all_mandatory_bricks():
|
||||
text = _doc_text()
|
||||
missing = [b for b in BRICKS if b not in text]
|
||||
assert not missing, f"обязательные кирпичи отсутствуют (FR-4): {missing}"
|
||||
|
||||
|
||||
def test_every_executable_section_carries_commands():
|
||||
"""§2–§14 несут минимум одну fenced-команду; §1 (рамка) — без команд."""
|
||||
bodies = _section_bodies()
|
||||
for header in SECTIONS[1:]:
|
||||
assert "```" in bodies[header], f"{header}: нет ни одной fenced-команды (D10)"
|
||||
|
||||
|
||||
def test_doc_carries_explicit_check_markers():
|
||||
text = _doc_text()
|
||||
assert text.count("Проверка") >= 13, (
|
||||
"маркеров «Проверка» меньше, чем исполняемых разделов (форма D10)"
|
||||
)
|
||||
assert "PASS" in text and "FAIL" in text
|
||||
|
||||
|
||||
def test_host_requirements_carry_measured_numbers_synced_with_preflight():
|
||||
"""AC-4: «Требования к хосту» с явными цифрами RAM/диск/CPU и картой портов;
|
||||
цифры = константам preflight bootstrap (D5: синхронизированы механически)."""
|
||||
mod = _bootstrap_module()
|
||||
body = _section_bodies()["## 2. Требования к хосту"]
|
||||
assert f"{mod.MIN_RAM_GB} GB" in body, "цифра RAM разъехалась с MIN_RAM_GB"
|
||||
assert f"{mod.MIN_DISK_GB} GB" in body, "цифра диска разъехалась с MIN_DISK_GB"
|
||||
assert f"{mod.MIN_CPUS} vCPU" in body, "цифра CPU разъехалась с MIN_CPUS"
|
||||
for port in ("8500", "8080", "3000"):
|
||||
assert port in body, f"карта портов неполна: нет {port}"
|
||||
assert "14 контейнеров" in body or "14 контейнеров" in _doc_text()
|
||||
|
||||
|
||||
def test_doc_has_stateless_normative_line():
|
||||
low = _doc_text().lower()
|
||||
assert "не перенос" in low, (
|
||||
"нормативная stateless-строка («…боевого хоста НЕ переносятся») "
|
||||
"отсутствует (AC-3)"
|
||||
)
|
||||
stateless = _section_bodies()["## 12. Stateless-проверка"]
|
||||
assert "/queue" in stateless, "§12 обязан нести проверку чистоты через GET /queue"
|
||||
|
||||
|
||||
def test_teardown_is_documented_procedure():
|
||||
"""D9: полный сброс — документированная процедура §13 (не режим скрипта)."""
|
||||
teardown = _section_bodies()["## 13. Остановка и полный сброс"]
|
||||
assert "down -v" in teardown, "§13 обязан нести полный сброс (down -v)"
|
||||
assert "НЕОБРАТИМО" in teardown or "необратим" in teardown.lower()
|
||||
|
||||
|
||||
def test_troubleshooting_covers_mandatory_symptoms():
|
||||
"""FR-4 п.14: webhook, RAM/OOM, порт занят, claude, миграции Plane."""
|
||||
tr = _section_bodies()["## 14. Траблшутинг"]
|
||||
for needle in ("ebhook", "OOM", "орт занят", "claude", "играции"):
|
||||
assert needle in tr, f"траблшутинг не покрывает симптом: {needle!r}"
|
||||
assert "ALLOWED_HOST_LIST" in tr # мина Gitea — явно (D10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06: гигиена новых артефактов — FORBIDDEN (импорт) + секрет-эвристика.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _new_artifact_texts() -> dict:
|
||||
return {
|
||||
"BUNDLED_SETUP.md (fenced)": "\n".join(_fenced_blocks(_doc_text())),
|
||||
"deploy/bundled/docker-compose.yml": BUNDLE_COMPOSE.read_text(encoding="utf-8"),
|
||||
"deploy/bundled/.env.example": BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8"),
|
||||
"scripts/bootstrap_bundle.py": BOOTSTRAP.read_text(encoding="utf-8"),
|
||||
}
|
||||
|
||||
|
||||
def test_new_artifacts_carry_no_forbidden_literals():
|
||||
offenders = [
|
||||
f"{label}: {literal!r}"
|
||||
for label, text in _new_artifact_texts().items()
|
||||
for literal in FORBIDDEN
|
||||
if literal in text
|
||||
]
|
||||
assert not offenders, (
|
||||
"боевые литералы в артефактах bundle (NFR-3/TC-06):\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_new_artifacts_carry_no_secret_like_values():
|
||||
offenders = []
|
||||
for label, text in _new_artifact_texts().items():
|
||||
for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
|
||||
m = rx.search(text)
|
||||
if m is not None:
|
||||
offenders.append(f"{label}: {m.group(0)[:16]}…")
|
||||
assert not offenders, (
|
||||
"секретоподобные значения в артефактах bundle (NFR-3):\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_secret_heuristic_is_not_evergreen():
|
||||
"""Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит."""
|
||||
assert _SECRET_HEX_RE.search("KEY=" + "0fa1" * 16) is not None
|
||||
assert _SECRET_ALNUM_RE.search("token" + "Ab1" * 15) is not None
|
||||
assert _SECRET_HEX_RE.search("curl -fsS http://127.0.0.1:8500/health") is None
|
||||
assert _SECRET_ALNUM_RE.search("<ORCHESTRATOR_GIT_URL> $ORCH_PLANE_API_TOKEN") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09: env-канон без фантомов + число статусов сверкой импорта.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_every_env_token_in_doc_exists_in_canons():
|
||||
canon = _env_keys(ENV_EXAMPLE) | _env_keys(BUNDLE_ENV_EXAMPLE)
|
||||
mentioned = set(_ENV_TOKEN_RE.findall(_doc_text()))
|
||||
assert mentioned, "в BUNDLED_SETUP.md не упомянут ни один env-ключ — док не полон"
|
||||
unknown = sorted(mentioned - canon)
|
||||
assert not unknown, (
|
||||
f"ключи из BUNDLED_SETUP.md отсутствуют в канонах (.env.example ∪ "
|
||||
f"deploy/bundled/.env.example) — опечатка или дрейф (TC-09): {unknown}"
|
||||
)
|
||||
|
||||
|
||||
def test_status_count_claim_matches_plane_sync():
|
||||
"""«22 статуса» держится фактическим маппингом src/plane_sync.py (AC-7:
|
||||
сверка импортом, не строковой копией)."""
|
||||
from src.plane_sync import _PLANE_NAME_TO_KEY
|
||||
|
||||
assert len(_PLANE_NAME_TO_KEY) == 22, (
|
||||
"число статусов в plane_sync изменилось — обнови BUNDLED_SETUP.md §7 "
|
||||
"(и ONBOARDING.md §1)"
|
||||
)
|
||||
assert "Confirm Deploy" in _PLANE_NAME_TO_KEY
|
||||
assert "STOP" in _PLANE_NAME_TO_KEY
|
||||
assert "22" in _doc_text(), "число статусов в BUNDLED_SETUP.md разъехалось с plane_sync"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10: канон не форкается — кросс-ссылки; REPLICATION §1 отмечает Type B.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_doc_links_canons_instead_of_forking():
|
||||
text = _doc_text()
|
||||
for canon in ("LITE_SETUP.md", "ONBOARDING.md", "REPLICATION.md"):
|
||||
assert canon in text, f"BUNDLED_SETUP.md не ссылается на канон {canon} (FR-4)"
|
||||
bodies = _section_bodies()
|
||||
assert "LITE_SETUP.md" in bodies["## 8. LLM (claude CLI)"], "§8 — ссылкой на LITE_SETUP §7"
|
||||
assert "LITE_SETUP.md" in bodies["## 9. Telegram"], "§9 — ссылкой на LITE_SETUP §8"
|
||||
assert "ONBOARDING.md" in bodies["## 10. Онбординг следующих проектов"]
|
||||
assert "REPLICATION.md" in bodies["## 11. Smoke"], "smoke — на REPLICATION §4, без форка"
|
||||
|
||||
|
||||
def test_replication_marks_type_b_done():
|
||||
text = REPLICATION.read_text(encoding="utf-8")
|
||||
assert "BUNDLED_SETUP.md" in text, (
|
||||
"REPLICATION.md §1 обязан ссылаться на BUNDLED_SETUP.md (Type B реализован)"
|
||||
)
|
||||
assert "ORCH-103" in text, "строка Type B в REPLICATION.md §1 не отмечена ✅ ORCH-103"
|
||||
|
||||
|
||||
def test_lite_setup_untouched_reference_exists():
|
||||
"""Канон Lite остаётся на месте (Bundled его дополняет, не заменяет)."""
|
||||
assert LITE_SETUP.is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11: CHANGELOG.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_changelog_has_orch_103_entry():
|
||||
assert "ORCH-103" in CHANGELOG.read_text(encoding="utf-8")
|
||||
Reference in New Issue
Block a user