diff --git a/.gitignore b/.gitignore index 71efb16..51128ba 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ data/ .env.watchdog # ORCH-31: staging DB data directory data/staging/ +# ORCH-103: Bundled-тираж — локальные клоны репо bundle-инсталляции (целевой хост); +# deploy/bundled/.env и deploy/bundled/data покрыты неякорными `.env` / `data/` выше +deploy/bundled/repos/ diff --git a/.task-dev.md b/.task-dev.md index e4b3c90..0a85c2a 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-009 +Work item: ORCH-103 Repo: orchestrator -Branch: feature/ORCH-009-turnkey-plane +Branch: feature/ORCH-103-orch-10b-bundled-bootstrap Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9cbaf..a41bc74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт** (ORCH-103, `feat`): закрыт Type B эпика ORCH-10 — заказчик **без собственной инфраструктуры** получает конвейер «под ключ»: одна команда `docker compose -f deploy/bundled/docker-compose.yml up -d` поднимает весь стек (орк + watchdog + Gitea + зеркало upstream Plane CE ≈14 контейнеров), один прогон `scripts/bootstrap_bundle.py apply` доводит его до рабочего состояния. Рантайм байт-в-байт: `src/**`/корневой compose/`Dockerfile`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — ноль изменений (паттерн ORCH-009/102, kill-switch не нужен — активация только явным запуском оператора на целевом хосте). ADR: `docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`, сквозной `adr-0038-bundled-replication-canon.md`. + - **Bundle-compose (D1–D4):** новый top-level каталог `deploy/` (дистрибутивы развёртывания); `deploy/bundled/docker-compose.yml` — один самодостаточный файл, project name `orchestrator-bundle` (узнаваемый префикс томов/контейнеров, по нему preflight детектирует «грязный хост»); `container_name` не пиннится (bundle и Lite не сталкиваются на одном хосте); staging-контура орка нет вовсе (self-hosting у заказчика = маршрут Lite). Все сторонние образы пиннованы неподвижными тегами (Plane CE v0.23.1 upstream-имена сервисов, Gitea 1.22.6, postgres/valkey/rabbitmq/minio). Сеть — одна bridge: машинный трафик строго сервис-DNS (`http://orchestrator:8500/webhook/plane|gitea`, `ORCH_GITEA_URL=http://gitea:3000`), наружу — только человеческие порты `BUNDLE_ORCH_PORT`/`BUNDLE_PLANE_PORT`/`BUNDLE_GITEA_HTTP_PORT`; postgres/redis/mq/minio не публикуются; мина Gitea закрыта `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator`. Конфиг-канон — `deploy/bundled/.env.example` (только плейсхолдеры, ни одного дефолтного пароля; key-set-sync интерполяций держит тест); runtime-конфиг орка/watchdog — корневые `.env`/`.env.watchdog` (канон Lite 1:1, `env_file required: false` — первый `up` живёт до сборки конфига). + - **Bootstrap (D5–D8):** `scripts/bootstrap_bundle.py` — python stdlib-only (модули платформы не импортируются, держится ast-сканом), режимы `plan` (дефолт, ноль мутаций) / `apply` / `verify`, step-движок check→ensure (повторный запуск = каскад skip, resume после manual-step = повторный запуск), exit-контракт 0/2/1. Шаги: preflight (fail-fast ДО мутаций: docker/compose, порты, RAM/диск, чистота хоста по префиксу) → секреты (webhook — **строго** субпроцессом `gen_secrets.py`; bundle-креды — stdlib `secrets`; существующие не перетираются без `--force-secrets`; значения не печатаются) → up+готовность (healthchecks + poll, migrator exit 0) → init Gitea полностью автоматом (`gitea admin user create`/`generate-access-token`; branch protection НЕ настраивается — норматив D10 ORCH-009/INV-4) → init Plane (честные manual-step c API-верификацией результата; workspace-webhook — ensure с fallback на manual-step) → онбординг sandbox-проекта **строго** `onboard_project.py apply+verify` (нулевой дрейф канона статусов/лейблов) → git-доступ агентов HTTP token-remote (ssh-контур не вводится) → сборка корневых `.env`/`.env.watchdog` (bootstrap — единственный писатель, права 600) → health/итоговая сводка PASS/FAIL. Delete-операций НЕТ вообще (D9): teardown — только документированная процедура. + - **Док-канон (D10):** `docs/deployment/BUNDLED_SETUP.md` — 14 разделов в порядке маршрута оператора (рамка → требования к хосту с цифрами RAM/диск/CPU и картой портов («Plane ≈ 14 контейнеров») → предусловия → код → секреты → запуск → bootstrap с перечнем manual-step → LLM/Telegram/онбординг ссылками на LITE_SETUP §7–§8/ONBOARDING → smoke (REPLICATION §4) → stateless-проверка → остановка/полный сброс → траблшутинг); каждый шаг = fenced-команда + «Проверка:» PASS/FAIL; REPLICATION.md §1 — строка Type B → ✅ ORCH-103. **Норматив сопровождения (NFR-5):** меняешь шаги Bundled-тиража → обнови BUNDLED_SETUP.md в том же PR. + - **Анти-дрейф (D11):** три структурных тест-модуля без docker/сети/LLM — `tests/test_bundle_compose.py` (состав сервисов, пины образов, изоляция томов, key-set-sync, заморозка корневого compose), `tests/test_bundled_setup_doc.py` (14 разделов, FORBIDDEN — импорт из `test_no_host_hardcodes.py`, секрет-эвристика hex≥32/alnum≥40, env-ключи ⊆ канонов, «22 статуса» импортом `plane_sync`, кросс-рефы, CHANGELOG), `tests/test_bootstrap_script.py` (кирпичи, stdlib-only, нет delete-операций/своего списка статусов, unit чистых функций preflight/плана/рендера, exit 0/2/1). `.gitignore` дополнен `deploy/bundled/repos/` (клоны целевого хоста не коммитятся; `.env`/`data/` уже покрыты неякорными паттернами). - **ORCH-10a Lite-тираж: инструкция LITE_SETUP + канон watchdog-конфига + анти-дрейф контур** (ORCH-102, `docs`): закрыт Type A эпика ORCH-10 — заказчик разворачивает у себя **только орк+watchdog** и донастраивает окружение (Plane/Gitea/Telegram/LLM) по одной сквозной инструкции «голый хост → работающий конвейер». **Docs+tests** (паттерн ORCH-077/092): `src/**`/`docker-compose.yml`/`Dockerfile`/`scripts/**` — ноль изменений; конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД) — байт-в-байт. ADR: `docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`, сквозной `adr-0037-lite-replication-canon.md`. - **Главный продукт (D1/D2):** новый docs-раздел `docs/deployment/` (витрина тиража, читатель — внешний оператор) с golden source `docs/deployment/LITE_SETUP.md` — 13 нормативных разделов в порядке маршрута оператора (рамка → предусловия хоста → перенос кода → конфигурация → Plane → Gitea → LLM → Telegram → запуск → регистрация проекта → smoke → stateless-проверка → траблшутинг ×7); каждый шаг = fenced-команда + явная «Проверка:»/PASS/FAIL; хост-специфика — только плейсхолдеры `<...>`/`$ENV_VAR`; канон не форкается — статусы/env/вебхуки/smoke ссылками на ONBOARDING §1 / REPLICATION §2–§4 / SETUP_WEBHOOKS. - **Канон watchdog-конфига (D5, исход А-4):** новый `.env.watchdog.example` (третий env-example; key-set = блок `WATCHDOG_*` `.env.example`, 19 ключей, токены — пустые плейсхолдеры) закрывает ловушку файла-носителя: sidecar читает ТОЛЬКО `.env.watchdog`, ключ `WATCHDOG_*` в `.env` для него инертен; шапка несёт C-1 (ORCH-100: свой бот, токен орка переиспользовать запрещено) и когерентность порта `WATCHDOG_METRICS_URL` ⇄ `ORCH_DEPLOY_PROD_TARGET_PORT`; `.env.watchdog` добавлен в `.gitignore` (секрет-гигиена, зеркало `.env.staging`). diff --git a/CLAUDE.md b/CLAUDE.md index 926fdd1..04f0592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -350,6 +350,36 @@ API → `manual-step` (fail-safe); **runbook** `docs/operations/ONBOARDING.md` ( `docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`, сквозной `docs/architecture/adr/adr-0037-lite-replication-canon.md`. +## Bundled-тираж: весь стек одним комплектом (ORCH-103) +Закрыт **Type B** эпика ORCH-10 (поверх 10-common ORCH-101 и канона Lite ORCH-102): заказчик +**без собственной инфраструктуры** получает весь стек одним комплектом — новый top-level каталог +**`deploy/bundled/`** (самодостаточный compose: орк + watchdog + Gitea + зеркало upstream Plane CE +≈14 контейнеров; project name `orchestrator-bundle` = узнаваемый префикс томов/контейнеров; +`container_name` не пиннится; staging-контура нет вовсе — самразвитие платформы у заказчика = +маршрут Lite) + **`scripts/bootstrap_bundle.py`** (python stdlib-only, режимы `plan` (дефолт) / +`apply`/`verify`, step-движок check→ensure, exit 0/2/1), доводящий стек одним прогоном: preflight +(fail-fast до мутаций) → секреты (webhook — строго `gen_secrets.py`; bundle-креды — stdlib +`secrets`, без перетирания без `--force-secrets`) → up+готовность → init Gitea (полностью +автоматом, `gitea admin …`; branch protection НЕ включается — D10 ORCH-009/INV-4) → init Plane +(честные manual-step c API-верификацией; молчаливый пропуск запрещён) → онбординг sandbox-проекта +**строго** `onboard_project.py apply+verify` (22 статуса — `plane_sync._PLANE_NAME_TO_KEY`, нулевой +дрейф канона) → git-доступ агентов HTTP token-remote (ssh-контур не вводится) → сборка корневых +`.env`/`.env.watchdog` (bootstrap — единственный писатель live-конфигов) → health/итог. +Сеть — одна bridge, машинный трафик строго сервис-DNS (`http://orchestrator:8500/webhook/*`), +наружу — только человеческие порты (`BUNDLE_ORCH_PORT`/`BUNDLE_PLANE_PORT`/`BUNDLE_GITEA_HTTP_PORT`); +мина Gitea закрыта `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator`. Все сторонние образы пиннованы +неподвижными тегами; teardown — только документированная процедура BUNDLED_SETUP §13 (delete-операций +в скрипте НЕТ вообще). Рантайм байт-в-байт: `src/**`, корневой compose, `Dockerfile`, +`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты; kill-switch не нужен (активация — только +явный запуск оператором, паттерн ORCH-009/102). Golden source — `docs/deployment/BUNDLED_SETUP.md` +(14 разделов канона LITE_SETUP; общие шаги — ссылками на LITE_SETUP/ONBOARDING/REPLICATION). +Анти-дрейф — `tests/test_bundle_compose.py` (состав/пины/key-set-sync/заморозка корневого compose), +`tests/test_bundled_setup_doc.py` (разделы/FORBIDDEN-импорт/секрет-эвристика/env-ключи/кросс-рефы), +`tests/test_bootstrap_script.py` (кирпичи/stdlib-only ast-сканом/нет delete-операций/unit чистых +функций). **Норматив сопровождения (NFR-5):** меняешь шаги Bundled-тиража → обнови BUNDLED_SETUP.md +в том же PR. Детали — `docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`, +сквозной `docs/architecture/adr/adr-0038-bundled-replication-canon.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/deploy/bundled/.env.example b/deploy/bundled/.env.example new file mode 100644 index 0000000..caa55f2 --- /dev/null +++ b/deploy/bundled/.env.example @@ -0,0 +1,61 @@ +# deploy/bundled/.env — конфиг bundle-ИНФРЫ (ORCH-103, ADR-001 D2). +# Канонический example: 100% ключей интерполяции deploy/bundled/docker-compose.yml +# (key-set-sync держит tests/test_bundle_compose.py) + ключи init-кред, которые +# заполняет bootstrap. Создание: cp .env.example .env (или это сделает +# scripts/bootstrap_bundle.py apply); права 600. +# +# ⚠️ СЕМАНТИКА ФАЙЛА-НОСИТЕЛЯ (TR-8): этот файл читает ТОЛЬКО compose-интерполяция +# bundle (авто-чтение .env из project dir deploy/bundled/). Runtime-конфиг самого +# оркестратора и watchdog — КОРНЕВЫЕ .env / .env.watchdog (каноны Lite 1:1: +# .env.example / .env.watchdog.example, карта — docs/operations/REPLICATION.md §2). +# Единственный писатель всех live-файлов — scripts/bootstrap_bundle.py: дублируемые +# ключи (uid/gid, HOME, пути Claude CLI) когерентны механически, не дисциплиной. +# +# DO NOT COMMIT реальный deploy/bundled/.env (покрыт неякорным `.env` в .gitignore). +# Секреты: НИ ОДНОГО дефолтного пароля — пустые значения ниже генерирует bootstrap +# (stdlib secrets) и никогда не печатает (NFR-3); повторный запуск НЕ перетирает +# существующие значения без явного --force-secrets. + +# --- Публичная точка инсталляции ------------------------------------------- +# Хост, по которому браузер оператора открывает Plane/Gitea и по которому +# строятся публичные ссылки (ORCH_GITEA_PUBLIC_URL / ORCH_PLANE_WEB_URL / WEB_URL +# Plane / ROOT_URL Gitea). HTTPS/домены/reverse-proxy заказчика — вне bundle. +BUNDLE_PUBLIC_HOST=localhost + +# --- Карта публикуемых портов (D4: только человеческие точки) --------------- +# Конфликт порта на хосте → отказ preflight bootstrap ДО любых мутаций (BR-7). +BUNDLE_ORCH_PORT=8500 +BUNDLE_PLANE_PORT=8080 +BUNDLE_GITEA_HTTP_PORT=3000 + +# --- Идентичность контейнера орка (реюз имён ORCH-101: один факт = одно имя) -- +# uid:gid владельца deploy/bundled/repos (инвариант ORCH-040); docker-gid хоста +# («МИНА 1», узнать: getent group docker). Заполняет bootstrap из id -u/-g/getent. +ORCH_RUN_UID=1000 +ORCH_RUN_GID=1000 +ORCH_DOCKER_GID=999 +# HOME всех акторов в контейнере (группа ORCH-040 двигается одной переменной). +ORCH_AGENT_HOME_DIR=/home/orchestrator + +# --- LLM-предусловие хоста заказчика (bundle НЕ поставляет Claude CLI) ------- +# Пути дистрибутива claude-code/node и кред CLI на хосте (канон — LITE_SETUP §7). +ORCH_HOST_CLAUDE_CODE_DIR=/usr/lib/node_modules/@anthropic-ai/claude-code +ORCH_HOST_NODE_BIN=/usr/bin/node +ORCH_HOST_CLAUDE_DIR=~/.claude +ORCH_HOST_CLAUDE_JSON=~/.claude.json + +# --- Внутренние креды Plane CE-стека (upstream-имена; значения — bootstrap) -- +POSTGRES_USER=plane +POSTGRES_PASSWORD= +POSTGRES_DB=plane +SECRET_KEY= +RABBITMQ_DEFAULT_USER=plane +RABBITMQ_DEFAULT_PASS= +RABBITMQ_DEFAULT_VHOST=plane +MINIO_ROOT_USER=plane-minio-admin +MINIO_ROOT_PASSWORD= + +# --- Init-креды Gitea (D6: один пользователь-бот = админ, владелец репо, +# носитель API-токена; создаёт bootstrap через `gitea admin user create`) -- +GITEA_ADMIN_USERNAME=orchestrator-bot +GITEA_ADMIN_PASSWORD= diff --git a/deploy/bundled/docker-compose.yml b/deploy/bundled/docker-compose.yml new file mode 100644 index 0000000..892b408 --- /dev/null +++ b/deploy/bundled/docker-compose.yml @@ -0,0 +1,338 @@ +# ORCH-103 (Type B Bundled, ADR-001 D1–D4): самодостаточный compose ВСЕГО стека +# для тиража «под ключ» на хост заказчика: orchestrator + orchestrator-watchdog + +# Gitea + Plane CE (зеркало официального selfhost-référence makeplane/plane +# v0.23.1: имена сервисов и env-контракт — upstream, анти-дрейф к их докам; наши +# отличия от référence: пиннинг неподвижными тегами литералом вместо ${APP_RELEASE} +# (NFR-6, держится tests/test_bundle_compose.py), убраны replicas/platform/SENTRY, +# секреты БЕЗ дефолтных значений — их генерирует scripts/bootstrap_bundle.py). +# +# Этот файл НЕ исполняется в нашем прод-контуре (корневой docker-compose.yml — +# байт-в-байт, заморожен анти-дрейфом ORCH-102); активация — только явный запуск +# оператором на целевом хосте (паттерн ORCH-009, kill-switch не нужен). +# +# Конфиг-слои (D2): интерполяции ${VAR} читаются compose'ом из deploy/bundled/.env +# (авто-чтение из project dir — без --env-file-футгана); канон ключей — +# deploy/bundled/.env.example (key-set-sync держит тест). Runtime-конфиг орка и +# watchdog — КОРНЕВЫЕ .env / .env.watchdog (канон Lite 1:1, REPLICATION §2); +# их единственный писатель — bootstrap_bundle.py. +# +# Сеть (D4): одна bridge-сеть проекта; машинный трафик — строго сервис-DNS +# (Plane→орк http://orchestrator:8500/webhook/plane, Gitea→орк .../webhook/gitea, +# орк→Plane http://proxy, орк→Gitea http://gitea:3000); network_mode: host НЕ +# используется (ssh-деплой-контур нашего хоста в bundle структурно спит — +# ORCH_DEPLOY_SSH_HOST пуст). Наружу публикуются ТОЛЬКО человеческие порты +# (орк/Plane proxy/Gitea web); postgres/redis/mq/minio не публикуются. +# +# Project name = узнаваемый префикс томов/контейнеров orchestrator-bundle_* (D1); +# container_name сознательно НЕ пиннится ни у кого — bundle и Lite/корневой +# compose не сталкиваются по именам на одном хосте. +name: orchestrator-bundle + +networks: + default: + name: orchestrator-bundle + driver: bridge + +# Env-контракт Plane CE — upstream-имена (référence v0.23.1). Значения секретов +# (POSTGRES_PASSWORD/SECRET_KEY/RABBITMQ_DEFAULT_PASS/MINIO_ROOT_PASSWORD) живут +# ТОЛЬКО в deploy/bundled/.env (генерирует bootstrap); дефолтных паролей нет. +x-plane-env: &plane-env + environment: + - WEB_URL=http://${BUNDLE_PUBLIC_HOST:-localhost}:${BUNDLE_PLANE_PORT:-8080} + - DEBUG=0 + - CORS_ALLOWED_ORIGINS=http://${BUNDLE_PUBLIC_HOST:-localhost}:${BUNDLE_PLANE_PORT:-8080} + - GUNICORN_WORKERS=1 + # db (upstream-имена; host/port — фиксированные сервис-DNS этого файла) + - PGHOST=plane-db + - PGDATABASE=${POSTGRES_DB:-plane} + - POSTGRES_USER=${POSTGRES_USER:-plane} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB:-plane} + - POSTGRES_PORT=5432 + - PGDATA=/var/lib/postgresql/data + - DATABASE_URL=postgresql://${POSTGRES_USER:-plane}:${POSTGRES_PASSWORD}@plane-db:5432/${POSTGRES_DB:-plane} + # redis + - REDIS_HOST=plane-redis + - REDIS_PORT=6379 + - REDIS_URL=redis://plane-redis:6379/ + # rabbitmq + - RABBITMQ_HOST=plane-mq + - RABBITMQ_PORT=5672 + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER:-plane} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + - RABBITMQ_DEFAULT_VHOST=${RABBITMQ_DEFAULT_VHOST:-plane} + - RABBITMQ_VHOST=${RABBITMQ_DEFAULT_VHOST:-plane} + - AMQP_URL=amqp://${RABBITMQ_DEFAULT_USER:-plane}:${RABBITMQ_DEFAULT_PASS}@plane-mq:5672/${RABBITMQ_DEFAULT_VHOST:-plane} + # application secret (генерирует bootstrap; дефолта сознательно НЕТ) + - SECRET_KEY=${SECRET_KEY} + # datastore (minio) + - USE_MINIO=1 + - AWS_REGION= + - AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER:-plane-minio-admin} + - AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} + - AWS_S3_ENDPOINT_URL=http://plane-minio:9000 + - AWS_S3_BUCKET_NAME=uploads + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-plane-minio-admin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} + - BUCKET_NAME=uploads + - FILE_SIZE_LIMIT=5242880 + # live server + - API_BASE_URL=http://api:8000 + # proxy + - NGINX_PORT=80 + +services: + # ── Платформа: орк + sidecar-watchdog (образы собираются из этого же чекаута; + # корневой Dockerfile / watchdog/Dockerfile — без правок, NFR-1) ────────── + orchestrator: + build: + context: ../.. + # ORCH-101 (D5): uid/gid/home двигаются ОДНОЙ группой с user: и таргетами + # маунтов ниже (инвариант ORCH-040). Дефолты bundle нейтральны (D2). + args: + APP_UID: ${ORCH_RUN_UID:-1000} + APP_GID: ${ORCH_RUN_GID:-1000} + APP_HOME: ${ORCH_AGENT_HOME_DIR:-/home/orchestrator} + restart: unless-stopped + user: "${ORCH_RUN_UID:-1000}:${ORCH_RUN_GID:-1000}" + init: true + command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"] + ports: + # человеческая точка: операторский smoke `curl /health` (D4) + - "${BUNDLE_ORCH_PORT:-8500}:8500" + volumes: + # данные/репозитории — bind ВНУТРИ project dir (uid-причины ORCH-040; + # покрыты .gitignore: неякорный data/ + deploy/bundled/repos/) + - ./data:/app/data + - ./repos:/repos + - /var/run/docker.sock:/var/run/docker.sock + # LLM-предусловие хоста заказчика (bundle его НЕ поставляет, BRD §1.3) + - ${ORCH_HOST_CLAUDE_CODE_DIR:-/usr/lib/node_modules/@anthropic-ai/claude-code}:/opt/claude-code:ro + - ${ORCH_HOST_NODE_BIN:-/usr/bin/node}:/usr/bin/node:ro + - ${ORCH_HOST_CLAUDE_DIR:-~/.claude}:${ORCH_AGENT_HOME_DIR:-/home/orchestrator}/.claude + - ${ORCH_HOST_CLAUDE_JSON:-~/.claude.json}:${ORCH_AGENT_HOME_DIR:-/home/orchestrator}/.claude.json:ro + # ssh-контур в bundle сознательно НЕ вводится (ADR D8): git-доступ агентов + # — HTTP token-remote, деплой-хуки нашего хоста структурно спят. + # runtime-конфиг орка собирает bootstrap (шаг 8); required:false — первый + # `up -d` поднимает стек ДО сборки конфига (AC-1), орк жив без него. + env_file: + - path: ../../.env + required: false + environment: + - ORCH_REPOS_DIR=/repos + group_add: + - "${ORCH_DOCKER_GID:-999}" + + orchestrator-watchdog: + build: + context: ../.. + dockerfile: watchdog/Dockerfile + restart: unless-stopped + init: true + mem_limit: 128m + mem_reservation: 32m + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./repos:/repos:ro + - ./data:/app/data:ro + env_file: + - path: ../../.env.watchdog + required: false + environment: + # bundle-сеть ≠ host-network Lite: метрики — по сервис-DNS; имя контейнера + # орка детерминировано project name (container_name не пиннится, D1). + # environment перекрывает env_file → когерентность механическая (TR-8). + - WATCHDOG_METRICS_URL=http://orchestrator:8500/metrics + - WATCHDOG_CONTAINERS=orchestrator-bundle-orchestrator-1 + group_add: + - "${ORCH_DOCKER_GID:-999}" + + # ── Gitea (D6): официальный образ, НЕ rootless; init полностью автоматом — + # bootstrap создаёт админа/токен через `gitea admin ...` CLI в контейнере. + # Branch protection на main НЕ настраивается (норматив D10 ORCH-009/INV-4). + gitea: + image: gitea/gitea:1.22.6 + restart: unless-stopped + ports: + - "${BUNDLE_GITEA_HTTP_PORT:-3000}:3000" + environment: + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__security__INSTALL_LOCK=true + - GITEA__server__DOMAIN=${BUNDLE_PUBLIC_HOST:-localhost} + - GITEA__server__ROOT_URL=http://${BUNDLE_PUBLIC_HOST:-localhost}:${BUNDLE_GITEA_HTTP_PORT:-3000}/ + # ssh-контур не вводится (D8): порт не публикуется, ssh выключен. + - GITEA__server__DISABLE_SSH=true + - GITEA__service__DISABLE_REGISTRATION=true + # МИНА TR-4 (D4): Gitea по умолчанию режет webhook'и в приватные адреса — + # без этой строки «задача не появилась» гарантирован. + - GITEA__webhook__ALLOWED_HOST_LIST=orchestrator + volumes: + - gitea-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/healthz"] + interval: 10s + timeout: 5s + retries: 12 + + # ── Plane CE — зеркало upstream selfhost-référence v0.23.1 (D3) ──────────── + web: + <<: *plane-env + image: makeplane/plane-frontend:v0.23.1 + restart: unless-stopped + command: node web/server.js web + depends_on: + - api + - worker + + space: + <<: *plane-env + image: makeplane/plane-space:v0.23.1 + restart: unless-stopped + command: node space/server.js space + depends_on: + - api + - worker + - web + + admin: + <<: *plane-env + image: makeplane/plane-admin:v0.23.1 + restart: unless-stopped + command: node admin/server.js admin + depends_on: + - api + - web + + live: + <<: *plane-env + image: makeplane/plane-live:v0.23.1 + restart: unless-stopped + command: node live/dist/server.js live + depends_on: + - api + - web + + api: + <<: *plane-env + image: makeplane/plane-backend:v0.23.1 + restart: unless-stopped + command: ./bin/docker-entrypoint-api.sh + volumes: + - logs_api:/code/plane/logs + depends_on: + - plane-db + - plane-redis + - plane-mq + + worker: + <<: *plane-env + image: makeplane/plane-backend:v0.23.1 + restart: unless-stopped + command: ./bin/docker-entrypoint-worker.sh + volumes: + - logs_worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + - plane-mq + + beat-worker: + <<: *plane-env + image: makeplane/plane-backend:v0.23.1 + restart: unless-stopped + command: ./bin/docker-entrypoint-beat.sh + volumes: + - logs_beat-worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + - plane-mq + + migrator: + <<: *plane-env + image: makeplane/plane-backend:v0.23.1 + restart: "no" + command: ./bin/docker-entrypoint-migrator.sh + volumes: + - logs_migrator:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + plane-db: + <<: *plane-env + image: postgres:15.7-alpine + restart: unless-stopped + command: postgres -c 'max_connections=1000' + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 12 + + plane-redis: + <<: *plane-env + image: valkey/valkey:7.2.5-alpine + restart: unless-stopped + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 12 + + plane-mq: + <<: *plane-env + image: rabbitmq:3.13.6-management-alpine + restart: always + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 15s + timeout: 10s + retries: 12 + + plane-minio: + <<: *plane-env + # upstream-référence держит latest — bundle пиннит неподвижный тег (NFR-6) + image: minio/minio:RELEASE.2024-05-28T17-19-04Z + restart: unless-stopped + command: server /export --console-address ":9090" + volumes: + - uploads:/export + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 12 + + proxy: + <<: *plane-env + image: makeplane/plane-proxy:v0.23.1 + restart: unless-stopped + ports: + # человеческая точка: UI Plane в браузере оператора (D4) + - "${BUNDLE_PLANE_PORT:-8080}:80" + depends_on: + - web + - api + - space + +# Состояние Plane/Gitea — именованные тома проекта (префикс orchestrator-bundle_, +# D1/D2); preflight bootstrap детектирует «грязный хост» по этому префиксу. +volumes: + pgdata: + redisdata: + uploads: + logs_api: + logs_worker: + logs_beat-worker: + logs_migrator: + rabbitmq_data: + gitea-data: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7212b1c..aaeb882 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -210,6 +210,41 @@ 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).** Закрывает эпик ORCH-10: весь стек одним комплектом +(орк + watchdog + Gitea + Plane CE ≈13–14 контейнеров) для заказчика без собственной +инфраструктуры. Состав 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 на одном хосте), пиннинг сторонних образов +неподвижными тегами литералом (не `latest`); корневой compose не форкается (заморожен +анти-дрейфом ORCH-102); staging-контур орка в bundle отсутствует, репо `orchestrator` не +регистрируется → self-deploy-машинерия структурно спит (`SELF_HOSTING_REPO`-леафы не матчатся). +Сеть — одна bridge: машинный трафик строго сервис-DNS (webhooks в обе стороны, API, /metrics), +наружу — только человеческие порты (Plane 8080 / Gitea 3000 / орк 8500; явный +`GITEA__webhook__ALLOWED_HOST_LIST=orchestrator` против дефолтного запрета приватных таргетов). +Конфиг-слои: `deploy/bundled/.env.example` (канон bundle-инфры, key-set-sync тест) → live +`deploy/bundled/.env` (авто-чтение compose из project dir, без `--env-file`-футгана); runtime +орка/watchdog — корневые `.env`/`.env.watchdog` ровно по канону Lite (`env_file: required: +false` до сборки); **единственный писатель live-файлов — bootstrap**. +`scripts/bootstrap_bundle.py` (python stdlib-only, `plan`-дефолт/`apply`/`verify`, step-движок +check→ensure, exit 0/2/1): preflight fail-fast до мутаций → секреты (`gen_secrets.py` + +stdlib-креды стека, в логи не печатаются) → up+ожидание готовности → init Gitea (полностью +автоматом через CLI; branch protection НЕ включать — D10 ORCH-009) → init Plane CE (честные +manual-step: инструкция → подтверждение → API-верификация результата) → онбординг +sandbox-проекта строго `onboard_project.py apply`/`verify` (host-venv, канон ONBOARDING) → +git-доступ агентов token-remote (`_push_url`-паттерн; ssh-контур не вводится) → сборка env +орка → health/итог; delete-операций в скрипте нет — teardown только документированной +процедурой (§13). Golden source — `docs/deployment/BUNDLED_SETUP.md` (14 разделов по канону +LITE_SETUP, требования к хосту по замеру тестового развёртывания; REPLICATION §1 — отметка +Type B). Анти-дрейф — `tests/test_bundle_compose.py` / `test_bundled_setup_doc.py` / +`test_bootstrap_script.py`. Рантайм/конвейер — байт-в-байт; kill-switch не нужен (активация — +только явный запуск оператора на целевом хосте, паттерн ORCH-009). Подробнее: +[adr-0038](adr/adr-0038-bundled-replication-canon.md), детально — +`docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`. + ## Конвейер и Quality Gates ``` diff --git a/docs/architecture/adr/adr-0038-bundled-replication-canon.md b/docs/architecture/adr/adr-0038-bundled-replication-canon.md new file mode 100644 index 0000000..76a3494 --- /dev/null +++ b/docs/architecture/adr/adr-0038-bundled-replication-canon.md @@ -0,0 +1,114 @@ +--- +work_item: ORCH-103 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# adr-0038: Канон Bundled-тиража — `deploy/bundled/` + bootstrap + `BUNDLED_SETUP.md` (ORCH-103, 10b) + +## Статус +Proposed + +## Контекст + +Эпик ORCH-10 (D5 «Масштаб»), тип **B — Bundled**: заказчик без собственной инфраструктуры +получает **весь стек одним комплектом** (орк + watchdog + Gitea + Plane CE ≈13–14 контейнеров) и +bootstrap, доводящий его до рабочего конвейера одним запуском. Фундамент готов: 10-common +(ORCH-101, adr-0036 — хост-параметризация/секреты/smoke) и Lite (ORCH-102, adr-0037 — док-канон +`docs/deployment/`). Корневой `docker-compose.yml` заморожен анти-дрейфом ORCH-102 (ровно 3 +сервиса, запрет подстрок `plane`/`gitea`) → комплект обязан жить отдельным файлом. + +Сквозной характер: вводится новый top-level каталог `deploy/` (дистрибутивы развёртывания), +новый канонический env-example и нормативы, обязательные для будущих задач эпика ORCH-10 и +любого агента, меняющего шаги тиража. Детальный пакет решений (D1…D11, исходы OQ-1…OQ-7 ТЗ) — +work-item ADR: `docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`. + +## Решение + +1. **Новый top-level каталог `deploy/` — исполняемые дистрибутивы развёртывания** (дополняет + `docs/deployment/` — инструкции). Bundled-комплект: **`deploy/bundled/docker-compose.yml`** — + один самодостаточный compose всего стека с top-level `name: orchestrator-bundle` (project + name = узнаваемый префикс томов/контейнеров; `container_name` не пиннится — нет коллизий с + корневым compose на одном хосте). Staging-контур орка в bundle **отсутствует вовсе**; репо + `orchestrator` в bundle-инсталляции не регистрируется → self-deploy-машинерия структурно спит + (`SELF_HOSTING_REPO`-леафы не матчатся). +2. **Конфиг-слои:** `deploy/bundled/.env.example` — канон bundle-инфры (committed, плейсхолдеры; + key-set-sync тест: каждая `${VAR}`-интерполяция bundle-compose имеет ключ в каноне) → live + `deploy/bundled/.env` (авто-чтение compose из project dir — без `--env-file`-футгана; покрыт + неякорным `.env` в `.gitignore`); runtime орка/watchdog — **корневые `.env`/`.env.watchdog` + ровно по канону Lite** (REPLICATION §2 применим 1:1), в bundle-compose — `env_file: + required: false` (первый `up` жив до сборки конфига). **Bootstrap — единственный писатель** + всех трёх live-файлов (когерентность дублируемых ключей — механическая). Один факт = одно имя + (ORCH-101 D1): существующие факты — существующие `ORCH_*`-имена; bundle-only — `BUNDLE_*`; + внутренние креды Plane — upstream-имена. +3. **Состав/пиннинг:** Plane CE — зеркало официального selfhost-référence (upstream-имена + сервисов/env); Gitea — `gitea/gitea` (не rootless). Пиннинг — **точный неподвижный тег + литералом** (не `latest`, не интерполяция; digest не требуется); точные теги фиксирует + developer по проверенному стенду; форму держит структурный тест. +4. **Сеть:** одна именованная bridge-сеть; машинный трафик — строго сервис-DNS + (`http://orchestrator:8500/webhook/*`, `http://gitea:3000`, plane-proxy); `network_mode: host` + в bundle не используется (ssh-деплой-пути неактивны: `ORCH_DEPLOY_SSH_HOST` пуст). Наружу — + только человеческие порты (Plane proxy 8080 / Gitea 3000 / орк 8500; конфигурируемы); + БД/брокер/minio не публикуются. Публичные URL — от `BUNDLE_PUBLIC_HOST` (split internal/public + уже в конфиге орка). Мина Gitea закрывается явно: `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator`. +5. **Bootstrap `scripts/bootstrap_bundle.py`:** python stdlib-only, без импортов из `src/**`; + режимы `plan` (дефолт, ноль мутаций) / `apply` / `verify`; step-движок check→ensure + (идемпотентность, resume = повторный запуск); exit `0/2/1`. Preflight fail-fast до мутаций + (docker/порты/чистота томов по префиксу/RAM/диск; Claude CLI — warning). **Кирпичи не + дублируются:** секреты — субпроцесс `gen_secrets.py`; статусы/лейблы/репо/вебхуки — строго + `onboard_project.py apply`+`verify` (host-venv, канон ONBOARDING). Init Gitea — полностью + автоматом (CLI в контейнере; branch protection НЕ настраивается — D10 ORCH-009/adr-0037 п.4); + init Plane CE — честные **manual-step чекпоинты** (инструкция → подтверждение → + API-верификация; прогрессивная автоматизация разрешена без смены контракта). Git-доступ + агентов — HTTP token-remote (паттерн `_push_url`); ssh-контур не вводится. Секреты в + логи не печатаются; delete-операций в скрипте нет вообще — teardown только документированной + процедурой (`BUNDLED_SETUP` §13). +6. **Док-канон:** `docs/deployment/BUNDLED_SETUP.md` — 14 нормативных разделов по форме + LITE_SETUP (fenced-команда + «Проверка:» PASS/FAIL, плейсхолдеры, общие шаги ссылками на + LITE_SETUP/ONBOARDING/REPLICATION — канон не форкается), включая «Требования к хосту» с + цифрами **по замеру** тестового развёртывания. REPLICATION §1: Type B → ✅ ORCH-103. + **Норматив сопровождения:** изменил шаги Bundled-тиража → обнови BUNDLED_SETUP.md в том же PR. +7. **Анти-дрейф — постоянная CI-гарантия:** `tests/test_bundle_compose.py` / + `test_bundled_setup_doc.py` / `test_bootstrap_script.py` (структурные, без docker/сети/LLM: + состав сервисов, заморозка корневого compose, пины, key-set-sync, разделы дока, FORBIDDEN — + импортом из `test_no_host_hardcodes.py`, секрет-эвристика, ссылки на кирпичи, отсутствие + delete-операций, unit чистых функций preflight/плана, exit-контракт). + +### Что НЕ меняется +`src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.gitea/workflows/**`, `onboarding/**`, +промпты `.openclaw/agents/**`; `STAGE_TRANSITIONS`, состав `QG_CHECKS`, семантика `check_*`, +machine-verdict ключи, схема БД — байт-в-байт. Kill-switch не вводится (активация — только явный +запуск оператора на целевом хосте, паттерн ORCH-009). Прод-контейнер в рамках задачи не +рестартуется; наши данные/секреты не переносятся (stateless, решение Владельца 10.06). + +## Альтернативы +- **Расширение корневого compose (профиль `bundled`)** — отвергнуто: заморожен анти-дрейфом + ORCH-102/нормативом «compose не форкается»; смешение дистрибутива с боевым контуром. +- **Include-композиция / live-env через `--env-file`** — отвергнуто: лишние степени свободы + запуска, молчаливые дефолты при забытом флаге. +- **Орк в bundle на host-network + `host-gateway`** — отвергнуто: хост-сеть нужна была + ssh-деплой-контуру нашего хоста, который в bundle спит; bridge даёт чистые двунаправленные + сервис-DNS-URL. +- **Digest-пиннинг / rootless-Gitea / ssh-доступ агентов / bash-bootstrap / reset-режим + скрипта** — отвергнуты (см. work-item ADR-001, «Альтернативы»). + +## Последствия +- Эпик ORCH-10 закрыт по обоим типам: A (Lite, инструкция) + B (Bundled, комплект); заказчик + без инфраструктуры разворачивает конвейер «под ключ». +- Цена: пиннованные версии Plane/Gitea стареют (апгрейд — отдельные задачи); manual-step Plane CE + размывают «одну команду» — неустранимо честно (нет API), митигировано контрактом чекпоинта; + двойной `.env`-слой — под единственным писателем-bootstrap и key-sync тестом. +- Откат: удалить `deploy/`, bootstrap, BUNDLED_SETUP.md, три тест-модуля, строку REPLICATION §1 — + состояние 1:1 (docs+scripts+tests, без миграций). + +## Связи +adr-0036 (ORCH-101 — фундамент 10-common: параметризация, gen_secrets, REPLICATION/smoke), +adr-0037 (ORCH-102 — док-канон `docs/deployment/`, compose-подмножество, запрет branch +protection), adr-0035 (ORCH-009 — onboarding-CLI: 22 статуса, manual-step паттерн, `_push_url`, +D10), adr-0027/INV-4 (merge-актор — основание норматива Gitea), adr-0001 +(`SELF_HOSTING_REPO`-конвенция — почему self-гейты в bundle спят). Детально — +`docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`, +`07-infra-requirements.md`, `10-tech-risks.md`. diff --git a/docs/deployment/BUNDLED_SETUP.md b/docs/deployment/BUNDLED_SETUP.md new file mode 100644 index 0000000..f0163e0 --- /dev/null +++ b/docs/deployment/BUNDLED_SETUP.md @@ -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 <путь-чекаута> +cd <путь-чекаута> +ls deploy/bundled/docker-compose.yml deploy/bundled/.env.example \ + scripts/bootstrap_bundle.py scripts/gen_secrets.py scripts/onboard_project.py +``` + +**Проверка:** все пять файлов на месте — PASS. Канал дистрибуции +(``) согласуйте с поставщиком платформы (как в +`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 --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//" +``` + +**Проверка:** оба направления связности живы — 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///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` (каноны ключей).* diff --git a/docs/operations/REPLICATION.md b/docs/operations/REPLICATION.md index 315a267..c5a5cf2 100644 --- a/docs/operations/REPLICATION.md +++ b/docs/operations/REPLICATION.md @@ -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). diff --git a/docs/work-items/ORCH-103/00-business-request.md b/docs/work-items/ORCH-103/00-business-request.md new file mode 100644 index 0000000..adc415e --- /dev/null +++ b/docs/work-items/ORCH-103/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +Work Item ID: ORCH-103 + +## Description + +TBD diff --git a/docs/work-items/ORCH-103/01-brd.md b/docs/work-items/ORCH-103/01-brd.md new file mode 100644 index 0000000..c8c60f1 --- /dev/null +++ b/docs/work-items/ORCH-103/01-brd.md @@ -0,0 +1,199 @@ +--- +work_item: ORCH-103 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 01 — BRD: ORCH-103 — ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +Work Item: **ORCH-103** · Repo: **orchestrator** (self-hosting) · Стадия: analysis +Заказчик: Слава · Эпик: **ORCH-10** (домен D5 «Масштаб», `docs/epics/self-evolution.md`) · Тип: **B — Bundled** + +--- + +## 1. Бизнес-контекст и проблема + +### 1.1. Цель эпика ORCH-10 +Тираж платформы — РАЗДАЧА текущей функциональности нескольким заказчикам **на тест**. +Решения Владельца 10.06 (приняты как требования): ДВА типа тиража, ОБА **stateless** +(наши задачи/данные/секреты НЕ переносим — чистый старт): + +- **Тип A (Lite, ORCH-102 ✅)** — переносится ТОЛЬКО орк+watchdog; Plane/Gitea/LLM/Telegram + заказчик донастраивает сам по инструкции `docs/deployment/LITE_SETUP.md`. +- **Тип B (Bundled, эта задача)** — **весь стек одним комплектом** + (орк + watchdog + Gitea + Plane + рантайм-обвязка агентов) — «под ключ». + +### 1.2. Проблема, которую закрывает ORCH-103 +Lite предполагает, что у заказчика **уже есть** (или он сам поднимет) свои Plane и Gitea. +Для заказчика без собственной инфраструктуры это барьер: Plane CE self-hosted — это ~14 +контейнеров со своей БД/брокером/хранилищем, Gitea — отдельная установка, и поверх всего — +первичная инициализация (админы, токены, workspace, 22 статуса, лейблы, вебхуки в обе стороны, +git-доступ агентов). Сегодня репо не содержит ни compose-описания этого стека, ни автоматизации +его доводки: разворачивание «с нуля до работающего конвейера» = многочасовая ручная работа по +сторонним докам с рисками дефолтных паролей и дрейфа от канона платформы. + +ORCH-103 должен дать: **один compose-комплект** всего стека + **bootstrap-скрипт**, доводящий +свежеподнятый стек до рабочего состояния одной командой/визардом, + **новые секреты** на каждую +инсталляцию + **инструкцию `docs/deployment/BUNDLED_SETUP.md`** с требованиями к хосту. + +### 1.3. Установленные факты (проверено по репо — не изобретать) +- **Корневой `docker-compose.yml` защищён анти-дрейфом:** ровно 3 сервиса + (`orchestrator`, `orchestrator-watchdog`, `orchestrator-staging` за `profiles: ["staging"]`); + `tests/test_lite_setup_doc.py` (TC-04) проверяет точное множество сервисов и **запрещает** + появление в нём имён/образов с подстроками `plane`/`gitea` → bundle-компоуз обязан быть + **отдельным файлом**, корневой compose не форкается и не расширяется. +- **Кирпичи уже в `main` (переиспользовать, не дублировать):** + - `scripts/gen_secrets.py` (ORCH-101) — криптослучайные webhook-секреты + (`ORCH_PLANE_WEBHOOK_SECRET`/`ORCH_GITEA_WEBHOOK_SECRET`), печать по умолчанию, + `--write` отказывает при существующем `.env`, `--force` — перезапись; exit 0/2. + - `scripts/onboard_project.py` (ORCH-009) — `plan` (GET-only) / `apply` (идемпотентный ensure, + без delete) / `verify`: Plane-проект + **22 статуса** (read-only импорт + `plane_sync._PLANE_NAME_TO_KEY`, fail-closed имена `Confirm Deploy`/`STOP`) + лейблы + `autoApprove`/`autoDeploy`/`Bug`; Gitea-репо + per-repo webhook (`push`/`pull_request`/`status`, + ОДИН глобальный `ORCH_GITEA_WEBHOOK_SECRET`); недоступное в Plane CE API → `manual-step` + (fail-safe); exit 0/2/1. + - `docs/operations/REPLICATION.md` (ORCH-101) — карта env (§2), чек-лист секретов (§3), + **smoke §4** (шаги 0–6 с PASS/FAIL: config-резолв → `/health` → `/queue`+`/metrics` → + onboard plan/apply/verify → тестовая задача → артефакты `01–04` → опц. до `done`); §1 — + таблица границ, где Type B помечен «отдельная задача». + - `docs/deployment/LITE_SETUP.md` (ORCH-102) — канон тиражной инструкции: 13 нормативных + разделов, каждый шаг = fenced-команда + явная «Проверка:» PASS/FAIL, хост-специфика только + плейсхолдерами; канон не форкается — общие шаги ссылками. + - `.env.example` — канон 100% ключей орка; `.env.watchdog.example` — канон watchdog + (key-set-sync тестом, D5 ORCH-102). +- **Хост-параметризация завершена (ORCH-101):** платформа разворачивается без правки кода — + только env (`${VAR:-default}`-интерполяция compose, `ARG APP_*` Dockerfile); анти-регресс + `tests/test_no_host_hardcodes.py` (FORBIDDEN-литералы: IP/`/home/slin`/`mva154`/`duckdns`). +- **Claude CLI НЕ запечён в образ орка:** монтируется с хоста + (`ORCH_HOST_CLAUDE_CODE_DIR`/`ORCH_HOST_NODE_BIN`/`ORCH_HOST_CLAUDE_DIR`/`ORCH_HOST_CLAUDE_JSON`). + «Агенты» в комплекте = рантайм-обвязка запуска; **инсталляция Claude CLI и LLM-ключ — внешнее + предусловие хоста заказчика** (как Lite §7), bundle их не содержит и не генерирует. +- **Нормативы тиражной Gitea:** branch protection на `main` НЕ включать (D10 ORCH-009 / INV-4 — + мерж только через Gitea PR-merge API); pre-receive не вводится. +- **Plane CE self-hosted ≈ 14 контейнеров** (web/admin/space/api/worker/beat/live/migrator + + postgres/redis/mq/minio/proxy) — ресурсоёмко; часть первичной инициализации в CE недоступна + по API → честные ручные чекпоинты (паттерн `manual-step` ORCH-009). + +--- + +## 2. Объём (scope) + +### 2.1. В объёме +- **Bundle-compose** — отдельный compose-комплект всего стека: орк + watchdog + Gitea + + Plane-стек (~14 контейнеров); пиннинг версий; чистые именованные тома; согласованная + сетевая достижимость (вебхуки в обе стороны). +- **Bootstrap-скрипт** — один запуск (команда/визард): поднять всё → дождаться + готовности/миграций → инициализация Gitea (админ/токен) → инициализация Plane + (instance/workspace/API-токен; CE-ограничения → явные manual-step чекпоинты) → + онбординг sandbox-проекта (22 статуса/3 лейбла/репо/вебхуки — через `onboard_project.py`) → + git-доступ агентов → сборка `.env`/`.env.watchdog` орка → health → smoke-подсказка. +- **Инициализация секретов** — генерация НОВЫХ на каждую инсталляцию (reuse `gen_secrets.py` + + bundle-внутренние креды: пароли БД/брокера/хранилища Plane, админ Gitea); дефолтных паролей + в репо нет. +- **`docs/deployment/BUNDLED_SETUP.md`** — инструкция запуска bundle по канону LITE_SETUP, + включая **требования к хосту (RAM/диск/CPU/порты)**. +- **Структурные анти-дрейф тесты** (без docker/сети/LLM в CI) + полный зелёный pytest + CHANGELOG. +- Отметка Type B в `docs/operations/REPLICATION.md` §1 (границы трёх задач эпика). + +### 2.2. Вне объёма (явно, не делать) +- Изменения рантайма: `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.gitea/workflows/`, + `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт. +- Перенос наших задач/данных/секретов (stateless — решение Владельца 10.06). +- Автоматическая установка Claude CLI / выдача LLM-ключей / создание Telegram-ботов — + внешние предусловия заказчика (документируются, не автоматизируются). +- HTTPS/домены/публичный reverse-proxy заказчика — за рамками bundle (документируется + как ручной шаг при необходимости). +- Процедура обновления (upgrade) развёрнутого bundle; миграция Lite→Bundled; кластерные/ + multi-host топологии; мультитенантность (D5.6) и горизонтальный воркер-пул (D5.4). +- Какая-либо активация bundle на НАШЕМ боевом хосте. + +--- + +## 3. Заинтересованные стороны +- **Владелец (Слава)** — раздаёт платформу заказчикам на тест; принимает результат. +- **Оператор заказчика** — целевой читатель BUNDLED_SETUP.md: чистый Linux-хост, + docker+compose, без знания внутренностей платформы. +- **Self-hosting прод** (`orchestrator`, общий для всех проектов) — не должен быть затронут: + задача — артефакты репо (compose/скрипт/доки/тесты), активируемые только явным запуском + на ЦЕЛЕВОМ хосте. + +--- + +## 4. Бизнес-требования (BR) + +| ID | Требование | Связь | +|----|------------|-------| +| BR-1 | Единый bundle-compose (отдельный файл) поднимает ВЕСЬ стек одной командой: орк, watchdog, Gitea, Plane-стек. Корневой `docker-compose.yml` не форкается и не меняется. | AC-1, AC-6, FR-1 | +| BR-2 | Bootstrap-скрипт ОДНИМ запуском (команда/визард) доводит свежеподнятый стек до рабочего состояния: готовность/миграции → init Gitea → init Plane → онбординг sandbox-проекта → git-доступ агентов → конфиг орка → health. Шаги, физически недоступные через Plane CE API, оформляются явными интерактивными manual-step чекпоинтами (fail-safe, паттерн ORCH-009) — без молчаливых пропусков. | AC-1, FR-2 | +| BR-3 | После bootstrap smoke проходит: тестовый проект создан, тестовая задача доезжает минимум до артефактов `01–04` в ветке (минимальный сигнал REPLICATION §4 шаг 5); расширенно — до `done`. Вебхуки работают в ОБЕ стороны (Plane→орк, Gitea→орк, орк→Plane/Gitea API). | AC-2, FR-2/FR-6 | +| BR-4 | Stateless: каждая инсталляция стартует с чистых томов/БД (Plane, Gitea, орк) и НОВЫХ секретов (`gen_secrets.py` + bundle-внутренние креды). Боевые данные/секреты не используются ни на одном шаге; в репо нет ни одного реального секрета/дефолтного пароля. | AC-3, FR-3 | +| BR-5 | `docs/deployment/BUNDLED_SETUP.md` написан по канону LITE_SETUP (fenced-команды + «Проверка:» PASS/FAIL, плейсхолдеры вместо хост-специфики, канон не форкается — общие шаги ссылками на LITE_SETUP/ONBOARDING/REPLICATION) и фиксирует требования к хосту: RAM/диск/CPU/занимаемые порты (Plane ~14 контейнеров — ресурсоёмко). | AC-4, FR-4 | +| BR-6 | Переиспользование кирпичей без дублирования: секреты — `gen_secrets.py`; статусы/лейблы/репо/вебхуки — `onboard_project.py` (22 статуса — из `plane_sync._PLANE_NAME_TO_KEY`, нулевой дрейф); smoke — шаги REPLICATION §4. Bootstrap не реализует собственную копию этих канонов. | FR-2/FR-3, AC-7 | +| BR-7 | Идемпотентность/fail-safe: повторный запуск bootstrap безопасен (ensure/skip, без delete-операций); запуск на «грязном» хосте (существующие тома/занятые порты/нехватка ресурсов) → явный отказ preflight с понятной подсказкой, а не молчаливое переиспользование чужого состояния. | FR-2, AC-8 | +| BR-8 | Наш прод не затрагивается: вся задача — вне рантайма и вне конвейера; kill-switch не требуется (активация — только явный запуск человеком на целевом хосте, паттерн ORCH-009). | NFR-1/NFR-2, AC-6 | +| BR-9 | Анти-дрейф: структурные тесты держат bundle-канон (compose-структура, док-канон, env-ключи, FORBIDDEN-литералы, секрет-эвристика, кросс-ссылки); существующие `test_lite_setup_doc.py`/`test_no_host_hardcodes.py` остаются зелёными; полный `pytest tests/ -q` зелёный; CHANGELOG обновлён. | AC-5, AC-6, AC-7, FR-5 | + +--- + +## 5. Нефункциональные требования (NFR) + +| ID | Требование | +|----|------------| +| NFR-1 | **Рантайм/конвейер байт-в-байт:** `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, machine-verdict ключи, схема БД орка — не тронуты. Задача — docs+scripts+compose-bundle+tests. | +| NFR-2 | **Self-hosting безопасность:** ни один артефакт задачи не рестартит/не деплоит/не конфигурирует наш прод-контейнер; bundle-артефакты в нашем контуре инертны (никто их не исполняет). | +| NFR-3 | **Секрет-гигиена:** в репо не попадают реальные секреты, высокоэнтропийные литералы и хост-литералы (FORBIDDEN-скан `test_no_host_hardcodes.py` распространяется на новые артефакты); bootstrap не печатает секреты в лог; сгенерированные файлы конфигов — только на целевом хосте, в `.gitignore`. | +| NFR-4 | **Переносимость:** bundle не зависит от нашей инфраструктуры; вся хост-специфика — переменные/плейсхолдеры; целевая платформа — одиночный Linux x86_64 хост с docker+compose. | +| NFR-5 | **Норматив сопровождения** (зеркало NFR-5 ORCH-102): изменение шагов тиража в будущих задачах → обновление `BUNDLED_SETUP.md` в том же PR. | +| NFR-6 | **Воспроизводимость:** версии образов Gitea/Plane-стека зафиксированы (пиннинг тегов/digest, не `latest`); состав bundle детерминирован. | +| NFR-7 | **Без новых тяжёлых зависимостей:** bootstrap — в духе существующих скриптов (stdlib-инструментарий `gen_secrets.py`/`onboard_project.py`); точный стек (bash/python) — решение архитектора. | + +--- + +## 6. Допущения и ограничения +- Целевой хост: чистый одиночный Linux x86_64 с установленными docker + docker compose; + оператор имеет sudo. Прочие ОС — вне целевой платформы (best-effort). +- Ресурсы: Plane-стек ресурсоёмок; ориентир для проверки — **не менее 4 vCPU / 8 GB RAM / + 40 GB диска** (финальные минимумы УТОЧНЯЮТСЯ при реализации замером на тестовом + развёртывании и фиксируются в BUNDLED_SETUP.md — см. AC-4; цифры выше — гипотеза, не факт). +- Внешние предусловия заказчика (bundle не поставляет): инсталляция/аутентификация Claude CLI + + LLM-доступ Anthropic; Telegram-боты (трекер + watchdog) — опциональны, их отсутствие + деградирует только нотификации (never-raise), не конвейер. +- Часть инициализации Plane CE недоступна по API (instance-setup/workspace/API-токен) — + допускаются документированные интерактивные шаги внутри визарда; «одной командой» означает + «один запуск bootstrap с явными чекпоинтами», а не «ноль действий человека». +- Версии upstream-образов (Plane CE/Gitea) фиксируются на момент реализации; их обновление — + отдельные будущие задачи (NFR-6). + +--- + +## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md) +Пять AC из постановки Владельца (сохранены 1:1 как AC-1…AC-5) + производные проверяемые: +- AC-1 единый bundle-compose поднимает ВЕСЬ стек; bootstrap доводит до рабочего состояния + одной командой/визардом. +- AC-2 после bootstrap smoke проходит (тестовый проект + задача доезжает). +- AC-3 stateless (чистые Plane/Gitea/БД, новые секреты). +- AC-4 BUNDLED_SETUP.md + требования к хосту (RAM/диск) задокументированы. +- AC-5 pytest зелёный; CHANGELOG. +- AC-6 корневой compose/рантайм не тронуты (анти-дрейф зелёный); AC-7 нулевой дрейф канонов + (22 статуса/лейблы/секреты — через существующие кирпичи); AC-8 идемпотентность/fail-safe + bootstrap; AC-9 секрет-гигиена новых артефактов. + +--- + +## 8. Риски (детали — 10-tech-risks.md, заполняет архитектор) +- **R-1 Ресурсоёмкость Plane:** ~14 контейнеров → OOM/медленный старт на слабом хосте; + смягчение — preflight-проверка ресурсов + честные требования в доке (AC-4). +- **R-2 Дыры Plane CE API:** первичная инициализация частично UI-only → ручные чекпоинты; + риск — UX «одной команды» размывается; смягчение — явные manual-step с проверкой результата + (паттерн ORCH-009), минимизация числа ручных шагов. +- **R-3 Дрейф upstream-образов:** «плавающие» теги ломают воспроизводимость → пиннинг (NFR-6). +- **R-4 Сетевая достижимость вебхуков:** орк (host network) ⟷ Plane/Gitea (bridge-сеть bundle) + — двунаправленные URL должны быть согласованы bootstrap'ом; ошибка = «задача не появилась» + (труднодиагностируемо); смягчение — smoke проверяет оба направления. +- **R-5 Соблазн форкнуть корневой compose** (анти-дрейф TC-04 `test_lite_setup_doc.py` упадёт) + → bundle строго отдельным файлом. +- **R-6 Утечка секретов в логи/репо** при генерации bundle-кред → секрет-эвристика в тестах, + запрет печати секретов (NFR-3). diff --git a/docs/work-items/ORCH-103/02-trz.md b/docs/work-items/ORCH-103/02-trz.md new file mode 100644 index 0000000..3adfbf5 --- /dev/null +++ b/docs/work-items/ORCH-103/02-trz.md @@ -0,0 +1,232 @@ +--- +work_item: ORCH-103 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-103 — ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +Work Item: **ORCH-103** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ фиксирует **что** должно измениться и **где** (артефакты/контракты/границы). **Как** +> (расположение bundle-каталога, состав сервисов Plane-стека, язык/режимы bootstrap, механизм +> сетевой связности) — решает архитектор в `06-adr/`. Архитектурные решения здесь не принимаются. + +--- + +## 1. Сводка изменения + +Добавить в репо **Bundled-комплект тиража (Type B эпика ORCH-10)**: (1) отдельный +**bundle-compose** всего стека (орк + watchdog + Gitea + Plane-стек ~14 контейнеров), +(2) **bootstrap-скрипт**, доводящий свежеподнятый стек до рабочего конвейера одним запуском +(с явными manual-step чекпоинтами там, где Plane CE API не позволяет автоматизацию), +(3) **генерацию новых секретов** на инсталляцию (reuse `gen_secrets.py` + bundle-внутренние +креды), (4) инструкцию **`docs/deployment/BUNDLED_SETUP.md`** с требованиями к хосту, +(5) **структурные анти-дрейф тесты**. Всё — вне рантайма и вне конвейера: `src/**`, корневой +`docker-compose.yml`, `Dockerfile`, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — байт-в-байт +(паттерн ORCH-009/ORCH-102). Kill-switch не требуется: активация — только явный запуск +оператором на целевом хосте. + +--- + +## 2. Задействованные модули / пути + +| Путь | Действие | Назначение | +|------|----------|------------| +| `deploy/bundled/docker-compose.yml` *(рабочая гипотеза; финальное расположение/имя — ADR; далее «bundle-compose»)* | **создать** | Compose-комплект всего стека (FR-1) | +| `deploy/bundled/.env.bundled.example` *(имя — ADR; далее «bundle-конфиг-канон»)* | **создать** | Канон 100% переменных bundle (порты/версии/плейсхолдеры кред), паттерн `.env.watchdog.example` | +| `scripts/bootstrap_bundle.py` *(имя/язык — ADR)* | **создать** | Bootstrap-скрипт (FR-2) | +| `docs/deployment/BUNDLED_SETUP.md` | **создать** | Golden source инструкции Bundled-тиража (FR-4) | +| `docs/operations/REPLICATION.md` | **изменить (точечно)** | §1: строка Type B → ✅ ORCH-103 + ссылка на BUNDLED_SETUP.md (FR-6) | +| `tests/test_bundle_compose.py` | **создать** | Структура bundle-compose + изоляция корневого compose (FR-5) | +| `tests/test_bundled_setup_doc.py` | **создать** | Канон дока, env-ключи, FORBIDDEN/секрет-эвристика, кросс-рефы (FR-5) | +| `tests/test_bootstrap_script.py` | **создать** | Структурные/unit-ассерты bootstrap (FR-5) | +| `CHANGELOG.md` | **изменить** | Запись `feat: ORCH-103` | +| `.gitignore` | **изменить (при необходимости)** | Сгенерированные на хосте конфиги bundle не коммитятся (NFR-3) | +| **НЕ трогать:** `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.gitea/workflows/**`, `.env.example` (кроме явно обоснованного в ADR аддитива), `onboarding/**`, промпты `.openclaw/agents/**` | — | NFR-1; анти-дрейф `test_lite_setup_doc.py` (TC-04: ровно 3 сервиса, нет `plane*`/`gitea*`) и `test_no_host_hardcodes.py` остаются зелёными | + +--- + +## 3. Функциональные требования + +### FR-1 — Bundle-compose всего стека (BR-1) +- **Отдельный файл**, корневой `docker-compose.yml` не изменяется (жёсткое ограничение: + анти-дрейф TC-04 `tests/test_lite_setup_doc.py` проверяет точное множество сервисов корневого + compose и запрещает подстроки `plane`/`gitea` в именах сервисов/образов/контейнеров). +- **Состав стека:** `orchestrator` (образ собирается из существующего корневого `Dockerfile` — + без его правки), `orchestrator-watchdog` (существующий `watchdog/Dockerfile`), Gitea + (+ её хранилище), полный Plane CE-стек (~14 контейнеров: web/admin/space/api/worker/beat/ + live/migrator + postgres/redis/mq/minio/proxy — точный состав и версии пиннит архитектор по + upstream-référence). Staging-контур орка (8501) — НЕ в дефолтном `up` (вне скоупа заказчика; + включать ли за профилем — ADR). +- **Пиннинг версий** всех сторонних образов (тег или digest; не `latest`) — NFR-6. +- **Тома:** только именованные/каталожные тома bundle (узнаваемый префикс); чистый первый старт; + пересечений с томами/путями нашего прод-контура нет. +- **Сеть и достижимость (двунаправленно):** (a) Plane→орк и Gitea→орк webhooks доставляются; + (b) орк→Plane API и орк→Gitea API доступны; (c) git push/fetch агентов в Gitea работает. + Механизм (bridge-сеть + публикация портов, `extra_hosts: host-gateway`, host-network — что + выбрать) — ADR; ТЗ фиксирует только инвариант достижимости, проверяемый smoke (FR-6). +- **Порты:** карта портов по умолчанию задокументирована (BUNDLED_SETUP «Требования к хосту»); + порты конфигурируемы через bundle-конфиг; конфликт порта → отказ preflight bootstrap (FR-2), + не молчаливый сбой. Дефолт порта орка — существующий 8500 (`ORCH_DEPLOY_PROD_TARGET_PORT`). +- **Конфиг-канон:** все параметры bundle (порты/версии/пути/плейсхолдеры кред) — в + bundle-конфиг-каноне; key-set синхронизируется структурным тестом (паттерн key-sync + `.env.watchdog.example`, D5 ORCH-102). Ключи орка НЕ дублируются — `.env` орка собирается + bootstrap'ом из существующего канона `.env.example`. + +### FR-2 — Bootstrap-скрипт: один запуск до рабочего состояния (BR-2, BR-6, BR-7) +Последовательность (нумерация — норматив поведения; механика шагов — ADR): +1. **Preflight (fail-fast, до любых мутаций):** docker+compose присутствуют; свободные + RAM/диск ≥ задокументированных минимумов; целевые порты свободны; тома bundle отсутствуют + (чистый хост) — иначе явный отказ с подсказкой (BR-7); наличие Claude CLI/кред — warning + (не блокер: конвейер без LLM не поедет, но стек поднимется). +2. **Секреты (FR-3):** генерация полного набора НОВЫХ секретов инсталляции. +3. **Up:** подъём bundle-compose; ожидание готовности каждого сервиса (healthcheck/готовность + БД/завершение миграций Plane и Gitea) с таймаутами и внятной диагностикой. +4. **Init Gitea:** административная учётка + API-токен (через официальные механизмы Gitea — + CLI/env/API; конкретика — ADR); branch protection НЕ настраивается (норматив D10 ORCH-009). +5. **Init Plane:** instance-setup/workspace/API-токен. Всё, что недоступно в CE по API, — + **интерактивный manual-step чекпоинт**: скрипт печатает точную инструкцию (URL/что нажать/ + что ввести), ждёт подтверждения, **проверяет результат** (например, валидность введённого + API-токена запросом) и только тогда продолжает (fail-safe; молчаливый пропуск запрещён). +6. **Онбординг sandbox-проекта:** вызов `scripts/onboard_project.py apply` + `verify` + (22 статуса из `plane_sync._PLANE_NAME_TO_KEY`, лейблы `autoApprove`/`autoDeploy`/`Bug`, + Gitea-репо, per-repo webhook под глобальным секретом). Собственная реализация этих шагов + в bootstrap **запрещена** (BR-6, нулевой дрейф канона). +7. **Git-доступ агентов:** обеспечить push/fetch созданного репо из контейнера орка + (ssh-ключ + регистрация в Gitea ИЛИ токен-remote — механизм ADR); клон репо в repos-каталог + орка (`ORCH_HOST_REPOS_DIR`). +8. **Конфиг орка:** собрать `.env` (на базе `.env.example`: URL'ы Plane/Gitea bundle-инсталляции, + токены, webhook-секреты, `ORCH_PROJECTS_JSON` из вывода onboard) и `.env.watchdog` + (из `.env.watchdog.example`); файлы остаются только на целевом хосте. +9. **Health + итог:** `GET /health`, `GET /queue`, `GET /metrics` зелёные; финальная сводка + PASS/FAIL по всем шагам + следующая команда оператора (smoke FR-6). + +Требования к скрипту: +- **Идемпотентность:** повторный запуск на уже-инициализированном bundle безопасен + (ensure/skip-семантика, как `onboard_project.py apply`); никаких delete-операций. +- **Exit-коды:** `0` — успех; `2` — остановка на manual-step/незавершённое предусловие; + `1` — ошибка (паттерн `onboard_project.py`). +- **Логи без секретов** (NFR-3): значения кред не печатаются (только имена ключей/пути файлов). +- **Никогда не адресует наш прод:** в скрипте нет боевых хостов/путей (FORBIDDEN-скан), + работает только с локальным docker целевого хоста. +- Желателен режим `plan` (печать шагов без мутаций, паттерн ORCH-009) — финально ADR. + +### FR-3 — Инициализация секретов: новые на каждую инсталляцию (BR-4) +- **Webhook-секреты орка** — строго через существующий `scripts/gen_secrets.py` + (не реализовывать заново). +- **Bundle-внутренние креды** (генерирует bootstrap, криптослучайно, stdlib `secrets`): + пароли postgres/redis*/mq/minio Plane-стека, секрет-ключи Plane, админ-пароль и API-токен + Gitea. В репо — только плейсхолдеры в bundle-конфиг-каноне; **ни одного дефолтного пароля**. +- **Внешние секреты заказчика** (не генерятся, чек-лист в доке): Anthropic/Claude CLI доступ, + Telegram-токены (опционально), `ORCH_PLANE_API_TOKEN` (если выдаётся вручную на manual-step). +- Сгенерированные файлы: только на целевом хосте, права `600`, в `.gitignore`; повторный + запуск НЕ перетирает существующие секреты без явного флага (паттерн `--force` gen_secrets). + +### FR-4 — `docs/deployment/BUNDLED_SETUP.md` (BR-5) +- **Канон LITE_SETUP** (ORCH-102): нормативные разделы в порядке маршрута оператора; каждый + исполняемый шаг = fenced-команда + явная «Проверка:» с PASS/FAIL; хост-специфика — только + плейсхолдеры; запрещены FORBIDDEN-литералы и реальные секреты (структурный тест). +- **Обязательные разделы** (минимум; точные заголовки — автор дока, проверяемость — тест): + (1) рамка Bundled (что входит/что НЕ входит: Claude CLI, Telegram, HTTPS; границы vs Lite); + (2) **требования к хосту** — RAM/диск/CPU/порты, явно «Plane ≈ 14 контейнеров», финальные + цифры — по замеру на тестовом развёртывании; (3) предусловия (docker/compose/sudo); + (4) получение кода; (5) секреты; (6) запуск bundle-compose; (7) bootstrap (включая перечень + manual-step чекпоинтов Plane); (8) LLM/Claude CLI (ссылкой на канон LITE_SETUP §7); + (9) Telegram (ссылкой на LITE_SETUP §8); (10) онбординг следующих проектов + (ссылкой на ONBOARDING.md); (11) smoke (шаги REPLICATION §4); (12) stateless-проверка; + (13) остановка/полный сброс инсталляции; (14) траблшутинг (минимум: webhook не доходит, + не хватает RAM/OOM, порт занят, claude не найден, Plane-миграции не завершились). +- **Канон не форкается:** общие с Lite шаги — ссылками (LITE_SETUP §5–§8, ONBOARDING §1, + REPLICATION §2–§4), не копипастой; fail-closed имена `Confirm Deploy`/`STOP` и «22 статуса» — + согласованы с `plane_sync._PLANE_NAME_TO_KEY` (число — сверкой импорта в тесте, не литералом). + +### FR-5 — Структурные анти-дрейф тесты (BR-9) +Все тесты — без docker/сети/LLM/subprocess-мутаций (CI-безопасные; паттерн +`test_lite_setup_doc.py`): +- **bundle-compose:** файл существует, валидный YAML; обязательные сервисы присутствуют + (`orchestrator`, `orchestrator-watchdog`, Gitea, Plane-стек — по списку из ADR); все + сторонние образы пиннованы (нет `:latest`/безтегового образа); корневой + `docker-compose.yml` НЕ изменён (множество сервисов == текущему эталону); +- **док:** BUNDLED_SETUP.md существует, несёт обязательные разделы (включая «Требования к + хосту»), каждый env-ключ из дока существует в канонах (`.env.example` ∪ bundle-конфиг-канон), + кросс-ссылки на LITE_SETUP/ONBOARDING/REPLICATION присутствуют; +- **гигиена:** FORBIDDEN-литералы (импорт списка из `test_no_host_hardcodes.py`) отсутствуют + в bundle-compose/доке/bootstrap; секрет-эвристика (hex ≥32 / alnum ≥40, паттерн D8 ORCH-102) + по новым файлам; +- **bootstrap:** скрипт существует; структурно ссылается на `gen_secrets`/`onboard_project` + (не дублирует канон); не содержит delete-операций уровня `docker volume rm`/`rm -rf` вне + явного отдельного «сброс»-режима; чистые функции (preflight-решение, сборка плана шагов, + рендер `.env`) покрыты unit-тестами; +- **кросс-рефы:** REPLICATION.md §1 несёт отметку Type B → BUNDLED_SETUP.md; CHANGELOG + содержит `ORCH-103`. + +### FR-6 — Smoke и наблюдаемость результата (BR-3) +- Smoke Bundled = шаги REPLICATION §4 (0–6) поверх bundle-инсталляции, зафиксированные в + BUNDLED_SETUP §smoke: config-резолв → `/health` → `/queue`+`/metrics` → onboard verify → + тестовая задача (Plane issue → «To Analyse» → job в очереди) → **минимальный сигнал: + артефакты `01–04` в ветке** → опционально полный цикл до `done`. +- Прохождение фиксируется оператором по PASS/FAIL каждого шага; это ручная приёмка AC-2 + (e2e в CI не гоняется — нет docker/LLM). + +--- + +## 4. Изменения API +**Нет.** Эндпоинты орка не добавляются/не меняются; bundle использует существующие +`/health`, `/queue`, `/metrics`, вебхуки `/webhook/plane`, `/webhook/gitea`. + +## 5. Изменения схемы БД +**Нет** (схема БД орка не тронута). БД Plane/Gitea внутри bundle — их собственные, на чистых +томах инсталляции; к схеме орка отношения не имеют. + +## 6. Требования к новым/изменённым QG checks +**Нет.** Реестр `QG_CHECKS`/`check_*`/`STAGE_TRANSITIONS` — байт-в-байт. Bundled-тираж — это +артефакты дистрибуции, а не гейты конвейера. + +--- + +## 7. Совместимость / регресс +- **Kill-switch не требуется** (паттерн ORCH-009): артефакты вне рантайма; в нашем контуре + ничего их не исполняет; активация — явный запуск оператором на целевом хосте. +- **Нулевая регрессия:** корневой compose/`Dockerfile`/`src/**` не изменены ⇒ наш прод, + staging-контур и enduro-trails не затронуты по построению; существующие анти-дрейф тесты + (`test_lite_setup_doc.py`, `test_no_host_hardcodes.py`, канон-тесты ORCH-009) остаются + зелёными без правки их ассертов. +- **Обратимость:** удаление bundle-каталога/скрипта/дока возвращает репо в текущее состояние; + на целевом хосте полный сброс = задокументированная процедура (FR-4 §13). +- **Эскалация:** если при реализации выяснится необходимость править `src/**`/корневой compose + (например, недостающая параметризация, не закрытая ORCH-101) — это выход за рамки ТЗ: + остановиться и вернуть задачу с обоснованием (CLAUDE.md правило 4), не «дотачивать молча». + +--- + +## 8. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR) +- `docs/work-items/ORCH-103/06-adr/ADR-001-.md` — решения архитектора (см. §9 OQ); + при сквозном значении — зеркало в `docs/architecture/adr/adr-NNNN-.md`. +- `docs/architecture/README.md` — раздел «Bundled-тираж (ORCH-103)» рядом с 10-common/Lite. +- `CLAUDE.md` — краткий абзац Type B (паттерн абзацев ORCH-101/102). +- `docs/operations/REPLICATION.md` §1 — отметка Type B (FR-6). +- `CHANGELOG.md` — `feat: ORCH-103 …`. +- При выявлении инфра-предусловий целевого хоста — `07-infra-requirements.md` (архитектор). + +--- + +## 9. Открытые вопросы для архитектора (не блокируют анализ) +- **OQ-1** Расположение/имя bundle-каталога и compose-файла (`deploy/bundled/` vs `bundle/`; + один compose vs include-композиция); судьба staging-контура орка в bundle (исключить vs + профиль). +- **OQ-2** Точный состав/версии Plane CE-стека (по upstream selfhost-référence) и Gitea; + стратегия пиннинга (тег vs digest). +- **OQ-3** Перечень физически автоматизируемых шагов инициализации Plane CE (instance-setup/ + workspace/API-токен): что через API/CLI/seed, что — manual-step чекпоинт. +- **OQ-4** Язык и режимы bootstrap (python stdlib vs bash; `plan`/`apply` vs линейный визард); + способ ожидания готовности (healthchecks vs poll). +- **OQ-5** Механизм сетевой связности орк (host network?) ⟷ bundle bridge-сеть: публикация + портов, `host-gateway`, либо весь bundle в host-network — и согласование URL вебхуков. +- **OQ-6** Механизм git-доступа агентов к bundle-Gitea (ssh-ключ vs http-токен) и наполнение + repos-каталога. +- **OQ-7** Делать ли отдельный явный «сброс»-режим (teardown) частью скрипта или только + документированной процедурой в BUNDLED_SETUP §13. diff --git a/docs/work-items/ORCH-103/03-acceptance-criteria.md b/docs/work-items/ORCH-103/03-acceptance-criteria.md new file mode 100644 index 0000000..e60c1c2 --- /dev/null +++ b/docs/work-items/ORCH-103/03-acceptance-criteria.md @@ -0,0 +1,164 @@ +--- +work_item: ORCH-103 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-103 — Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +Work Item: **ORCH-103** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). AC-1…AC-5 — из постановки Владельца (сохранены 1:1 по смыслу); +AC-6…AC-9 — производные обязательные. AC-1/AC-2/AC-3 в части e2e — **ручная приёмка** на +чистом тестовом хосте/VM по BUNDLED_SETUP.md (в CI docker/LLM не гоняются); остальное +проверяется по файлам репозитория и структурным тестам. + +--- + +## AC-1 — Единый bundle поднимает ВЕСЬ стек; bootstrap доводит одной командой/визардом + +**Условие:** на чистом Linux-хосте с docker+compose по шагам BUNDLED_SETUP.md: одна команда +`docker compose -f … up -d` поднимает все сервисы стека (орк + watchdog + +Gitea + Plane-стек); затем ОДИН запуск bootstrap-скрипта доводит инсталляцию до рабочего +состояния (init Gitea/Plane → онбординг sandbox-проекта → git-доступ агентов → конфиг орка → +health). Интерактивные manual-step чекпоинты допустимы только там, где Plane CE API не +позволяет автоматизацию, каждый — с инструкцией и проверкой результата. +- **PASS:** все контейнеры bundle в состоянии Up/healthy; bootstrap завершается `exit 0`; + `GET /health` орка — 200/ok, `GET /queue` и `GET /metrics` отдают валидный JSON; + `onboard_project.py verify` зелёный (22 статуса, лейблы, репо, webhook); ни одного + НЕдокументированного ручного действия (правка compose/конфигов руками сверх инструкции). +- **FAIL:** хотя бы один сервис не поднялся/в рестарт-цикле; bootstrap падает или завершается + с нерабочим конвейером; для доводки потребовались действия, отсутствующие в BUNDLED_SETUP.md; + manual-step пропускается молча без проверки результата. + +--- + +## AC-2 — После bootstrap smoke проходит (тестовый проект + задача доезжает) + +**Условие:** smoke-процедура BUNDLED_SETUP §smoke (шаги REPLICATION.md §4 поверх +bundle-инсталляции): создать issue в sandbox-проекте Plane → перевести в «To Analyse». +- **PASS:** webhook доезжает (job появляется в `GET /queue`); конвейер запускает analyst; + в рабочей ветке Gitea появляются артефакты `01-brd.md`/`02-trz.md`/`03-acceptance-criteria.md`/ + `04-test-plan.yaml` (минимальный сигнал — шаг 5 REPLICATION §4); обратное направление + работает (орк пишет статус/коммент в Plane). Опционально-расширенно: задача доводится до + `done` (шаг 6). +- **FAIL:** webhook не доходит (нет job); analyst не стартует; артефакты `01–04` не появляются; + орк не может писать в Plane/Gitea API (одностороння связность — R-4). + +--- + +## AC-3 — Stateless: чистые Plane/Gitea/БД, новые секреты + +**Условие:** инсталляция стартует с нуля и не содержит ничего нашего. +- **PASS:** все тома bundle созданы заново при первом `up` (чистые БД Plane/Gitea/орка: + `GET /queue` — нулевые счётчики, в Plane/Gitea нет наших задач/репо/пользователей); ВСЕ + секреты инсталляции сгенерированы на месте (`gen_secrets.py` + bundle-креды bootstrap); + в репо нет ни одного реального секрета/дефолтного пароля (структурный тест: секрет-эвристика + + плейсхолдеры в bundle-конфиг-каноне); боевые данные/секреты/БД не копируются ни одним шагом + инструкции. +- **FAIL:** инструкция/скрипт предлагает перенос наших данных или переиспользование боевых + секретов; в репо обнаружен реальный секрет/дефолтный пароль/высокоэнтропийный литерал; + на свежей инсталляции видны чужие задачи/счётчики. + +--- + +## AC-4 — BUNDLED_SETUP.md + требования к хосту задокументированы + +**Условие:** `docs/deployment/BUNDLED_SETUP.md` существует и написан по канону тиражных доков +(ORCH-102). +- **PASS:** док несёт обязательные разделы FR-4 (рамка, **требования к хосту с явными цифрами + RAM/диск/CPU и картой портов**, предусловия, секреты, запуск, bootstrap с перечнем + manual-step, LLM, Telegram, онбординг, smoke, stateless-проверка, остановка/сброс, + траблшутинг); каждый исполняемый шаг = fenced-команда + «Проверка:» PASS/FAIL; явно указано + «Plane ≈ 14 контейнеров — ресурсоёмко»; цифры требований подтверждены замером на тестовом + развёртывании (не «с потолка»); хост-специфика — только плейсхолдеры; общие шаги — ссылками + на LITE_SETUP/ONBOARDING/REPLICATION (без копипасты канона). +- **FAIL:** дока нет/раздел «Требования к хосту» отсутствует или без цифр; шаги без + команд/проверок; FORBIDDEN-литералы (IP/`/home/slin`/`mva154`/`duckdns`) или секреты в + тексте/fenced-блоках; канон LITE_SETUP скопирован вместо ссылок. + +--- + +## AC-5 — pytest зелёный; CHANGELOG + +**Условие:** полный регресс и журнал изменений. +- **PASS:** `pytest tests/ -q` — 0 failed (включая существующие анти-дрейф + `test_lite_setup_doc.py`, `test_no_host_hardcodes.py`, канон-тесты ORCH-009 — без правки их + ассертов); `CHANGELOG.md` содержит запись `ORCH-103`. +- **FAIL:** хотя бы один тест красный; существующий анти-дрейф тест «починен» ослаблением + ассертов; CHANGELOG не обновлён. + +--- + +## AC-6 — Корневой compose и рантайм не тронуты + +**Условие:** изоляция от боевого контура (NFR-1/NFR-2, BR-1/BR-8). +- **PASS:** `git diff main` НЕ содержит изменений `src/**`, корневого `docker-compose.yml`, + `Dockerfile`, `.gitea/workflows/**`; bundle-compose — отдельный файл; множество сервисов + корневого compose неизменно (`orchestrator`/`orchestrator-watchdog`/`orchestrator-staging`); + ни один артефакт задачи не исполняется в нашем контуре автоматически (нет правок + деплой-хука/CI, нет cron/врезок). +- **FAIL:** любая правка рантайма/корневого compose/Dockerfile; сервисы `plane*`/`gitea*` + добавлены в корневой compose; артефакт bundle задействован в нашем прод/staging-контуре. + +--- + +## AC-7 — Нулевой дрейф канонов: кирпичи переиспользованы + +**Условие:** BR-6 — единственный источник истины для статусов/лейблов/секретов/smoke. +- **PASS:** bootstrap вызывает `scripts/gen_secrets.py` (webhook-секреты) и + `scripts/onboard_project.py` (статусы/лейблы/репо/вебхуки) — структурный тест подтверждает + ссылки; собственного списка статусов/лейблов в bundle-артефактах нет (упоминание числа + статусов в доке сверяется импортом `plane_sync._PLANE_NAME_TO_KEY` в тесте, не литералом); + smoke-раздел ссылается на REPLICATION §4. +- **FAIL:** bootstrap/док несут собственную копию канона (свой список статусов, свой генератор + webhook-секретов, свой smoke-чеклист с нуля) — дрейф при будущих изменениях канона. + +--- + +## AC-8 — Идемпотентность и fail-safe bootstrap + +**Условие:** BR-7 — повторный запуск и грязный хост. +- **PASS:** повторный запуск bootstrap на уже-инициализированном bundle завершается успешно + (ensure/skip, без дублей и без разрушения состояния); preflight на грязном/непригодном хосте + (существующие тома bundle, занятый порт, нехватка RAM/диска) → явный отказ с понятной + подсказкой ДО любых мутаций; delete-операций нет (teardown — только отдельный + явный режим/процедура, не часть обычного прогона); exit-коды: 0 — успех, 2 — manual-step/ + предусловие, 1 — ошибка; секреты в логи не печатаются; повторный запуск не перетирает + существующие секреты без явного флага. +- **FAIL:** повторный запуск ломает/дублирует состояние; bootstrap молча переиспользует чужие + тома или продолжает после провального preflight; обычный прогон удаляет данные; секрет виден + в stdout/логе. + +--- + +## AC-9 — Секрет-гигиена и переносимость новых артефактов + +**Условие:** NFR-3/NFR-4/NFR-6 по файлам репо. +- **PASS:** структурные тесты подтверждают: в bundle-compose/доке/скрипте нет + FORBIDDEN-литералов (список — импорт из `test_no_host_hardcodes.py`) и высокоэнтропийных + литералов (hex ≥32 / alnum ≥40); все сторонние образы bundle-compose пиннованы (не `latest`); + все env-ключи, упомянутые в BUNDLED_SETUP.md, существуют в канонах (`.env.example` ∪ + bundle-конфиг-канон); сгенерированные на хосте конфиги — в `.gitignore`. +- **FAIL:** найден хост-литерал/секрет; образ без пина; ключ-фантом в доке (нет в канонах); + сгенерированный конфиг коммитится. + +--- + +## Сводная матрица AC ↔ BR/FR + +| AC | Покрывает | Способ проверки | +|----|-----------|-----------------| +| AC-1 | BR-1, BR-2 / FR-1, FR-2 | ручной e2e на тестовом хосте + структурные тесты (TC-01..04, TC-08) | +| AC-2 | BR-3 / FR-2, FR-6 | ручной e2e (smoke REPLICATION §4) | +| AC-3 | BR-4 / FR-3 | ручной e2e + структурные тесты (TC-06, TC-09) | +| AC-4 | BR-5 / FR-4 | структурный тест дока (TC-05) + ревью | +| AC-5 | BR-9 / FR-5, FR-6 | `pytest tests/ -q` (TC-12) + CHANGELOG (TC-11) | +| AC-6 | BR-1, BR-8 / NFR-1, NFR-2 | git diff + существующий анти-дрейф (TC-02) | +| AC-7 | BR-6 / FR-2, FR-3 | структурный тест bootstrap/дока (TC-07, TC-10) | +| AC-8 | BR-7 / FR-2 | unit-тесты чистых функций preflight/плана (TC-08) + ручной повторный прогон | +| AC-9 | NFR-3, NFR-4, NFR-6 / FR-1, FR-4 | структурные тесты гигиены (TC-03, TC-06, TC-09) | diff --git a/docs/work-items/ORCH-103/04-test-plan.yaml b/docs/work-items/ORCH-103/04-test-plan.yaml new file mode 100644 index 0000000..2c8d7a1 --- /dev/null +++ b/docs/work-items/ORCH-103/04-test-plan.yaml @@ -0,0 +1,102 @@ +work_item: ORCH-103 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +title: "Bundled-тираж: bundle-compose + bootstrap + BUNDLED_SETUP.md (структурные анти-дрейф тесты)" +framework: pytest +scope: > + Автоматическое покрытие — СТРУКТУРНЫЕ инварианты артефактов Bundled-тиража + (bundle-compose, bootstrap-скрипт, docs/deployment/BUNDLED_SETUP.md) без docker/сети/LLM + в CI (паттерн tests/test_lite_setup_doc.py). Вне автоматического покрытия — фактический + e2e-подъём стека: он принимается ВРУЧНУЮ по чек-листу BUNDLED_SETUP §smoke + (шаги REPLICATION.md §4) на чистом тестовом хосте/VM — см. notes (AC-1/AC-2/AC-3/AC-8). +notes: > + Ручная приёмка (вне CI): чистый Linux-хост/VM -> docker compose -f up -d -> + один запуск bootstrap (manual-step чекпоинты Plane CE допустимы и проверяются) -> + health/queue/metrics зелёные -> onboard verify -> тестовая задача доезжает до артефактов + 01-04 (минимальный сигнал), опционально до done; повторный запуск bootstrap безопасен; + тома чистые, секреты новые. Имена модулей tests/test_bundle_compose.py / + tests/test_bundled_setup_doc.py / tests/test_bootstrap_script.py — норматив тест-плана; + имена bundle-каталога/скрипта внутри ассертов следуют ADR-001 архитектора. + Полный регресс tests/ обязан остаться зелёным БЕЗ ослабления ассертов существующих + анти-дрейф тестов (test_lite_setup_doc.py, test_no_host_hardcodes.py, канон ORCH-009). + +tests: + # ---------- FR-1 / AC-1: bundle-compose ---------- + - id: TC-01 + type: unit + description: "Bundle-compose существует и валидно парсится (yaml.safe_load); содержит обязательные сервисы: orchestrator, orchestrator-watchdog, Gitea и Plane-стек (имена — по ADR-001); staging-контур орка не входит в дефолтный up" + module: tests/test_bundle_compose.py + expected: PASS + + - id: TC-02 + type: unit + description: "Корневой docker-compose.yml НЕ изменён: множество сервисов == {orchestrator, orchestrator-watchdog, orchestrator-staging}; в его сервисах/образах/container_name нет подстрок plane/gitea (зеркало TC-04 test_lite_setup_doc.py — существующий анти-дрейф остаётся зелёным)" + module: tests/test_bundle_compose.py + expected: PASS + + - id: TC-03 + type: unit + description: "Все сторонние образы bundle-compose пиннованы: ни одного image с тегом latest или без тега/digest (NFR-6, воспроизводимость)" + module: tests/test_bundle_compose.py + expected: PASS + + - id: TC-04 + type: unit + description: "Изоляция и конфиг-канон bundle: тома — именованные с узнаваемым bundle-префиксом, без bind-путей нашего прод-контура; bundle-конфиг-канон (example-файл) существует, и каждая ${VAR}-интерполяция bundle-compose имеет ключ в каноне (key-set-sync, паттерн .env.watchdog.example)" + module: tests/test_bundle_compose.py + expected: PASS + + # ---------- FR-4 / AC-4: BUNDLED_SETUP.md ---------- + - id: TC-05 + type: unit + description: "docs/deployment/BUNDLED_SETUP.md существует и несёт обязательные разделы FR-4 (включая 'Требования к хосту' с цифрами RAM/диск/CPU, картой портов и упоминанием ~14 контейнеров Plane; bootstrap; smoke; stateless-проверка; остановка/сброс; траблшутинг); исполняемые шаги оформлены fenced-блоками с явной 'Проверка:'" + module: tests/test_bundled_setup_doc.py + expected: PASS + + - id: TC-06 + type: unit + description: "Гигиена новых артефактов (док + bundle-compose + bootstrap): нет FORBIDDEN-литералов (список — импорт из tests/test_no_host_hardcodes.py) и нет высокоэнтропийных секрет-литералов (hex >=32 / alnum >=40, эвристика D8 ORCH-102)" + module: tests/test_bundled_setup_doc.py + expected: PASS + + # ---------- FR-2/FR-3 / AC-7: bootstrap переиспользует кирпичи ---------- + - id: TC-07 + type: unit + description: "Bootstrap-скрипт существует и структурно переиспользует канон: ссылается на scripts/gen_secrets.py и scripts/onboard_project.py; НЕ несёт собственного списка Plane-статусов/лейблов; в обычном прогоне нет delete-операций (docker volume rm / rm -rf допустимы только в отдельном явном reset-режиме, если введён ADR)" + module: tests/test_bootstrap_script.py + expected: PASS + + - id: TC-08 + type: unit + description: "Чистые функции bootstrap (preflight/план шагов): грязное состояние (существующие bundle-тома, занятый порт, нехватка RAM/диска) -> отказ с диагностикой ДО мутаций; чистое -> план полного прогона; контракт exit-кодов 0/2/1 (успех / manual-step-остановка / ошибка)" + module: tests/test_bootstrap_script.py + expected: PASS + + # ---------- FR-4/FR-5 / AC-9: env-канон и нулевой дрейф ---------- + - id: TC-09 + type: unit + description: "Каждый env-ключ ORCH_*/WATCHDOG_*, упомянутый в BUNDLED_SETUP.md, существует в .env.example либо в bundle-конфиг-каноне (нет ключей-фантомов); упоминание ЧИСЛА статусов Plane сверяется импортом len(plane_sync._PLANE_NAME_TO_KEY), а не зашитым литералом" + module: tests/test_bundled_setup_doc.py + expected: PASS + + - id: TC-10 + type: unit + description: "Кросс-ссылки канона: BUNDLED_SETUP.md ссылается на LITE_SETUP.md, ONBOARDING.md и REPLICATION.md (канон не форкается); docs/operations/REPLICATION.md §1 несёт отметку Type B -> BUNDLED_SETUP.md / ORCH-103" + module: tests/test_bundled_setup_doc.py + expected: PASS + + # ---------- AC-5: журнал и полный регресс ---------- + - id: TC-11 + type: unit + description: "CHANGELOG.md содержит запись ORCH-103" + module: tests/test_bundled_setup_doc.py + expected: PASS + + - id: TC-12 + type: integration + description: "Полный регресс pytest tests/ -q зелёный: новые тесты добавлены, существующие анти-дрейф (test_lite_setup_doc.py, test_no_host_hardcodes.py, канон-тесты ORCH-009) проходят БЕЗ изменения их ассертов" + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md b/docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md new file mode 100644 index 0000000..3894cac --- /dev/null +++ b/docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md @@ -0,0 +1,362 @@ +--- +work_item: ORCH-103 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# ADR-001: Bundled-тираж (Type B) — bundle-compose всего стека + bootstrap-канон + +Work Item: **ORCH-103** — ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0038-bundled-replication-canon.md`** +(закрывает Type B эпика ORCH-10; вводит новый top-level каталог `deploy/` и нормативы, +обязательные для будущих задач тиража). + +## Статус +Proposed + +## Контекст + +Эпик ORCH-10 (D5 «Масштаб»), тип **B — Bundled**: заказчик без собственной инфраструктуры +получает **весь стек одним комплектом** (орк + watchdog + Gitea + Plane CE) + bootstrap, +доводящий стек до рабочего конвейера одним запуском. Факты, сверенные с репо: + +- **Корневой `docker-compose.yml` заморожен анти-дрейфом** (`tests/test_lite_setup_doc.py:: + test_compose_services_are_exactly_the_lite_set` + `test_compose_has_no_plane_or_gitea_services`): + ровно 3 сервиса, подстроки `plane`/`gitea` запрещены → bundle обязан быть **отдельным файлом**. +- **Кирпичи уже в `main`:** `scripts/gen_secrets.py` (webhook-секреты, `--write PATH`, exit 0/2), + `scripts/onboard_project.py` (`plan`/`apply`/`verify`, 22 статуса из + `plane_sync._PLANE_NAME_TO_KEY`, шаг `plane.workspace-webhook` — уже **MANUAL** в его отчёте, + token-push `_push_url`, exit 0/2/1; запуск — host-venv, `docs/operations/ONBOARDING.md`), + smoke `docs/operations/REPLICATION.md` §4, док-канон `docs/deployment/LITE_SETUP.md` (ORCH-102). +- **Хост-параметризация закрыта ORCH-101** (adr-0036): `src/**` без хост-литералов + (`tests/test_no_host_hardcodes.py`, FORBIDDEN = `82.22.50.71`/`/home/slin`/`mva154`/`duckdns`); + internal/public split URL уже в конфиге (`ORCH_GITEA_URL`≠`ORCH_GITEA_PUBLIC_URL`, + `ORCH_PLANE_API_URL`≠`ORCH_PLANE_WEB_URL`). +- **`.gitignore` уже неякорный** для `.env`, `data/`, `.env.watchdog` — вложенные копии этих имён + игнорируются на любом уровне без правок. +- **Claude CLI не запекается** в образ (маунты `ORCH_HOST_CLAUDE_*`) — внешнее предусловие хоста + заказчика; bundle его не поставляет (решение Владельца, BRD §1.3). + +ТЗ (02-trz.md §9) оставило архитектору OQ-1…OQ-7. Ниже — пакет решений D1…D11. + +## Решение + +### Сводка + +Bundled-комплект живёт в новом top-level каталоге **`deploy/bundled/`**: самодостаточный +compose-файл всего стека (зеркало официального Plane CE selfhost-référence + Gitea + орк + +watchdog, пиннинг неподвижными тегами) на **одной bridge-сети** с сервис-DNS для машинного +трафика и публикацией только «человеческих» портов. **`scripts/bootstrap_bundle.py`** +(python stdlib, режимы `plan`/`apply`/`verify`, step-движок check→ensure, exit 0/2/1) доводит +стек: preflight → секреты → up → init Gitea (полностью автоматом) → init Plane (честные +manual-step с верификацией) → онбординг sandbox-проекта **строго** через `onboard_project.py` → +git-доступ агентов token-remote → сборка `.env`/`.env.watchdog` орка → health/итог. Teardown — +только документированная процедура. Рантайм байт-в-байт: `src/**`, корневой compose, +`Dockerfile`, `STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схема БД — не тронуты (NFR-1); +kill-switch не нужен — активация только явным запуском оператора на целевом хосте +(паттерн ORCH-009/102). + +### D1 — Расположение и изоляция: `deploy/bundled/`, project name `orchestrator-bundle` (OQ-1) + +- Новый top-level каталог **`deploy/`** — «дистрибутивы развёртывания» (исполняемые комплекты; + семантика дополняет `docs/deployment/` — инструкции). Bundle: **`deploy/bundled/docker-compose.yml`**, + один **самодостаточный** файл (include-композиция отвергнута — см. Альтернативы). +- Top-level **`name: orchestrator-bundle`** в compose: project name фиксирован → все тома/контейнеры + получают узнаваемый префикс `orchestrator-bundle_*`/`orchestrator-bundle-*` (требование TC-04 + тест-плана «узнаваемый bundle-префикс»; preflight bootstrap детектирует «грязный хост» по этому + префиксу). **`container_name` не пиннится ни у одного сервиса** — установка Lite и bundle на одном + хосте не сталкивается по именам с корневым compose (у которого `container_name: orchestrator` + закреплён). +- **Staging-контур орка в bundle отсутствует вовсе** (ни сервисом, ни профилем): заказчик Type B + эксплуатирует платформу для СВОИХ проектов, а не развивает её self-hosting'ом; репо `orchestrator` + в bundle-инсталляции **не регистрируется** как проект → вся self-deploy-машинерия + (`SELF_HOSTING_REPO="orchestrator"`-леафы, freshness, serial-gate freeze) структурно спит. + Нужен self-hosting у заказчика → это маршрут Lite/корневого compose (LITE_SETUP §9), не bundle. +- Имена сервисов: `orchestrator`, `orchestrator-watchdog` — платформенные конвенции (ORCH-101 D3); + `gitea`; Plane-стек — **upstream-имена** сервисов (минимальный дифф к référence, D3 ниже). + Запрет `plane*`/`gitea*` касается ТОЛЬКО корневого compose — на bundle-файл не распространяется. + +### D2 — Конфиг-слои: три файла, один писатель (OQ-1, FR-1/FR-3) + +| Файл | Роль | В гите | +|------|------|--------| +| `deploy/bundled/.env.example` | **bundle-конфиг-канон**: 100% ключей bundle-инфры (публичный хост, карта портов, uid/gid, пути Claude CLI, плейсхолдеры внутренних кред Plane/Gitea) | да (только плейсхолдеры/нейтральные дефолты) | +| `deploy/bundled/.env` | live bundle-конфиг; **авто-читается compose** из project dir — все `docker compose -f deploy/bundled/docker-compose.yml …` работают без флагов | нет (покрыт неякорным `.env` в `.gitignore`) | +| корневые `.env` / `.env.watchdog` | runtime-конфиг орка и watchdog — **ровно канон Lite** (REPLICATION §2 / `.env.example` / `.env.watchdog.example` применимы 1:1); в bundle-compose подключаются `env_file: ../../.env` (`required: false` — см. ниже) | нет (уже в `.gitignore`) | + +- **Отвергнут** отдельный live-файл с обязательным `--env-file`: оператор неизбежно наберёт голую + compose-команду без флага → интерполяции молча упадут в дефолты → пересоздание контейнеров с + чужими портами/путями (труднодиагностируемый класс R-4). Авто-`.env` в project dir — fail-safe + по построению. +- **`env_file` орка/watchdog — `required: false`** (паттерн уже в корневом compose у watchdog): + первый `up -d` поднимает ВЕСЬ стек до того, как конфиг орка собран (AC-1 «одна команда»); орк + без конфига жив (`/health` отвечает), bootstrap пересоздаёт его после сборки env (шаг 8 D5). +- **Bootstrap — единственный писатель** `deploy/bundled/.env` (дозапись сгенерённых кред), + корневого `.env` и `.env.watchdog` на целевом хосте: дублируемые между слоями ключи + (`ORCH_AGENT_HOME_DIR`, порт орка) когерентны механически, а не дисциплиной оператора. + Права `600`; повторный запуск НЕ перетирает существующие значения без явного флага + (паттерн `--force` `gen_secrets.py`). +- **Неймспейсы ключей:** один факт = одно имя (ORCH-101 D1) — существующие факты переиспользуют + существующие имена (`ORCH_RUN_UID/GID`, `ORCH_DOCKER_GID`, `ORCH_AGENT_HOME_DIR`, + `ORCH_HOST_CLAUDE_CODE_DIR`/`ORCH_HOST_NODE_BIN`/`ORCH_HOST_CLAUDE_DIR`/`ORCH_HOST_CLAUDE_JSON`); + bundle-only факты — префикс **`BUNDLE_*`** (`BUNDLE_PUBLIC_HOST`, `BUNDLE_PLANE_PORT`, + `BUNDLE_GITEA_HTTP_PORT`, `BUNDLE_ORCH_PORT`); внутренние креды Plane-стека — **upstream-имена** + Plane CE (значения генерирует bootstrap). Точный состав финализирует developer; форму держит + key-set-sync тест: **каждая `${VAR}`-интерполяция bundle-compose имеет ключ в + `deploy/bundled/.env.example`** (паттерн `.env.watchdog.example`, D5 ORCH-102). +- **Дефолты bundle-compose нейтральны** (FORBIDDEN-литералов нет — TC-06 распространяет скан на + bundle-артефакты): HOME акторов в bundle — `/home/orchestrator` (значение в `.env.example` + bundle, обе стороны группы ORCH-040 двигаются одной переменной — инвариант сохранён); + uid/gid/доки docker-gid заполняет bootstrap из `id -u`/`id -g`/`getent group docker`. +- Каталоги данных орка — bind внутри project dir: `./data` → `deploy/bundled/data` (покрыт + неякорным `data/` в `.gitignore`), `./repos` → `deploy/bundled/repos` (**добавить в + `.gitignore`**: `deploy/bundled/repos/`); bind, а не named volume — те же uid-причины, что в + корневом compose (ORCH-040: named volume создаётся root-owned, контейнер бежит под uid + оператора). Состояние Plane/Gitea (postgres/redis/mq/minio/gitea-data) — **именованные тома** + проекта (root-владение для них нормально: процессы своих образов). + +### D3 — Состав стека и пиннинг: зеркало upstream, неподвижные теги литералом (OQ-2) + +- **Plane CE** — зеркало официального selfhost-compose Plane CE (web/space/admin/api/worker/ + beat-worker/migrator/live + postgres/redis/mq/minio/proxy, ≈13–14 сервисов по факту référence + на момент пиннинга). Структура сервисов/env-контракт — upstream-имена (анти-дрейф к их докам; + своя «переписанная» топология Plane = неоплачиваемый долг сопровождения). +- **Gitea** — официальный `gitea/gitea` (НЕ rootless: rootless усложняет ssh/тома, а ssh-контур + и так не вводится — D8). +- **Пиннинг: точный неподвижный тег литералом в compose** (`image: :`), не `latest`, + не плавающий мажор, не `${VERSION}`-интерполяция (версия — не операторская ручка; её смена = + осознанная правка bundle под тестом). Digest-пиннинг **не требуется**: тег + анти-дрейф формы + (TC-03: ни одного `:latest`/безтегового образа) достаточны для NFR-6, digest нечитаем и + затрудняет осознанный апгрейд. +- **Точные теги фиксирует developer при реализации по фактически проверенному стенду** (ADR + сознательно не выдумывает номера версий — ложная точность хуже честной отсылки к référence); + обновление версий после ORCH-103 — отдельные задачи (BRD §6). +- Healthchecks: у инфра-сервисов (postgres/redis/minio/gitea) — стандартные; у Plane-сервисов — + что даёт upstream; недостающее добирает poll-ожидание bootstrap (D5). + +### D4 — Сеть: одна bridge, сервис-DNS внутрь, публикация только человеческих портов (OQ-5) + +- **Вся инсталляция — в одной именованной bridge-сети** compose-проекта. `network_mode: host` + в bundle **не используется** ни для кого: он был нужен нашему контуру ради ssh-деплоя в + 127.0.0.1 (ORCH-036/058) — в bundle эти пути структурно неактивны (`ORCH_DEPLOY_SSH_HOST` + пуст → freshness/self-deploy/build-cache-pruner no-op по построению). +- **Машинный трафик — строго сервис-DNS:** Plane→орк webhook `http://orchestrator:8500/webhook/plane`, + Gitea→орк `http://orchestrator:8500/webhook/gitea`, орк→Plane `ORCH_PLANE_API_URL=http://`, + орк→Gitea `ORCH_GITEA_URL=http://gitea:3000`, watchdog→орк + `WATCHDOG_METRICS_URL=http://orchestrator:8500/metrics`. Никаких `host-gateway`/`extra_hosts`. +- **Наружу публикуются только человеческие точки** (карта конфигурируема в bundle-каноне, + дефолты): Plane proxy → `${BUNDLE_PLANE_PORT:-8080}`, Gitea web → `${BUNDLE_GITEA_HTTP_PORT:-3000}`, + орк API → `${BUNDLE_ORCH_PORT:-8500}` (операторский smoke `curl /health`). **Postgres/redis/ + mq/minio наружу НЕ публикуются** (секрет-гигиена/поверхность атаки). +- **Публичные URL** (браузер оператора, ссылки в Plane-комментариях/Telegram) строятся от + **`BUNDLE_PUBLIC_HOST`** (дефолт `localhost`): `ORCH_GITEA_PUBLIC_URL=http://$BUNDLE_PUBLIC_HOST:3000`, + `ORCH_PLANE_WEB_URL=http://$BUNDLE_PUBLIC_HOST:8080`, WEB_URL Plane, ROOT_URL Gitea. Split + internal/public уже поддержан конфигом орка (ORCH-101) — новых ключей `src/**` не требуется. +- **Мина Gitea закрывается явно:** Gitea по умолчанию запрещает webhook'и в приватные адреса — + bundle задаёт `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator` (env-конфиг образа Gitea), иначе + R-4 «задача не появилась» гарантирован. Smoke (FR-6) проверяет оба направления. +- HTTPS/домены/reverse-proxy заказчика — вне bundle (BRD §2.2); `BUNDLE_PUBLIC_HOST` + + документированный ручной шаг при необходимости. + +### D5 — Bootstrap: `scripts/bootstrap_bundle.py`, python stdlib, `plan`/`apply`/`verify` (OQ-4) + +- **Язык — python stdlib-only** (NFR-7; паттерн `gen_secrets.py`: работает на голом python3 + целевого хоста ДО `docker compose up`; bash отвергнут — 9-шаговый stateful-визард с + таймаутами/JSON/чистыми функциями под unit-тесты на bash не тестируем структурно). + Никаких импортов из `src/**` (bootstrap бежит вне venv платформы; канон-знания — только + субпроцессами кирпичей, см. ниже). +- **Режимы (паттерн ORCH-009):** `plan` — **дефолт**, ноль мутаций (печать плана + read-only + preflight-диагностика); `apply` — полный прогон; `verify` — read-only пост-проверка + (health/queue/metrics + `onboard_project.py verify` субпроцессом). Exit-коды: `0` успех / + `2` остановка на manual-step или незавершённое предусловие / `1` ошибка (контракт TRZ FR-2). +- **Step-движок check→ensure:** каждый шаг = `check()` (выполнено?) → skip | `ensure()` + (доводка). Повторный `apply` на инициализированном bundle = каскад skip (AC-8); + «resume» после manual-step = просто повторный запуск. Чистые функции (preflight-вердикт, + сборка плана, рендер env-файлов) выделены для unit-тестов (TC-08). +- **Последовательность apply (норматив TRZ FR-2, механика):** + 1. **Preflight (fail-fast, до мутаций):** docker+compose есть; `deploy/bundled/.env` существует + и обязательные ключи заполнены (пути Claude CLI — существуют на хосте, иначе warning-блок: + стек поднимется, конвейер без LLM не поедет); целевые порты свободны; томов/контейнеров с + префиксом `orchestrator-bundle` нет (иначе — явный «уже инициализирован, продолжаю в + ensure-режиме» либо отказ при противоречивом состоянии); RAM/диск ≥ минимумов из + BUNDLED_SETUP (пороги — константы скрипта, синхронизированы с доком); python3+venv доступны. + 2. **Секреты (FR-3):** webhook-секреты — **субпроцессом `scripts/gen_secrets.py --write `** + (не реализуются заново, AC-7); bundle-внутренние (пароли postgres/redis/mq/minio, + SECRET_KEY Plane, админ-пароль Gitea) — stdlib `secrets`; запись в `deploy/bundled/.env` + + корневой `.env`; существующие значения не перетираются без `--force-secrets`; значения + в stdout/лог **не печатаются** (только имена ключей). + 3. **Up + готовность:** `docker compose -f deploy/bundled/docker-compose.yml up -d` + (идемпотентен по построению — оба прочтения AC-1 истинны: оператор мог выполнить up сам); + ожидание готовности poll-циклами с таймаутами (health контейнеров, `migrator` завершился + `exit 0`, HTTP-пробы Plane/Gitea); по таймауту — диагностика «какой сервис не дождались + + хвост его логов». + 4. **Init Gitea — полностью автоматом** (D6). + 5. **Init Plane — manual-step чекпоинты** (D7). + 6. **Онбординг sandbox-проекта — строго `onboard_project.py apply` + `verify`** (D7). + 7. **Git-доступ агентов** (D8) + клон sandbox-репо в `deploy/bundled/repos/`. + 8. **Конфиг орка:** сборка корневого `.env` (база — канон `.env.example`: URL'ы D4, токены, + секреты, `ORCH_PROJECTS_JSON` из вывода onboard; `ORCH_DEPLOY_SSH_HOST=` пусто — + деплой-машинерия спит) и `.env.watchdog` (база — `.env.watchdog.example`; Telegram-ключи + опциональны — пусто = деградация только нотификаций); пересоздание + `up -d orchestrator orchestrator-watchdog` для подхвата env. + 9. **Health + итог:** `GET /health`, `/queue`, `/metrics`; финальная сводная таблица PASS/FAIL + по шагам; следующая команда оператора — smoke BUNDLED_SETUP §smoke (REPLICATION §4). +- **Контракт manual-step (fail-safe, BR-2):** печать точной инструкции (URL/что нажать/что + ввести) → ожидание подтверждения (интерактивно при TTY; без TTY — немедленный `exit 2` с той + же инструкцией) → **верификация результата API-пробой** (например, валидность введённого + `ORCH_PLANE_API_TOKEN` запросом к workspace) → только затем продолжение. Молчаливый пропуск + запрещён. +- **Запретов в скрипте нет:** delete-операций (`docker volume rm`/`rm -rf`/`down -v`) — **ноль** + (teardown — D9); боевых литералов FORBIDDEN — ноль (TC-06); печати секретов — ноль (NFR-3); + наш прод недостижим по построению (скрипт говорит только с локальным docker целевого хоста). + +### D6 — Init Gitea: полностью автоматизирован, branch protection НЕ настраивается (OQ-3-часть) + +- Административная учётка — **официальный CLI в контейнере**: + `docker compose … exec gitea gitea admin user create --admin …` (idempotent: предсуществование + пользователя → skip); API-токен — `gitea admin user generate-access-token` (или REST под basic + auth — равнозначно, выбирает developer по фактической версии Gitea) → `ORCH_GITEA_TOKEN`. +- **Один пользователь-бот** — владелец (`ORCH_GITEA_OWNER`) sandbox-репо и носитель токена для + API орка и token-remote агентов (D8). Отдельная россыпь пользователей на тестовый bundle — + неоправданная сложность (зафиксировано как осознанный компромисс в 10-tech-risks TR-7). +- **Branch protection на `main` НЕ включается; pre-receive не вводится** — норматив D10 ORCH-009 / + adr-0037 п.4 (ломают PR-merge API merge-актора, INV-4); bundle-Gitea конфигурируется тем же + правилом, BUNDLED_SETUP фиксирует его явно (ссылкой на LITE_SETUP §6, не копией). +- `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator` — см. D4. + +### D7 — Init Plane: честные manual-step + онбординг строго кирпичом (OQ-3) + +- **Не автоматизируется (CE):** instance-setup/первый админ, создание workspace + (`ORCH_PLANE_WORKSPACE_SLUG`), выпуск `ORCH_PLANE_API_TOKEN` — три **manual-step чекпоинта** + контракта D5 (инструкция → подтверждение → API-верификация). **Прогрессивная автоматизация + разрешена:** если на момент реализации у конкретной пиннованной версии Plane CE обнаружится + стабильный API/seed-механизм для шага — developer вправе заменить manual-step на ensure **без + изменения контракта чекпоинта** (верификация результата остаётся той же) и без правки ADR. +- **Онбординг sandbox-проекта — ТОЛЬКО субпроцессом** `python3 scripts/onboard_project.py apply` + + `verify` из **host-venv чекаута** (канон запуска — ONBOARDING.md: venv с `requirements.txt`; + создание venv — ensure-шаг bootstrap). Env для субпроцесса (URL'ы/токены D4) bootstrap передаёт + через окружение процесса (pydantic env-переменные перекрывают `env_file`) — корневой `.env` к + этому моменту уже собран либо передаётся фрагментом. Собственная реализация статусов/лейблов/ + webhook в bootstrap **запрещена** (BR-6/AC-7; 22 статуса остаются за + `plane_sync._PLANE_NAME_TO_KEY`). +- **Workspace-webhook Plane** (Plane→орк) — остаётся manual-step **самого onboard-CLI** + (его шаг `plane.workspace-webhook` уже MANUAL — CE не даёт API); bootstrap лишь подставляет + правильный in-network URL `http://orchestrator:8500/webhook/plane` и секрет-имя в инструкцию + и верифицирует доставку на smoke (FR-6). +- `--webhook-url` для Gitea per-repo hook — `http://orchestrator:8500/webhook/gitea` (D4). + +### D8 — Git-доступ агентов: HTTP token-remote; ssh-контур не вводится (OQ-6) + +- Клон sandbox-репо в `deploy/bundled/repos/` с remote-URL вида + `http://@gitea:3000//.git` — **паттерн уже в каноне** + (`onboard_project.py::_push_url` делает initial push именно так); агенты наследуют origin + чекаута (push/fetch из контейнера — bridge-DNS, D4). +- **Ssh-контур в bundle не вводится:** ssh-порт Gitea не публикуется, маунт `ORCH_HOST_SSH_DIR` + в bundle-compose отсутствует (это нашему контуру нужен ssh к хосту/Gitea; в bundle — + лишняя поверхность: генерация ключей, known_hosts, регистрация в Gitea). +- Компромисс «токен в `.git/config` plaintext» зафиксирован честно: каталог `deploy/bundled/repos` + — локальный, права на токен-носители `600`, токен — бот-юзера одной тестовой инсталляции; + риск/митигейшн — 10-tech-risks TR-7. Git-идентичность агентов — существующие + `ORCH_AGENT_GIT_NAME`/`ORCH_GIT_EMAIL_DOMAIN` (дефолты годятся). + +### D9 — Teardown: только документированная процедура, не режим скрипта (OQ-7) + +`BUNDLED_SETUP.md` §13 «Остановка и полный сброс»: `docker compose -f … down` (остановка) / +`down -v` + удаление сгенерённых конфигов и `deploy/bundled/{data,repos}` (полный сброс) — с +явным предупреждением о необратимости. **Reset-режим в bootstrap отвергнут:** одна опечатка +флага = снос томов; ценность против fenced-команды — нулевая; зато скрипт получает структурную +гарантию «delete-операций НЕТ вообще» (упрощение TC-07 и ревью). + +### D10 — Док-канон: `docs/deployment/BUNDLED_SETUP.md`, 14 разделов, ссылки вместо форка (FR-4) + +Форма — канон LITE_SETUP (adr-0037 D2): нормативные разделы в порядке маршрута оператора, +каждый исполняемый шаг = fenced-команда + «Проверка:» PASS/FAIL, хост-специфика — только +плейсхолдеры. **14 разделов** (норматив; точные заголовки — за developer'ом, проверяемость — +структурный тест): (1) рамка Bundled (включая «что НЕ входит»: Claude CLI, Telegram, HTTPS; +границы vs Lite); (2) **требования к хосту** (RAM/диск/CPU **по замеру тестового развёртывания**, +карта портов D4, явное «Plane ≈ 14 контейнеров — ресурсоёмко»); (3) предусловия; +(4) получение кода; (5) секреты; (6) запуск bundle-compose; (7) bootstrap (+ перечень +manual-step Plane); (8) LLM — ссылкой на LITE_SETUP §7; (9) Telegram — ссылкой на LITE_SETUP §8; +(10) онбординг следующих проектов — ссылкой на ONBOARDING.md; (11) smoke — шаги REPLICATION §4; +(12) stateless-проверка; (13) остановка/полный сброс (D9); (14) траблшутинг (минимум: webhook +не доходит — включая `ALLOWED_HOST_LIST`, OOM/нехватка RAM, порт занят, claude не найден, +Plane-миграции не завершились). Fail-closed имена `Confirm Deploy`/`STOP` и «22 статуса» — +сверкой импорта в тесте, не литералом. `docs/operations/REPLICATION.md` §1: строка Type B → +✅ ORCH-103 + ссылка. **Норматив сопровождения (NFR-5):** изменил шаги Bundled-тиража → обнови +BUNDLED_SETUP.md в том же PR. + +### D11 — Анти-дрейф: три структурных тест-модуля (FR-5; без docker/сети/LLM) + +По тест-плану `04-test-plan.yaml` (имена модулей — норматив): `tests/test_bundle_compose.py` +(TC-01…04: yaml.safe_load, обязательные сервисы, заморозка корневого compose зеркалом +существующего ассерта, пины образов, префикс томов, key-set-sync `${VAR}` ⊆ +`deploy/bundled/.env.example`), `tests/test_bundled_setup_doc.py` (TC-05/06/09/10/11: разделы +D10, FORBIDDEN — **импорт** из `test_no_host_hardcodes.py`, секрет-эвристика hex≥32/alnum≥40 — +паттерн D8 ORCH-102, env-ключи ⊆ канонов, число статусов — импортом `plane_sync`, кросс-рефы, +CHANGELOG), `tests/test_bootstrap_script.py` (TC-07/08: ссылки на кирпичи, отсутствие +delete-операций и собственного списка статусов, unit чистых функций preflight/плана/рендера, +контракт exit 0/2/1). Существующие анти-дрейф тесты остаются зелёными **без правки их ассертов** +(AC-5/AC-6). + +## Альтернативы + +- **Расширить корневой `docker-compose.yml` (профиль `bundled`)** — отвергнуто: заморожен + анти-дрейфом ORCH-102 (TC-04) и нормативом adr-0037 п.2 «compose не форкается»; смешение + боевого контура с дистрибутивом = групповой риск self-hosting. +- **Include-композиция (`include:`/несколько `-f`)** — отвергнуто: многофайловость = новые + степени свободы запуска (забытый `-f` молча меняет состав), сложнее структурный тест; один + самодостаточный файл проще и детерминированнее (NFR-6). +- **Live env через `--env-file deploy/bundled/.env.bundled`** — отвергнуто: footgun голой + compose-команды без флага (молчаливые дефолты) — см. D2. +- **Орк в bundle под `network_mode: host` + `host-gateway` для webhook'ов** — отвергнуто: + хост-сеть нужна была нашему ssh-деплой-контуру, который в bundle спит; bridge даёт чистые + стабильные сервис-DNS-URL обоих направлений и нулевые порт-конфликты (D4). +- **Digest-пиннинг образов** — отвергнуто: нечитаем, усложняет осознанный апгрейд; неподвижный + тег + тест формы достаточны для NFR-6 (D3). +- **Ssh-доступ агентов к bundle-Gitea** — отвергнуто: три лишних механизма (ключи, known_hosts, + регистрация) против уже существующего token-remote-паттерна onboard (D8). +- **Bash-bootstrap** — отвергнуто: нет unit-тестируемых чистых функций (TC-08), JSON/поллинг/ + стейт-машина шагов на bash хрупки (D5). +- **Reset-режим bootstrap** — отвергнуто: риск-поверхность против нулевой ценности (D9). +- **Переписать Plane-стек «по-своему» (свои имена сервисов/env)** — отвергнуто: дрейф от + upstream-доков, неоплачиваемое сопровождение (D3). + +## Последствия + +- **+** Эпик ORCH-10 закрывается по типу B: заказчик без инфраструктуры получает конвейер + «под ключ» одной командой + одним bootstrap-прогоном с честными чекпоинтами. +- **+** Нулевой дрейф канонов: статусы/лейблы/секреты/smoke/док-форма — переиспользованы + (gen_secrets, onboard_project, REPLICATION §4, форма LITE_SETUP); рантайм байт-в-байт. +- **+** Наш прод недостижим по построению: артефакты инертны в нашем контуре, kill-switch не + нужен (паттерн ORCH-009); все существующие анти-дрейф тесты остаются зелёными. +- **−** Новая поверхность сопровождения: пиннованные версии Plane/Gitea стареют (апгрейд — + отдельные задачи, NFR-6); двойной `.env`-слой (bundle-инфра vs runtime орка) требует + дисциплины «писатель — bootstrap» (митигировано D2: один писатель + key-sync тест). +- **−** Manual-step Plane CE размывают UX «одной команды» — неустранимо честно (CE без API + первичной инициализации); митигировано контрактом чекпоинта (инструкция+верификация) и + прогрессивной автоматизацией (D7). +- **−** Токен в remote-URL агентских чекаутов — осознанный компромисс тестовой инсталляции + (TR-7; права 600, непубликуемые порты БД, один бот-юзер). +- **Откат:** удалить `deploy/`, `scripts/bootstrap_bundle.py`, `docs/deployment/BUNDLED_SETUP.md`, + три тест-модуля, строку REPLICATION §1 и записи CHANGELOG/CLAUDE.md/README — состояние репо 1:1 + (docs+scripts+tests, без миграций); на целевых хостах — процедура §13 (D9). + +## Ссылки + +- BRD: `docs/work-items/ORCH-103/01-brd.md` +- TRZ: `docs/work-items/ORCH-103/02-trz.md` (OQ-1…OQ-7 → D1…D9) +- Acceptance: `docs/work-items/ORCH-103/03-acceptance-criteria.md`; тест-план: `04-test-plan.yaml` +- Сквозной ADR: `docs/architecture/adr/adr-0038-bundled-replication-canon.md` +- Предшественники: adr-0035 (ORCH-009 onboarding: D10 branch-protection, manual-step, `_push_url`), + adr-0036 (ORCH-101 10-common: параметризация/«дефолт=боевое»/gen_secrets/REPLICATION), + adr-0037 (ORCH-102 Lite: док-канон/`.env.watchdog.example`/compose-подмножество) +- Сверено по коду/репо: `tests/test_lite_setup_doc.py` (заморозка корневого compose, FORBIDDEN-импорт, + секрет-эвристика), `tests/test_no_host_hardcodes.py` (`FORBIDDEN`), `scripts/gen_secrets.py` + (`--write PATH`, exit 0/2), `scripts/onboard_project.py` (закрытый src-импорт-лист, MANUAL + `plane.workspace-webhook`, `_push_url`, exit 0/2/1), `docs/operations/ONBOARDING.md` (host-venv), + `docker-compose.yml` (паттерны `${VAR:-default}`, `env_file required:false`, группа ORCH-040), + `.gitignore` (неякорные `.env`/`data/`/`.env.watchdog`) diff --git a/docs/work-items/ORCH-103/07-infra-requirements.md b/docs/work-items/ORCH-103/07-infra-requirements.md new file mode 100644 index 0000000..fce993d --- /dev/null +++ b/docs/work-items/ORCH-103/07-infra-requirements.md @@ -0,0 +1,73 @@ +--- +work_item: ORCH-103 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-103 — Bundled-тираж: весь стек одним комплектом + bootstrap + +Work Item: **ORCH-103** · Repo: **orchestrator** · Стадия: architecture + +> Вся инфраструктура этой задачи — **ЦЕЛЕВОЙ хост заказчика** (и одноразовый тестовый хост/VM +> приёмки). Инфраструктура НАШЕГО прод-контура (mva154) не затрагивается ни одним пунктом: +> артефакты bundle в нашем контуре инертны (NFR-2, паттерн ORCH-009). + +## I-1. Топология / окружения + +**Наш контур: N/A** (корневой `docker-compose.yml`, прод 8500, staging 8501 — байт-в-байт). + +**Целевой хост bundle (нормативно, ADR-001 D1/D3/D4):** +- Один Linux x86_64 хост, docker + docker compose v2, sudo у оператора. Compose-проект + **`orchestrator-bundle`** (`deploy/bundled/docker-compose.yml`), одна именованная bridge-сеть. +- Состав: `orchestrator` (build из корневого `Dockerfile`), `orchestrator-watchdog` + (build из `watchdog/Dockerfile`), `gitea` (пиннованный `gitea/gitea`), Plane CE-стек — + зеркало upstream selfhost-référence (≈13–14 сервисов: web/space/admin/api/worker/beat-worker/ + migrator/live + postgres/redis/mq/minio/proxy; точные теги пиннит developer по проверенному + стенду). Staging-контур орка отсутствует. +- **Карта портов (дефолты; конфигурируемы в `deploy/bundled/.env.example`):** + `${BUNDLE_ORCH_PORT:-8500}` — API орка (smoke/health), `${BUNDLE_PLANE_PORT:-8080}` — Plane + proxy (UI), `${BUNDLE_GITEA_HTTP_PORT:-3000}` — Gitea web. Postgres/redis/mq/minio/ssh-Gitea — + **наружу не публикуются**. Машинный трафик (webhooks в обе стороны, API, git, /metrics) — + внутрисетевой сервис-DNS. +- **Хранилище:** состояние Plane/Gitea — именованные тома `orchestrator-bundle_*`; данные орка — + bind `deploy/bundled/data`; репозитории агентов — bind `deploy/bundled/repos` (владелец — + uid оператора = `ORCH_RUN_UID`, инвариант ORCH-040). +- **Ресурсы (предусловие, гипотеза BRD §6 — финальные цифры по замеру на приёмке, AC-4):** + ориентир ≥ 4 vCPU / 8 GB RAM / 40 GB диска; preflight bootstrap проверяет и отказывает до + любых мутаций (BR-7). + +## I-2. Переменные окружения / секреты + +- **Новый канон:** `deploy/bundled/.env.example` (bundle-инфра: `BUNDLE_PUBLIC_HOST`, карта + портов, реюз `ORCH_RUN_UID/GID`/`ORCH_DOCKER_GID`/`ORCH_AGENT_HOME_DIR`/`ORCH_HOST_CLAUDE_*`, + плейсхолдеры внутренних кред Plane/Gitea по upstream-именам). Live-файлы только на целевом + хосте, права 600: `deploy/bundled/.env`, корневые `.env`/`.env.watchdog` (каноны Lite 1:1). +- **Корневой `.env.example` НЕ меняется** (bundle не вводит новых ключей `Settings`); в + `.gitignore` добавляется `deploy/bundled/repos/` (остальные live-файлы уже покрыты неякорными + `.env`/`data/`/`.env.watchdog`). +- **Секреты (FR-3):** webhook-секреты — `gen_secrets.py`; внутренние креды стека (postgres/ + redis/mq/minio/SECRET_KEY Plane, админ Gitea) — stdlib `secrets` в bootstrap; внешние + предусловия заказчика — Claude CLI/Anthropic-доступ (обязателен для конвейера), + Telegram-токены (опциональны). В репо и логах bootstrap секретов нет (NFR-3, тест-эвристика). + +## I-3. Деплой / рестарт + +- **Наш прод: рестарт НЕ требуется и НЕ выполняется.** Задача — docs+scripts+compose+tests; + мерж в `main` ничего не активирует в нашем контуре (никто не исполняет bundle-артефакты; + kill-switch не нужен — паттерн ORCH-009). Self-hosting инвариант соблюдён по построению. +- На целевом хосте пересоздание контейнеров орка/watchdog после сборки env — штатный шаг + bootstrap (D5 шаг 8); к нашему проду отношения не имеет. + +## I-4. CI/CD + +- `.gitea/workflows/**` — **без изменений**; три новых структурных тест-модуля подхватываются + существующим `pytest tests/ -q` (без docker/сети/LLM — CI-безопасны, TC-12). + +## I-5. Разовое предусловие приёмки (человек) + +Чистый тестовый Linux-хост/VM (ресурсы I-1) для ручной приёмки AC-1/AC-2/AC-3/AC-8 по +`BUNDLED_SETUP.md` + замер фактических минимумов RAM/диск/CPU для §2 дока (AC-4: цифры «не с +потолка»). На нашем боевом хосте bundle не запускается ни в каком виде (BRD §2.2). diff --git a/docs/work-items/ORCH-103/10-tech-risks.md b/docs/work-items/ORCH-103/10-tech-risks.md new file mode 100644 index 0000000..5c522f7 --- /dev/null +++ b/docs/work-items/ORCH-103/10-tech-risks.md @@ -0,0 +1,42 @@ +--- +work_item: ORCH-103 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-103 — Bundled-тираж: весь стек одним комплектом + bootstrap + +Work Item: **ORCH-103** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации и эксплуатации bundle; решения — +> `06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md` (D1…D11). + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Ресурсоёмкость Plane** (≈13–14 контейнеров): OOM/вечные миграции на слабом хосте → «bundle не работает» | Сред. | Выс. | Preflight bootstrap (RAM/диск/CPU до мутаций, BR-7); честные цифры в BUNDLED_SETUP §2 **по замеру** (AC-4); таймауты ожидания готовности с диагностикой «кто не дождался + хвост логов» (D5 ш.3); траблшутинг §14 | +| TR-2 | **Дыры Plane CE API**: instance-setup/workspace/API-токен UI-only → UX «одной команды» размывается; молчаливый пропуск шага ломает всё дальше | Выс. | Сред. | Контракт manual-step (инструкция → подтверждение → **API-верификация результата**, exit 2 без TTY; D5/D7); число ручных шагов минимизировано (Gitea — полностью автоматом, D6); прогрессивная автоматизация разрешена без смены контракта | +| TR-3 | **Дрейф upstream-образов**: «плавающий» тег ломает воспроизводимость; пиннованные версии стареют (CVE/несовместимость) | Сред. | Сред. | Неподвижные теги литералом + тест формы TC-03 (не `latest`/безтеговых); теги фиксируются по фактически проверенному стенду (D3); апгрейд — отдельные задачи (NFR-6, BRD §6) | +| TR-4 | **Сетевая недостижимость вебхуков** (труднодиагностируемое «задача не появилась»): приватные адреса, рассинхрон URL | Сред. | Выс. | Одна bridge-сеть + строго сервис-DNS для машинного трафика (D4); **явный `GITEA__webhook__ALLOWED_HOST_LIST=orchestrator`** (Gitea по умолчанию режет приватные таргеты); URL подставляет bootstrap, не оператор; smoke проверяет ОБА направления (FR-6); траблшутинг §14 | +| TR-5 | **Соблазн форкнуть/расширить корневой compose** (упадёт анти-дрейф ORCH-102 TC-04) | Низ. | Выс. | Bundle строго отдельным файлом `deploy/bundled/` (D1); TC-02 зеркалит заморозку корневого compose; правило эскалации TRZ §7 — если всплывёт незакрытая параметризация `src/**`, остановиться и вернуть задачу, не «дотачивать молча» | +| TR-6 | **Утечка секретов** в репо/логи при генерации bundle-кред | Низ. | Выс. | В гите — только плейсхолдеры (канон D2); bootstrap не печатает значения (имена ключей/пути, D5); права 600; секрет-эвристика hex≥32/alnum≥40 + FORBIDDEN-скан на новых артефактах (TC-06); live-файлы в `.gitignore` | +| TR-7 | **Токен-remote агентов**: токен бот-юзера plaintext в `.git/config` чекаутов; один бот = широкие права | Сред. | Низ. | Осознанный компромисс тестовой инсталляции (D8): порты БД/брокера не публикуются, каталог локальный, права 600; ssh-контур сознательно не вводится (меньше поверхность); зафиксировано в BUNDLED_SETUP §1 «рамка» | +| TR-8 | **Путаница двух `.env`-слоёв** (bundle-инфра `deploy/bundled/.env` vs runtime орка корневой `.env`): ручная правка не того файла | Сред. | Сред. | Bootstrap — **единственный писатель** всех live-файлов (D2); авто-чтение compose из project dir (нет `--env-file`-футгана); шапки-комментарии в канонах перекрёстно ссылаются; key-set-sync тест TC-04 | +| TR-9 | **Хост-python/venv для onboard**: `onboard_project.py` требует venv с `requirements.txt` (канон ONBOARDING) — на голом хосте шаг 6 падает | Сред. | Сред. | Preflight проверяет python3/venv (D5 ш.1); создание venv — идемпотентный ensure-шаг bootstrap; сам bootstrap stdlib-only и от venv не зависит (D5) | +| TR-10 | **Повторный запуск/грязный хост**: bootstrap портит чужое состояние или дублирует своё | Низ. | Выс. | Step-движок check→ensure (skip-семантика, AC-8); детект «грязи» по префиксу `orchestrator-bundle` до мутаций; delete-операций в скрипте нет вообще — teardown только документированной процедурой §13 (D9); unit-тесты чистых функций preflight (TC-08) | + +## Сводный вывод + +Доминирующий класс — **эксплуатационные риски целевого хоста** (TR-1/TR-2/TR-4): они не +затрагивают наш прод и гасятся честным preflight, контрактом manual-step и smoke в обе стороны. +Рисков для прод-конвейера self-hosting **нет по построению** (NFR-1/NFR-2: рантайм байт-в-байт, +артефакты в нашем контуре инертны, kill-switch не требуется — паттерн ORCH-009; все существующие +анти-дрейф тесты остаются зелёными). Эскалация `arch:major-change` не требуется: новых стадий/ +компонентов рантайма/смены БД нет — задача целиком в слое дистрибуции (паттерн ORCH-101/102). +Возврат в анализ не требуется: ТЗ выполнимо без нарушения принципов; единственный заранее +оговорённый стоп-кран — TR-5 (обнаружение незакрытой параметризации `src/**` ⇒ остановка по +TRZ §7). Остаточный риск — **низкий**. diff --git a/docs/work-items/ORCH-103/12-review.md b/docs/work-items/ORCH-103/12-review.md new file mode 100644 index 0000000..6509ff1 --- /dev/null +++ b/docs/work-items/ORCH-103/12-review.md @@ -0,0 +1,160 @@ +--- +verdict: APPROVED +work_item: ORCH-103 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-11 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-103 +version: 1 +--- + +# Review ORCH-103 — ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +> Машинный вердикт читается ТОЛЬКО из `verdict:` во frontmatter. + +## Summary + +PR закрывает Type B эпика ORCH-10 строго по ТЗ и ADR-001 (D1–D11): новый каталог +`deploy/bundled/` (самодостаточный compose 16 сервисов, project name `orchestrator-bundle`, +пиннинг неподвижными тегами, одна bridge-сеть, только человеческие порты, мина +`GITEA__webhook__ALLOWED_HOST_LIST` закрыта), `scripts/bootstrap_bundle.py` (stdlib-only, +`plan`-дефолт/`apply`/`verify`, step-движок check→ensure, exit 0/2/1, ноль delete-операций), +конфиг-канон `deploy/bundled/.env.example` (ни одного дефолтного пароля), +`docs/deployment/BUNDLED_SETUP.md` (14 разделов канона D10) и три содержательных +анти-дрейф тест-модуля. Рантайм байт-в-байт: `git diff main` не содержит `src/**`, +корневого `docker-compose.yml`, `Dockerfile`, `.gitea/workflows/**` (AC-6 подтверждён +diff-stat'ом). Полный регресс: **`pytest tests/ -q` → 1844 passed, 0 failed** (AC-5); +существующие анти-дрейф тесты (`test_lite_setup_doc.py`, `test_no_host_hardcodes.py`, +канон ORCH-009) не правились. Документация обновлена в том же PR по всем точкам §8 ТЗ. +Findings — только P2/P3, блокеров нет → **APPROVED**. + +## Оси проверки + +### 1. Соответствие ТЗ (02-trz.md, 03-acceptance-criteria.md) + +- **FR-1** ✅ — отдельный bundle-compose; состав = ADR D1/D3 (тест + `test_bundle_has_exactly_the_adr_service_set` фиксирует множество); пиннинг всех сторонних + образов литералом (TC-03); тома — именованные с префиксом проекта + bind строго внутри + project dir (TC-04); достижимость в обе стороны — сервис-DNS (D4) + `ALLOWED_HOST_LIST`; + карта портов в доке §2, конфликт порта → отказ preflight; staging-контура нет вовсе. +- **FR-2** ✅ — последовательность шагов 1–9 ТЗ воспроизведена 1:1 в `APPLY_STEPS` + (тест `test_apply_steps_match_normative_plan` держит соответствие нормативному плану); + идемпотентность — check→ensure/skip (AC-8 покрыт unit'ами resume/«противоречивое + состояние»); exit-контракт 0/2/1 (`test_exit_code_contract`); манифест manual-step честный: + инструкция → подтверждение → API-верификация, без TTY — немедленный exit 2. +- **FR-3** ✅ — webhook-секреты строго субпроцессом `gen_secrets.py` (структурный тест); + bundle-креды — stdlib `secrets` (token_hex ≥16 байт, unit проверяет длину); в репо только + пустые плейсхолдеры (`test_bundle_secrets_in_example_are_empty_placeholders`); права 600; + без перетирания без `--force-secrets` (`test_merge_missing_secrets_never_overwrites_without_force`). +- **FR-4** ✅ — BUNDLED_SETUP.md: все 14 разделов в порядке маршрута, fenced-команды + + «Проверка:»/PASS/FAIL в каждом исполняемом разделе, общие шаги ссылками на + LITE_SETUP §5–§8 / ONBOARDING / REPLICATION §4 (форк канона отсутствует), fail-closed имена + `Confirm Deploy`/`STOP` и «22 статуса» — сверкой импорта `plane_sync._PLANE_NAME_TO_KEY`. +- **FR-5** ✅ — три модуля без docker/сети/LLM; FORBIDDEN — импорт из + `test_no_host_hardcodes.py` (один источник истины); секрет-эвристика hex≥32/alnum≥40 с + негативным самочеком (не-evergreen); key-set-sync `${VAR}` ⊆ bundle-канона; заморозка + корневого compose зеркалом ассерта ORCH-102. +- **FR-6** ✅ — smoke = REPLICATION §4 поверх bundle (§11 дока, без форка), минимальный + сигнал «артефакты 01–04 в ветке» зафиксирован. +- **AC-матрица:** AC-4…AC-9 в файловой/структурной части — PASS (TC-01…TC-12 зелёные). + AC-1/AC-2/AC-3/AC-8(повторный прогон) в e2e-части — **ручная приёмка** на чистом хосте/VM + по рамке самих AC (в CI docker/LLM не гоняются) — остаётся за стадией приёмки, см. P2-2. + +### 2. Соответствие ADR (06-adr/ADR-001, adr-0038) + +- D1–D11 реализованы без отклонений; сквозной `adr-0038-bundled-replication-canon.md` заведён. +- **Прогрессивная автоматизация webhook (D7)** — единственное место, где реализация глубже + буквы ADR: `step_plane_webhook` регистрирует workspace-webhook прямой записью в Postgres + инсталляции. Сверено: это **не новый канон, а переиспользование** документированного + «пути Б» LITE_SETUP §5.4 (тот же INSERT-контракт, колонка-в-колонку), контракт чекпоинта + сохранён (верификация SELECT'ом; при отказе — fallback на честный manual-step с той же + проверкой), схема стабильна благодаря пину `v0.23.1`. D7 явно разрешает такую замену + manual-step → ensure «без правки ADR». Нарушения нет; в доке §7 чекпоинт 4 описан честно. +- **Трассировка (TRACEABILITY):** правка чужого маркированного блока одна — строка Type B в + `REPLICATION.md` §1 (артефакт ORCH-101); это запланированная точка расширения, прямо + предписанная ТЗ FR-6/§8 — инвариант ORCH-101 не сломан. Остальные изменения аддитивны + (новые файлы / новые секции CLAUDE.md, README архитектуры, CHANGELOG). +- Нормативы предшественников соблюдены: branch protection НЕ настраивается (D10 ORCH-009 / + INV-4 — явно в D6 и §14.6 дока), compose не форкается (adr-0037), «дефолт = боевое» не + нарушен (корневой `.env.example` не тронут). + +### 3. Качество кода + +- `pytest tests/ -q`: **1844 passed, 0 failed** (66s); новые тесты содержательные — unit'ы + чистых функций покрывают позитив/негатив/resume/противоречивое состояние, эвристики имеют + негативный самочек, ast-скан stdlib-allowlist реально закрыт. +- Бизнес-логика скрипта аккуратная: never-print секретов в stdout (только имена ключей), + маскированный лог при падении clone (токен в URL не утекает в вывод), `_psql` через stdin + (секрет не в argv), fail-fast preflight до любых мутаций, диагностика «кто не дождался + + хвост логов». Не багфикс-трек (feature) — требование регресс-теста-фиксатора BR-4/ORCH-019 + неприменимо. +- Замечания P2/P3 — ниже; ни одно не ломает инварианты конвейера и не относится к рантайму + платформы (скрипт исполняется только оператором на целевом хосте). + +### 4. Документация — обязательная проверка + +Выполнена явно, см. раздел «Документация» ниже. Обновлено всё требуемое в том же PR. + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- [ ] **P2-1. Секреты в argv субпроцессов** (`scripts/bootstrap_bundle.py`): + `step_init_gitea` передаёт `GITEA_ADMIN_PASSWORD` аргументом `--password` в + `docker compose exec gitea gitea admin user create …`, а `step_agent_git` — токен в + clone-URL аргументом `git clone http://oauth2:@…`. Значения видимы в `ps` хоста на + время исполнения. Формально AC-8 («секрет виден в stdout/логе») не нарушен, но это против + духа NFR-3 (ТЗ FR-2) и непоследовательно с собственной argv-гигиеной скрипта (`_psql` + прогоняет секреты через stdin с явным комментарием «секреты не попадают в argv, NFR-3»). + Рекомендация: для clone — использовать `git -c credential.helper`/`GIT_ASKPASS` либо + дописать компромисс в TR-7 (10-tech-risks) и шапку скрипта; для `gitea admin user create` + альтернатив CLI мало — минимум зафиксировать окно экспозиции в TR-7. Не блокер: разовая + операция оператора на одноарендном целевом хосте, угроза-модель совпадает с уже + зафиксированным компромиссом TR-7. +- [ ] **P2-2. Замер цифр «Требований к хосту» отложен на приёмку** (AC-4): BUNDLED_SETUP §2 + декларирует «подтверждаются замером приёмочного развёртывания» — на момент ревью цифры + (8 GB / 40 GB / 4 vCPU) синхронизированы с константами preflight структурным тестом, но + фактический замер ещё не зафиксирован. По рамке 03-acceptance-criteria (e2e — ручная + приёмка вне CI) это допустимо, однако при ручной приёмке AC-1/AC-2 результат замера нужно + зафиксировать (13-test-report / 15-staging-log или правка §2), иначе FAIL-условие AC-4 + «цифры с потолка» останется формально незакрытым. + +### P3 — Nice to have +- [ ] **P3-1.** `step_plane_webhook`: `slug`/`secret` интерполируются в SQL-строку без + экранирования одинарных кавычек. Секрет — hex от `gen_secrets` (безопасен), slug — + операторский ввод; кавычка в slug уронит INSERT. Риск минимален (ON_ERROR_STOP + + fail-safe fallback на manual-step), но дешёвое `value.replace("'", "''")` сняло бы класс + целиком. +- [ ] **P3-2.** `run_verify`: при одновременном health-FAIL и onboard `exit 2` функция + возвращает `EXIT_MANUAL` (2), маскируя ошибку (ожидался бы приоритет `1`). Поведенческая + мелочь read-only режима. + +## Документация + +| Артефакт | Статус | +|----------|--------| +| `CLAUDE.md` | ✅ новый раздел «Bundled-тираж (ORCH-103)» (паттерн ORCH-101/102) | +| `docs/architecture/README.md` | ✅ блок Type B — Bundled рядом с 10-common/Lite | +| `CHANGELOG.md` | ✅ запись `feat: ORCH-103` (детальная, D1–D11) | +| `docs/operations/REPLICATION.md` §1 | ✅ Type B → ✅ ORCH-103 + ссылка на BUNDLED_SETUP.md | +| `docs/deployment/BUNDLED_SETUP.md` | ✅ создан, 14 разделов канона D10, держится тестом | +| ADR | ✅ work-item `06-adr/ADR-001-…` + сквозной `adr-0038-bundled-replication-canon.md` | +| `07-infra-requirements.md`, `10-tech-risks.md` (TR-1…TR-9) | ✅ заведены архитектором, кросс-рефы из кода сходятся | +| `.gitignore` | ✅ `deploy/bundled/repos/` (NFR-3) | +| `README.md` «Известные ограничения» (ORCH-079) | ✅ проверено явно: PR не закрывает ни один из 3 открытых пунктов (Telegram 48h / intra-repo deps / пакетный автоном) — обновление витрины не требуется | + +`src/**` не изменён (PR — deploy/scripts/docs/tests), поэтому P0-правило «`src/` изменён без +документации» неприменимо; документация при этом обновлена полностью. + +## Итог + +`verdict: APPROVED` — P0/P1 отсутствуют; P2-1/P2-2 и P3 рекомендуется снять follow-up'ом или +при ручной приёмке Bundled-развёртывания (AC-1/AC-2/AC-3/AC-8 e2e-часть). diff --git a/docs/work-items/ORCH-103/13-test-report.md b/docs/work-items/ORCH-103/13-test-report.md new file mode 100644 index 0000000..eb322f2 --- /dev/null +++ b/docs/work-items/ORCH-103/13-test-report.md @@ -0,0 +1,67 @@ +--- +result: PASS +work_item: ORCH-103 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-11 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-103 +--- + +# Test Report — ORCH-103 — ORCH-10b Bundled-тираж: весь стек одним комплектом + bootstrap-скрипт + +> Машинный вердикт читается ТОЛЬКО из `result:` во frontmatter (`check_tests_passed`). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-103-orch-10b-bundled-bootstrap/` (ветка задачи, не общий чекаут) +- Дата: 2026-06-11 + +## Smoke API (read-only) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — активная задача ORCH-103 (`stage: testing`) видна | +| `GET /queue` | PASS — валидный JSON; блок `serial_gate` присутствует (ORCH-088, `orchestrator.active_task = ORCH-103`), блок `auto_labels` присутствует | + +Смок-регресс ORCH-088: блок `serial_gate` присутствует в полезной нагрузке `/queue` наряду с `auto_labels` — регресса нет. + +## Результаты (покрытие каждого TC из 04-test-plan.yaml) + +| TC ID | Тип | Описание (кратко) | AC | Модуль | Результат | +|-------|-----|-------------------|----|--------|-----------| +| TC-01 | unit | Bundle-compose существует/парсится; обязательные сервисы (орк/watchdog/Gitea/Plane-стек); staging не в дефолтном up | AC-1 | test_bundle_compose.py | PASS | +| TC-02 | unit | Корневой docker-compose.yml не изменён; нет подстрок plane/gitea (зеркало TC-04 lite) | AC-6 | test_bundle_compose.py | PASS | +| TC-03 | unit | Все сторонние образы пиннованы (нет latest/безтегового) | AC-9 | test_bundle_compose.py | PASS | +| TC-04 | unit | Тома именованные с bundle-префиксом, без bind прод-контура; key-set-sync `${VAR}` ⊆ bundle-канон | AC-9 | test_bundle_compose.py | PASS | +| TC-05 | unit | BUNDLED_SETUP.md: обязательные разделы FR-4 (требования к хосту с цифрами/портами/~14 контейнеров, bootstrap, smoke, stateless, сброс, траблшутинг); fenced + «Проверка:» | AC-4 | test_bundled_setup_doc.py | PASS | +| TC-06 | unit | Гигиена (док+compose+bootstrap): нет FORBIDDEN-литералов (импорт из test_no_host_hardcodes); нет секрет-литералов (hex≥32/alnum≥40) | AC-9 | test_bundled_setup_doc.py | PASS | +| TC-07 | unit | Bootstrap ссылается на gen_secrets.py/onboard_project.py; нет своего списка статусов/лейблов; нет delete-операций в обычном прогоне | AC-7 | test_bootstrap_script.py | PASS | +| TC-08 | unit | Чистые функции bootstrap: грязный хост → отказ ДО мутаций; чистый → план; контракт exit 0/2/1 | AC-8 | test_bootstrap_script.py | PASS | +| TC-09 | unit | Каждый env-ключ из дока есть в `.env.example` ∪ bundle-канон; число статусов сверяется импортом `plane_sync._PLANE_NAME_TO_KEY` | AC-9 | test_bundled_setup_doc.py | PASS | +| TC-10 | unit | Кросс-ссылки: BUNDLED_SETUP → LITE_SETUP/ONBOARDING/REPLICATION; REPLICATION §1 отметка Type B → ORCH-103 | AC-7 | test_bundled_setup_doc.py | PASS | +| TC-11 | unit | CHANGELOG.md содержит запись ORCH-103 | AC-5 | test_bundled_setup_doc.py | PASS | +| TC-12 | integration | Полный регресс `pytest tests/ -q` зелёный; существующие анти-дрейф (test_lite_setup_doc.py, test_no_host_hardcodes.py, канон ORCH-009) без правки ассертов | AC-5 | tests/ | PASS | + +Все 12 TC выполнены и сопоставлены с критериями приёмки 03-acceptance-criteria.md. Структурная/файловая часть AC-4…AC-9 покрыта зелёными TC-01…TC-12. e2e-часть AC-1/AC-2/AC-3/AC-8 (фактический подъём bundle на чистом хосте/VM) — ручная приёмка вне CI по рамке самих AC (docker/LLM в CI не гоняются), как зафиксировано в test-plan `scope/notes` и ревью P2-2. + +## Целевые модули ORCH-103 (детально) +- `tests/test_bundle_compose.py` — 14 passed (TC-01..04) +- `tests/test_bundled_setup_doc.py` — 16 passed (TC-05/06/09/10/11) +- `tests/test_bootstrap_script.py` — 19 passed (TC-07/08) +- Анти-дрейф (без правки ассертов): `tests/test_lite_setup_doc.py` — 26 passed; `tests/test_no_host_hardcodes.py` — 8 passed +- Итого по целевому срезу: **88 passed, 0 failed** (0.91s) + +## Вывод pytest (полный регресс) +``` +$ python -m pytest tests/ -v --tb=short +... +================== 1844 passed, 1 warning in 70.33s (0:01:10) ================== +``` +- 1844 passed, 0 failed, 1 warning (Pydantic V2 deprecation, не относится к задаче). + +## Итог +PASS — полный регресс зелёный (1844 passed / 0 failed), smoke read-only (`/health`, `/status`, `/queue` с блоками `serial_gate` и `auto_labels`) в норме, все 12 TC из 04-test-plan.yaml выполнены и сопоставлены с критериями приёмки. Задача переходит на `deploy-staging`. diff --git a/docs/work-items/ORCH-103/14-deploy-log.md b/docs/work-items/ORCH-103/14-deploy-log.md new file mode 100644 index 0000000..2b11ff5 --- /dev/null +++ b/docs/work-items/ORCH-103/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-103 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/scripts/bootstrap_bundle.py b/scripts/bootstrap_bundle.py new file mode 100644 index 0000000..d4e0c70 --- /dev/null +++ b/scripts/bootstrap_bundle.py @@ -0,0 +1,972 @@ +#!/usr/bin/env python3 +"""bootstrap_bundle.py — доводка Bundled-инсталляции до рабочего конвейера (ORCH-103). + +Один запуск поверх `deploy/bundled/docker-compose.yml` доводит свежеподнятый стек +(орк + watchdog + Gitea + Plane CE) до рабочего состояния: preflight → секреты → +up + готовность → init Gitea (полностью автоматом) → init Plane (честные +manual-step чекпоинты с верификацией) → онбординг sandbox-проекта строго +кирпичом ``scripts/onboard_project.py`` → git-доступ агентов (HTTP token-remote) +→ сборка runtime-конфига орка (корневые ``.env`` / ``.env.watchdog``) → health. + +Режимы (ADR-001 D5, паттерн ORCH-009): + plan — дефолт; ноль мутаций: печать плана + read-only preflight-диагностика. + apply — полный прогон; step-движок check→ensure (повторный запуск = каскад + skip; «resume» после manual-step = просто повторный запуск). + verify — read-only пост-проверка (health/queue/metrics + onboard verify). + +Exit-коды (контракт TRZ FR-2): 0 — успех; 2 — остановка на manual-step или +незавершённое предусловие; 1 — ошибка. + +Гарантии (NFR-3 / D5 / D9): + * python stdlib-only; модули платформы не импортируются (канон-знания — только + субпроцессами кирпичей gen_secrets.py / onboard_project.py, AC-7); + * значения секретов НИКОГДА не печатаются (только имена ключей/пути файлов); + * delete-операций НЕТ ВООБЩЕ: teardown — только документированная процедура + docs/deployment/BUNDLED_SETUP.md §13 (ADR-001 D9); + * существующие секреты не перетираются без явного ``--force-secrets`` + (использовать только ДО первого подъёма стека: уже инициализированные + Plane/Gitea новых паролей сами не подхватят); + * скрипт говорит только с локальным docker целевого хоста. + +Запуск — из корня чекаута репо orchestrator на целевом хосте: + python3 scripts/bootstrap_bundle.py # план + диагностика + python3 scripts/bootstrap_bundle.py apply # полный прогон + python3 scripts/bootstrap_bundle.py verify # read-only пост-проверка +""" + +import argparse +import getpass +import json +import os +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BUNDLE_DIR = os.path.join(REPO_ROOT, "deploy", "bundled") +BUNDLE_COMPOSE = os.path.join(BUNDLE_DIR, "docker-compose.yml") +BUNDLE_ENV_EXAMPLE = os.path.join(BUNDLE_DIR, ".env.example") +BUNDLE_ENV = os.path.join(BUNDLE_DIR, ".env") +ROOT_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.example") +ROOT_ENV = os.path.join(REPO_ROOT, ".env") +WATCHDOG_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.watchdog.example") +WATCHDOG_ENV = os.path.join(REPO_ROOT, ".env.watchdog") +GEN_SECRETS = os.path.join(REPO_ROOT, "scripts", "gen_secrets.py") +ONBOARD = os.path.join(REPO_ROOT, "scripts", "onboard_project.py") +REQUIREMENTS = os.path.join(REPO_ROOT, "requirements.txt") +VENV_DIR = os.path.join(REPO_ROOT, ".venv") +VENV_PY = os.path.join(VENV_DIR, "bin", "python") + +DOC = "docs/deployment/BUNDLED_SETUP.md" + +# Узнаваемый префикс томов/контейнеров инсталляции (compose project name, D1). +PROJECT = "orchestrator-bundle" +ORCH_CONTAINER = "orchestrator-bundle-orchestrator-1" + +# Машинные in-network URL (D4): сервис-DNS bundle-сети, не хост. +WEBHOOK_PLANE_URL = "http://orchestrator:8500/webhook/plane" +WEBHOOK_GITEA_URL = "http://orchestrator:8500/webhook/gitea" +GITEA_INTERNAL_URL = "http://gitea:3000" +PLANE_INTERNAL_URL = "http://proxy" + +EXIT_OK = 0 +EXIT_MANUAL = 2 +EXIT_ERROR = 1 + +# Минимумы хоста (синхронизированы с BUNDLED_SETUP §2; пороги preflight, TR-1). +MIN_RAM_GB = 8 +MIN_DISK_GB = 40 +MIN_CPUS = 4 + +# Тайм-ауты ожидания готовности (D5 шаг 3): миграции Plane — самые долгие. +READY_TIMEOUT_S = 180 +PLANE_READY_TIMEOUT_S = 600 + +# Bundle-внутренние креды (upstream-имена, D2/FR-3) — генерирует bootstrap. +BUNDLE_SECRET_KEYS = ( + "POSTGRES_PASSWORD", + "SECRET_KEY", + "RABBITMQ_DEFAULT_PASS", + "MINIO_ROOT_PASSWORD", + "GITEA_ADMIN_PASSWORD", +) + +# Обязательные НЕсекретные ключи bundle-конфига (preflight, D5 шаг 1). +REQUIRED_BUNDLE_KEYS = ( + "BUNDLE_PUBLIC_HOST", + "BUNDLE_ORCH_PORT", + "BUNDLE_PLANE_PORT", + "BUNDLE_GITEA_HTTP_PORT", + "ORCH_RUN_UID", + "ORCH_RUN_GID", + "ORCH_DOCKER_GID", + "ORCH_AGENT_HOME_DIR", + "GITEA_ADMIN_USERNAME", +) + +# Webhook-секреты орка — выпускает ТОЛЬКО кирпич gen_secrets.py (AC-7). +WEBHOOK_SECRET_KEYS = ("ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET") + +# Sandbox-проект первого smoke (онбордится строго onboard_project.py, BR-6). +SANDBOX_DEFAULTS = { + "name": "Sandbox", + "repo": "sandbox", + "prefix": "SBX", + "stack": "python", + "test_cmd": "pytest -q", + "prod_port": "8600", + "staging_port": "8601", +} + + +class ManualStop(Exception): + """Остановка на manual-step / незавершённом предусловии → exit 2.""" + + +class BootstrapError(Exception): + """Невосстановимая ошибка шага → exit 1.""" + + +def log(msg: str) -> None: + """Печать строки прогресса. Значения секретов сюда НЕ передаются (NFR-3).""" + print(msg, flush=True) + + +# --------------------------------------------------------------------------- # +# Чистые функции (unit-тесты — tests/test_bootstrap_script.py, TC-08) +# --------------------------------------------------------------------------- # + +def parse_env(text: str) -> dict: + """``KEY=value``-строки текста → словарь (комментарии/пустые — мимо).""" + out: dict = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + out[key.strip()] = value.strip() + return out + + +def render_env(example_text: str, overrides: dict) -> str: + """Рендер env-файла от канона-example: ``KEY=`` строки получают значения + overrides (та же строка, комментарии сохранены); ключи overrides, которых + в каноне нет, дописываются управляемым блоком в конец.""" + used: set = set() + lines: list = [] + for line in example_text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in overrides: + lines.append(f"{key}={overrides[key]}") + used.add(key) + continue + lines.append(line) + extra = [k for k in overrides if k not in used] + if extra: + lines.append("") + lines.append("# --- bootstrap_bundle.py (ORCH-103): управляемые ключи ---") + for key in extra: + lines.append(f"{key}={overrides[key]}") + return "\n".join(lines) + "\n" + + +def merge_missing_secrets(existing: dict, keys: tuple = BUNDLE_SECRET_KEYS, + force: bool = False, gen=None) -> dict: + """Новые значения ТОЛЬКО для пустых/отсутствующих секрет-ключей (AC-8: + существующие не перетираются; ``force=True`` — явная регенерация всех).""" + gen = gen or (lambda key: secrets.token_hex(32 if key == "SECRET_KEY" else 16)) + fresh: dict = {} + for key in keys: + if force or not existing.get(key, ""): + fresh[key] = gen(key) + return fresh + + +def preflight_verdict(facts: dict) -> tuple: + """Чистый вердикт preflight (BR-7): ``(blockers, warnings, resume)``. + + resume=True — на хосте уже есть тома/контейнеры с префиксом проекта: + не «грязь», а инициализированная инсталляция → ensure-режим (AC-8); + противоречивое состояние (есть тома, но нет конфига) — блокер. + """ + blockers: list = [] + warnings: list = [] + resume = bool(facts.get("leftovers")) + + if not facts.get("docker"): + blockers.append("docker не найден — установите Docker Engine (BUNDLED_SETUP §3)") + if not facts.get("compose"): + blockers.append("docker compose v2 не найден (BUNDLED_SETUP §3)") + if not facts.get("env_exists"): + if resume: + blockers.append( + "противоречивое состояние: тома/контейнеры orchestrator-bundle уже " + "есть, а deploy/bundled/.env отсутствует — восстановите конфиг " + "или выполните полный сброс (BUNDLED_SETUP §13)" + ) + else: + blockers.append( + "deploy/bundled/.env отсутствует — создайте: " + "cp deploy/bundled/.env.example deploy/bundled/.env (BUNDLED_SETUP §5)" + ) + for key in facts.get("missing_keys", []): + blockers.append(f"deploy/bundled/.env: обязательный ключ {key} пуст") + if not resume: + for port in facts.get("busy_ports", []): + blockers.append( + f"порт {port} уже занят на хосте — освободите его или смените " + f"BUNDLE_*-порт в deploy/bundled/.env (BUNDLED_SETUP §2)" + ) + ram = facts.get("ram_gb") + if ram is not None and ram < MIN_RAM_GB: + blockers.append( + f"RAM {ram:.1f} GB < минимума {MIN_RAM_GB} GB (Plane ≈ 14 контейнеров, " + f"BUNDLED_SETUP §2)" + ) + disk = facts.get("disk_gb") + if disk is not None and disk < MIN_DISK_GB: + blockers.append(f"свободный диск {disk:.0f} GB < минимума {MIN_DISK_GB} GB") + cpus = facts.get("cpus") + if cpus is not None and cpus < MIN_CPUS: + warnings.append(f"CPU {cpus} < рекомендуемых {MIN_CPUS} vCPU — стек будет медленным") + if not facts.get("python3", True): + blockers.append("python3/venv недоступны — нужны для onboard-кирпича (TR-9)") + if not facts.get("claude_cli"): + warnings.append( + "Claude CLI/креды не найдены на хосте — стек поднимется, но конвейер " + "без LLM не поедет (BUNDLED_SETUP §8)" + ) + return blockers, warnings, resume + + +def build_plan() -> list: + """Нормативный план apply (нумерация — TRZ FR-2; механика — ADR-001 D5).""" + return [ + ("preflight", "fail-fast проверки хоста ДО любых мутаций (BR-7)"), + ("secrets", "новые секреты инсталляции: gen_secrets.py + bundle-креды (FR-3)"), + ("up", "подъём bundle-compose + ожидание готовности (миграции Plane/Gitea)"), + ("init-gitea", "админ-бот + API-токен через `gitea admin ...` (полностью автоматом)"), + ("init-plane", "instance-setup/workspace/API-токен — manual-step с верификацией"), + ("plane-webhook", "workspace-webhook Plane → орк (ensure либо manual-step + проверка)"), + ("onboard", "sandbox-проект строго через onboard_project.py apply+verify (BR-6)"), + ("agent-git", "git-доступ агентов: клон sandbox-репо token-remote в /repos (D8)"), + ("orch-env", "сборка корневых .env/.env.watchdog + пересоздание орка/watchdog"), + ("health", "GET /health, /queue, /metrics + итоговая сводка PASS/FAIL"), + ] + + +def build_arg_parser() -> argparse.ArgumentParser: + """CLI: режимы plan (дефолт) / apply / verify + параметры sandbox.""" + parser = argparse.ArgumentParser( + description="Bootstrap Bundled-инсталляции (ORCH-103). Канон — " + f"{DOC}." + ) + parser.add_argument( + "mode", nargs="?", default="plan", choices=("plan", "apply", "verify"), + help="plan — дефолт, ноль мутаций; apply — прогон; verify — пост-проверка", + ) + parser.add_argument( + "--force-secrets", action="store_true", + help="регенерировать СУЩЕСТВУЮЩИЕ bundle-креды (только ДО первого up!)", + ) + parser.add_argument("--sandbox-name", default=SANDBOX_DEFAULTS["name"]) + parser.add_argument("--sandbox-repo", default=SANDBOX_DEFAULTS["repo"]) + parser.add_argument("--sandbox-prefix", default=SANDBOX_DEFAULTS["prefix"]) + parser.add_argument("--sandbox-stack", default=SANDBOX_DEFAULTS["stack"]) + parser.add_argument("--sandbox-test-cmd", default=SANDBOX_DEFAULTS["test_cmd"]) + parser.add_argument("--sandbox-prod-port", default=SANDBOX_DEFAULTS["prod_port"]) + parser.add_argument("--sandbox-staging-port", default=SANDBOX_DEFAULTS["staging_port"]) + return parser + + +# --------------------------------------------------------------------------- # +# Тонкие обёртки subprocess/HTTP (единственные точки side-effects) +# --------------------------------------------------------------------------- # + +def _run(cmd: list, input_text: str | None = None, env: dict | None = None, + timeout: int = 600) -> subprocess.CompletedProcess: + """subprocess.run c capture; команды логируются БЕЗ секретов вызывающим.""" + return subprocess.run( + cmd, input=input_text, env=env, capture_output=True, text=True, + timeout=timeout, check=False, + ) + + +def _compose(*args: str, input_text: str | None = None, + timeout: int = 600) -> subprocess.CompletedProcess: + return _run(["docker", "compose", "-f", BUNDLE_COMPOSE, *args], + input_text=input_text, timeout=timeout) + + +def _http(url: str, headers: dict | None = None, timeout: int = 10) -> tuple: + """GET url → (status|None, body). Никогда не бросает (poll-friendly).""" + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, "" + except (urllib.error.URLError, OSError, ValueError): + return None, "" + + +def _port_busy(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + return s.connect_ex(("127.0.0.1", port)) == 0 + + +def _write_private(path: str, content: str) -> None: + """Запись live-конфига: права 600, без печати содержимого (NFR-3).""" + with open(path, "w", encoding="utf-8") as f: + f.write(content) + os.chmod(path, 0o600) + log(f" записан {os.path.relpath(path, REPO_ROOT)} (права 600)") + + +def update_env_file(path: str, example_path: str, overrides: dict) -> None: + """Идемпотентный ensure env-файла: существующий — обновить ключи overrides, + отсутствующий — отрендерить от канона-example. Никаких удалений.""" + if os.path.isfile(path): + base = open(path, encoding="utf-8").read() + else: + base = open(example_path, encoding="utf-8").read() + _write_private(path, render_env(base, overrides)) + + +# --------------------------------------------------------------------------- # +# Сбор фактов хоста (read-only; используется plan/apply/verify) +# --------------------------------------------------------------------------- # + +def bundle_ports(bundle_env: dict) -> list: + out = [] + for key, default in (("BUNDLE_ORCH_PORT", 8500), ("BUNDLE_PLANE_PORT", 8080), + ("BUNDLE_GITEA_HTTP_PORT", 3000)): + try: + out.append(int(bundle_env.get(key) or default)) + except ValueError: + out.append(default) + return out + + +def collect_facts(bundle_env: dict) -> dict: + """Read-only снимок хоста для preflight_verdict (ни одной мутации).""" + docker = shutil.which("docker") is not None + compose = docker and _compose("version", timeout=30).returncode == 0 + leftovers: list = [] + if docker: + vols = _run(["docker", "volume", "ls", "--format", "{{.Name}}"], timeout=30) + names = _run(["docker", "ps", "-a", "--format", "{{.Names}}"], timeout=30) + for line in (vols.stdout + "\n" + names.stdout).splitlines(): + if line.strip().startswith(PROJECT): + leftovers.append(line.strip()) + ram_gb = None + try: + with open("/proc/meminfo", encoding="utf-8") as f: + for line in f: + if line.startswith("MemTotal:"): + ram_gb = int(line.split()[1]) / 1024 / 1024 + break + except OSError: + pass + try: + disk_gb = shutil.disk_usage(REPO_ROOT).free / 2**30 + except OSError: + disk_gb = None + env_exists = os.path.isfile(BUNDLE_ENV) + missing = [k for k in REQUIRED_BUNDLE_KEYS if not bundle_env.get(k, "")] + claude_ok = ( + shutil.which("claude") is not None + or os.path.isdir(os.path.expanduser( + bundle_env.get("ORCH_HOST_CLAUDE_DIR", "") or "~/.claude")) + ) + return { + "docker": docker, + "compose": compose, + "env_exists": env_exists, + "missing_keys": missing if env_exists else [], + "busy_ports": [p for p in bundle_ports(bundle_env) if _port_busy(p)], + "leftovers": leftovers, + "ram_gb": ram_gb, + "disk_gb": disk_gb, + "cpus": os.cpu_count(), + "python3": True, # мы уже исполняемся под python3 + "claude_cli": claude_ok, + } + + +# --------------------------------------------------------------------------- # +# Manual-step контракт (D5/D7): инструкция → подтверждение → верификация +# --------------------------------------------------------------------------- # + +def manual_checkpoint(title: str, instructions: list, verify, max_tries: int = 3): + """Честный чекпоинт: печать точной инструкции; без TTY — немедленный exit 2 + с той же инструкцией; с TTY — ожидание подтверждения и ВЕРИФИКАЦИЯ результата + (молчаливый пропуск запрещён). verify() → (ok, hint).""" + log(f"\n🖐 MANUAL-STEP: {title}") + for line in instructions: + log(f" {line}") + if not sys.stdin.isatty(): + log(" Нет TTY: выполните шаги и перезапустите `apply` (resume = повторный запуск).") + raise ManualStop(title) + for _ in range(max_tries): + input(" Когда выполнено — нажмите Enter: ") + ok, hint = verify() + if ok: + log(" ✓ верификация пройдена") + return + log(f" ✗ верификация не прошла: {hint}") + raise ManualStop(f"{title}: верификация не прошла после {max_tries} попыток") + + +# --------------------------------------------------------------------------- # +# Шаги apply (step-движок check→ensure; каждый идемпотентен) +# --------------------------------------------------------------------------- # + +def step_preflight(ctx: dict) -> str: + facts = collect_facts(ctx["bundle_env"]) + blockers, warnings, resume = preflight_verdict(facts) + for w in warnings: + log(f" ⚠ {w}") + if blockers: + for b in blockers: + log(f" ✗ {b}") + raise ManualStop("preflight: незавершённые предусловия хоста") + ctx["resume"] = resume + if resume: + log(" инсталляция уже существует — продолжаю в ensure-режиме (AC-8)") + return "ok" + + +def step_secrets(ctx: dict) -> str: + """FR-3: bundle-креды (stdlib secrets) + webhook-секреты (gen_secrets.py).""" + force = ctx["args"].force_secrets + bundle_env = ctx["bundle_env"] + fresh = merge_missing_secrets(bundle_env, force=force) + # uid/gid/docker-gid хоста — дозаполняются фактическими значениями оператора + infra: dict = {} + if not bundle_env.get("ORCH_RUN_UID"): + infra["ORCH_RUN_UID"] = str(os.getuid()) + if not bundle_env.get("ORCH_RUN_GID"): + infra["ORCH_RUN_GID"] = str(os.getgid()) + if fresh or infra: + update_env_file(BUNDLE_ENV, BUNDLE_ENV_EXAMPLE, {**infra, **fresh}) + ctx["bundle_env"] = parse_env(open(BUNDLE_ENV, encoding="utf-8").read()) + log(f" bundle-креды выпущены: {', '.join(sorted(fresh)) or '—'}") + else: + log(" bundle-креды уже на месте (не перетираю без --force-secrets)") + # webhook-секреты орка — СТРОГО кирпичом gen_secrets.py (AC-7) + root_env = ctx["root_env"] + if all(root_env.get(k) for k in WEBHOOK_SECRET_KEYS) and not force: + log(" webhook-секреты уже в .env — skip") + return "skipped" + with tempfile.TemporaryDirectory() as tmp: + frag_path = os.path.join(tmp, "fragment.env") + proc = _run([sys.executable, GEN_SECRETS, "--write", frag_path], timeout=60) + if proc.returncode != 0: + raise BootstrapError(f"gen_secrets.py отказал (rc={proc.returncode})") + fragment = parse_env(open(frag_path, encoding="utf-8").read()) + overrides = {k: fragment[k] for k in WEBHOOK_SECRET_KEYS + if fragment.get(k) and (force or not root_env.get(k))} + update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides) + ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read()) + log(f" webhook-секреты выпущены: {', '.join(sorted(overrides)) or '—'}") + return "ok" + + +def _wait_http(url: str, timeout_s: int, label: str, ok_statuses=(200,)) -> None: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + status, _ = _http(url, timeout=5) + if status in ok_statuses: + log(f" ✓ {label} готов ({url})") + return + time.sleep(5) + tail = _compose("logs", "--tail", "30", label, timeout=60).stdout[-2000:] + raise BootstrapError(f"{label} не дождались за {timeout_s}с ({url}); хвост логов:\n{tail}") + + +def _wait_migrator(timeout_s: int) -> None: + deadline = time.monotonic() + timeout_s + name = f"{PROJECT}-migrator-1" + while time.monotonic() < deadline: + proc = _run(["docker", "inspect", "-f", + "{{.State.Status}} {{.State.ExitCode}}", name], timeout=30) + state = proc.stdout.strip() + if proc.returncode == 0 and state.startswith("exited"): + if state.endswith(" 0"): + log(" ✓ миграции Plane завершились (migrator exit 0)") + return + tail = _compose("logs", "--tail", "30", "migrator", timeout=60).stdout[-2000:] + raise BootstrapError(f"миграции Plane упали ({state}); хвост логов:\n{tail}") + time.sleep(5) + raise BootstrapError(f"миграции Plane не завершились за {timeout_s}с (TR-1: проверьте RAM/диск)") + + +def step_up(ctx: dict) -> str: + """Подъём стека + ожидание готовности каждого слоя (D5 шаг 3).""" + for sub in ("data", "repos"): + os.makedirs(os.path.join(BUNDLE_DIR, sub), exist_ok=True) + proc = _compose("up", "-d", timeout=1800) + if proc.returncode != 0: + raise BootstrapError(f"docker compose up отказал:\n{proc.stderr[-2000:]}") + ports = dict(zip(("orch", "plane", "gitea"), bundle_ports(ctx["bundle_env"]))) + _wait_http(f"http://127.0.0.1:{ports['gitea']}/api/healthz", READY_TIMEOUT_S, "gitea") + _wait_migrator(PLANE_READY_TIMEOUT_S) + _wait_http(f"http://127.0.0.1:{ports['plane']}/", PLANE_READY_TIMEOUT_S, + "proxy", ok_statuses=(200, 301, 302)) + _wait_http(f"http://127.0.0.1:{ports['orch']}/health", READY_TIMEOUT_S, "orchestrator") + return "ok" + + +def step_init_gitea(ctx: dict) -> str: + """D6: админ-бот + API-токен официальным CLI в контейнере; идемпотентно. + Branch protection НЕ настраивается (норматив D10 ORCH-009 / INV-4).""" + bundle_env, root_env = ctx["bundle_env"], ctx["root_env"] + user = bundle_env.get("GITEA_ADMIN_USERNAME", "orchestrator-bot") + gitea_port = bundle_ports(bundle_env)[2] + ctx["gitea_owner"] = user + proc = _compose( + "exec", "-T", "-u", "git", "gitea", + "gitea", "admin", "user", "create", "--admin", + "--username", user, "--password", bundle_env.get("GITEA_ADMIN_PASSWORD", ""), + "--email", f"{user}@{PROJECT}.local", "--must-change-password=false", + timeout=120, + ) + blob = proc.stdout + proc.stderr + if proc.returncode == 0: + log(f" создан админ-бот Gitea: {user}") + elif "already exists" in blob: + log(f" админ-бот {user} уже существует — skip") + else: + raise BootstrapError(f"gitea admin user create отказал: {blob[-500:]}") + # API-токен (носитель — root .env, ORCH_GITEA_TOKEN) + token = root_env.get("ORCH_GITEA_TOKEN", "") + if token: + status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user", + headers={"Authorization": f"token {token}"}) + if status == 200: + log(" ORCH_GITEA_TOKEN валиден — skip") + return "skipped" + proc = _compose( + "exec", "-T", "-u", "git", "gitea", + "gitea", "admin", "user", "generate-access-token", + "--username", user, "--token-name", f"orchestrator-{int(time.time())}", + "--scopes", "all", "--raw", + timeout=120, + ) + if proc.returncode != 0: + raise BootstrapError(f"generate-access-token отказал: {proc.stderr[-500:]}") + token = proc.stdout.strip().splitlines()[-1].strip() + status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user", + headers={"Authorization": f"token {token}"}) + if status != 200: + raise BootstrapError(f"свежий токен Gitea не прошёл верификацию (HTTP {status})") + update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, + {"ORCH_GITEA_TOKEN": token, "ORCH_GITEA_OWNER": user}) + ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read()) + log(" выпущен ORCH_GITEA_TOKEN (значение в .env, не печатается)") + return "ok" + + +def _verify_plane_token(plane_port: int, slug: str, token: str) -> tuple: + status, _ = _http( + f"http://127.0.0.1:{plane_port}/api/v1/workspaces/{slug}/projects/", + headers={"X-API-Key": token}, timeout=15, + ) + if status == 200: + return True, "" + return False, f"GET /api/v1/workspaces/{slug}/projects/ → HTTP {status}" + + +def step_init_plane(ctx: dict) -> str: + """D7: instance-setup / workspace / API-токен — честные manual-step + чекпоинты (Plane CE не даёт API первичной инициализации).""" + bundle_env, root_env = ctx["bundle_env"], ctx["root_env"] + host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost") + plane_port = bundle_ports(bundle_env)[1] + slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "") + token = root_env.get("ORCH_PLANE_API_TOKEN", "") + if slug and token and _verify_plane_token(plane_port, slug, token)[0]: + log(" workspace и ORCH_PLANE_API_TOKEN валидны — skip") + return "skipped" + + def _instance_done(): + status, body = _http(f"http://127.0.0.1:{plane_port}/api/instances/", timeout=10) + if status == 200 and '"is_setup_done":true' in body.replace(" ", ""): + return True, "" + if status == 200: + return False, "instance setup ещё не завершён (is_setup_done != true)" + # эндпоинт недоступен в этой сборке CE → деградация: живость UI + ui, _ = _http(f"http://127.0.0.1:{plane_port}/", timeout=10) + return (ui in (200, 301, 302)), f"Plane UI отвечает HTTP {ui}" + + manual_checkpoint( + "Plane: instance setup (первый администратор)", + [f"Откройте http://{host}:{plane_port}/ и зарегистрируйте первого", + "пользователя — он станет администратором инстанса (Plane CE)."], + _instance_done, + ) + if not sys.stdin.isatty(): + raise ManualStop("Plane: workspace/API-токен требуют интерактивного ввода") + slug = input(" Введите slug созданного workspace: ").strip() + log(" Plane UI → Workspace Settings → API tokens → выпустите токен.") + token = getpass.getpass(" Вставьте ORCH_PLANE_API_TOKEN (ввод скрыт): ").strip() + ok, hint = _verify_plane_token(plane_port, slug, token) + if not ok: + raise ManualStop(f"Plane: токен/slug не прошли верификацию ({hint})") + update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, + {"ORCH_PLANE_WORKSPACE_SLUG": slug, "ORCH_PLANE_API_TOKEN": token}) + ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read()) + log(" ✓ workspace и ORCH_PLANE_API_TOKEN верифицированы (значения в .env)") + return "ok" + + +def _psql(sql: str, bundle_env: dict) -> subprocess.CompletedProcess: + """SQL в plane-db через stdin (секреты не попадают в argv, NFR-3).""" + return _compose( + "exec", "-T", "plane-db", "psql", + "-U", bundle_env.get("POSTGRES_USER", "plane"), + "-d", bundle_env.get("POSTGRES_DB", "plane"), + "-t", "-A", "-v", "ON_ERROR_STOP=1", + input_text=sql, timeout=60, + ) + + +def step_plane_webhook(ctx: dict) -> str: + """Workspace-webhook Plane→орк. CE не даёт API → ensure прямой записью в + Postgres инсталляции (прогрессивная автоматизация D7: контракт чекпоинта — + та же верификация SELECT'ом); схема — канон LITE_SETUP §5.4 (путь Б).""" + bundle_env, root_env = ctx["bundle_env"], ctx["root_env"] + secret = root_env.get("ORCH_PLANE_WEBHOOK_SECRET", "") + slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "") + if not (secret and slug): + raise BootstrapError("нет ORCH_PLANE_WEBHOOK_SECRET/ORCH_PLANE_WORKSPACE_SLUG в .env") + + def _exists() -> tuple: + probe = _psql( + f"SELECT count(*) FROM webhooks WHERE url='{WEBHOOK_PLANE_URL}' " + f"AND deleted_at IS NULL;", bundle_env) + ok = probe.returncode == 0 and probe.stdout.strip().isdigit() \ + and int(probe.stdout.strip()) > 0 + return ok, f"SELECT по webhooks: rc={probe.returncode}" + + if _exists()[0]: + log(" workspace-webhook уже зарегистрирован — skip") + return "skipped" + wid = _psql(f"SELECT id FROM workspaces WHERE slug='{slug}';", bundle_env) + workspace_id = wid.stdout.strip().splitlines()[0].strip() if wid.stdout.strip() else "" + if wid.returncode == 0 and workspace_id: + ins = _psql( + "INSERT INTO webhooks (id, created_at, updated_at, deleted_at, " + "workspace_id, url, is_active, secret_key, project, issue, module, " + "cycle, issue_comment, is_internal, version) VALUES " + f"('{uuid.uuid4()}', NOW(), NOW(), NULL, '{workspace_id}', " + f"'{WEBHOOK_PLANE_URL}', true, '{secret}', true, true, false, false, " + "true, false, 'v1');", bundle_env) + if ins.returncode == 0 and _exists()[0]: + log(f" ✓ workspace-webhook зарегистрирован: {WEBHOOK_PLANE_URL}") + return "ok" + log(" прямая регистрация не удалась — честный manual-step (fail-safe)") + manual_checkpoint( + "Plane: workspace-webhook (CE без API)", + [f"Workspace Settings → Webhooks → Add Webhook: URL {WEBHOOK_PLANE_URL},", + "секрет — значение ORCH_PLANE_WEBHOOK_SECRET из корневого .env,", + "события Issue + Issue Comment (канон — LITE_SETUP §5.4)."], + _exists, + ) + return "ok" + + +def _ensure_venv() -> str: + """Host-venv для onboard-кирпича (канон ONBOARDING; ensure, TR-9).""" + if not os.path.exists(VENV_PY): + proc = _run([sys.executable, "-m", "venv", VENV_DIR], timeout=300) + if proc.returncode != 0: + raise BootstrapError(f"python3 -m venv отказал: {proc.stderr[-500:]}") + probe = _run([VENV_PY, "-c", "import httpx, pydantic"], timeout=60) + if probe.returncode != 0: + log(" ставлю зависимости onboard-кирпича в .venv (requirements.txt)…") + proc = _run([VENV_PY, "-m", "pip", "install", "-q", "-r", REQUIREMENTS], + timeout=1200) + if proc.returncode != 0: + raise BootstrapError(f"pip install отказал: {proc.stderr[-500:]}") + return VENV_PY + + +def _onboard_env(ctx: dict) -> dict: + """Окружение onboard-субпроцесса: host-видимые URL bundle-инсталляции + (pydantic env-переменные перекрывают env_file, D7).""" + bundle_env, root_env = ctx["bundle_env"], ctx["root_env"] + host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost") + plane_p, gitea_p = bundle_ports(bundle_env)[1:] + return { + **os.environ, + "ORCH_PLANE_API_URL": f"http://127.0.0.1:{plane_p}", + "ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}", + "ORCH_PLANE_WORKSPACE_SLUG": root_env.get("ORCH_PLANE_WORKSPACE_SLUG", ""), + "ORCH_PLANE_API_TOKEN": root_env.get("ORCH_PLANE_API_TOKEN", ""), + "ORCH_GITEA_URL": f"http://127.0.0.1:{gitea_p}", + "ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}", + "ORCH_GITEA_OWNER": root_env.get("ORCH_GITEA_OWNER", ""), + "ORCH_GITEA_TOKEN": root_env.get("ORCH_GITEA_TOKEN", ""), + "ORCH_GITEA_WEBHOOK_SECRET": root_env.get("ORCH_GITEA_WEBHOOK_SECRET", ""), + } + + +def _onboard_args(ctx: dict, mode: str) -> list: + a = ctx["args"] + return [ + ONBOARD, mode, + "--name", a.sandbox_name, "--repo", a.sandbox_repo, + "--gitea-owner", ctx["root_env"].get("ORCH_GITEA_OWNER", ""), + "--prefix", a.sandbox_prefix, "--stack", a.sandbox_stack, + "--test-cmd", a.sandbox_test_cmd, + "--prod-port", a.sandbox_prod_port, "--staging-port", a.sandbox_staging_port, + "--webhook-url", WEBHOOK_GITEA_URL, + "--env-file", ROOT_ENV, "--json", + ] + + +def step_onboard(ctx: dict) -> str: + """BR-6/AC-7: статусы/лейблы/репо/вебхуки — СТРОГО onboard_project.py.""" + venv_py = _ensure_venv() + env = _onboard_env(ctx) + proc = _run([venv_py, *_onboard_args(ctx, "apply")], env=env, timeout=900) + if proc.returncode not in (0, 2): + raise BootstrapError(f"onboard apply отказал (rc={proc.returncode}): " + f"{proc.stderr[-800:]}") + try: + report = json.loads(proc.stdout) + except ValueError: + raise BootstrapError("onboard apply вернул непарсимый отчёт") + merged = "" + for instr in report.get("instructions", []): + if isinstance(instr, str) and instr.startswith("ORCH_PROJECTS_JSON="): + merged = instr.split("=", 1)[1] + if merged: + update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, {"ORCH_PROJECTS_JSON": merged}) + ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read()) + log(" реестр ORCH_PROJECTS_JSON записан в .env (merged-вывод onboard)") + manual = [s for s in report.get("steps", []) + if s.get("status") == "manual-step" + and s.get("id") not in ("plane.workspace-webhook",)] + if manual: + log(" onboard оставил ручные шаги (см. отчёт): " + + ", ".join(s.get("id", "?") for s in manual)) + verify = _run([venv_py, *_onboard_args(ctx, "verify")], env=env, timeout=300) + if verify.returncode == 1: + raise BootstrapError(f"onboard verify отказал: {verify.stderr[-800:]}") + log(f" onboard verify: exit {verify.returncode} " + f"(0 — чисто; 2 — остались ручные пункты отчёта)") + ctx["onboard_manual"] = bool(manual) or verify.returncode == 2 + return "ok" + + +def step_agent_git(ctx: dict) -> str: + """D8: клон sandbox-репо token-remote ВНУТРИ контейнера орка (origin — + in-network gitea:3000, агенты наследуют его для push/fetch).""" + repo = ctx["args"].sandbox_repo + owner = ctx["root_env"].get("ORCH_GITEA_OWNER", "") + token = ctx["root_env"].get("ORCH_GITEA_TOKEN", "") + probe = _compose("exec", "-T", "orchestrator", "test", "-d", + f"/repos/{repo}/.git", timeout=30) + if probe.returncode == 0: + log(f" /repos/{repo} уже клонирован — skip") + return "skipped" + url = f"{GITEA_INTERNAL_URL.split('://')[0]}://oauth2:{token}@" \ + f"{GITEA_INTERNAL_URL.split('://')[1]}/{owner}/{repo}.git" + proc = _compose("exec", "-T", "orchestrator", + "git", "clone", url, f"/repos/{repo}", timeout=300) + if proc.returncode != 0: + raise BootstrapError( + f"клон {repo} в /repos не удался (лог замаскирован): rc={proc.returncode}") + log(f" ✓ /repos/{repo} клонирован (token-remote, TR-7: права локального каталога)") + return "ok" + + +def step_orch_env(ctx: dict) -> str: + """D5 шаг 8: корневой .env (канон Lite 1:1) + .env.watchdog; пересоздание + орка/watchdog для подхвата конфига.""" + bundle_env = ctx["bundle_env"] + host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost") + plane_p, gitea_p = bundle_ports(bundle_env)[1:] + overrides = { + # in-network машинные URL (D4) + публичные от BUNDLE_PUBLIC_HOST + "ORCH_PLANE_API_URL": PLANE_INTERNAL_URL, + "ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}", + "ORCH_GITEA_URL": GITEA_INTERNAL_URL, + "ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}", + # когерентность дублируемых ключей — механически (TR-8) + "ORCH_AGENT_HOME_DIR": bundle_env.get("ORCH_AGENT_HOME_DIR", "/home/orchestrator"), + "ORCH_RUN_UID": bundle_env.get("ORCH_RUN_UID", "1000"), + "ORCH_RUN_GID": bundle_env.get("ORCH_RUN_GID", "1000"), + "ORCH_DOCKER_GID": bundle_env.get("ORCH_DOCKER_GID", "999"), + "ORCH_HOST_REPOS_DIR": os.path.join(BUNDLE_DIR, "repos"), + "ORCH_HOST_CLAUDE_CODE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_CODE_DIR", ""), + "ORCH_HOST_NODE_BIN": bundle_env.get("ORCH_HOST_NODE_BIN", ""), + "ORCH_HOST_CLAUDE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_DIR", ""), + "ORCH_HOST_CLAUDE_JSON": bundle_env.get("ORCH_HOST_CLAUDE_JSON", ""), + # деплой-машинерия нашего хоста в bundle структурно спит (D4) + "ORCH_DEPLOY_SSH_HOST": "", + } + update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides) + ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read()) + if not os.path.isfile(WATCHDOG_ENV): + # Telegram-ключи опциональны: пусто = деградация только нотификаций + update_env_file(WATCHDOG_ENV, WATCHDOG_ENV_EXAMPLE, {}) + proc = _compose("up", "-d", "--force-recreate", + "orchestrator", "orchestrator-watchdog", timeout=600) + if proc.returncode != 0: + raise BootstrapError(f"пересоздание орка/watchdog отказало:\n{proc.stderr[-1000:]}") + log(" ✓ орк и watchdog пересозданы с собранным конфигом") + return "ok" + + +def step_health(ctx: dict) -> str: + orch_p = bundle_ports(ctx["bundle_env"])[0] + failures = [] + for path in ("/health", "/queue", "/metrics"): + url = f"http://127.0.0.1:{orch_p}{path}" + status, body = None, "" + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + status, body = _http(url, timeout=5) + if status == 200: + break + time.sleep(3) + ok = status == 200 + if path != "/health" and ok: + try: + json.loads(body) + except ValueError: + ok = False + log(f" GET {path} → {'PASS' if ok else f'FAIL (HTTP {status})'}") + if not ok: + failures.append(path) + if failures: + raise BootstrapError(f"health-контракты не зелёные: {', '.join(failures)}") + return "ok" + + +APPLY_STEPS = ( + ("preflight", step_preflight), + ("secrets", step_secrets), + ("up", step_up), + ("init-gitea", step_init_gitea), + ("init-plane", step_init_plane), + ("plane-webhook", step_plane_webhook), + ("onboard", step_onboard), + ("agent-git", step_agent_git), + ("orch-env", step_orch_env), + ("health", step_health), +) + + +# --------------------------------------------------------------------------- # +# Режимы +# --------------------------------------------------------------------------- # + +def _load_ctx(args: argparse.Namespace) -> dict: + bundle_env = parse_env(open(BUNDLE_ENV, encoding="utf-8").read()) \ + if os.path.isfile(BUNDLE_ENV) else {} + root_env = parse_env(open(ROOT_ENV, encoding="utf-8").read()) \ + if os.path.isfile(ROOT_ENV) else {} + return {"args": args, "bundle_env": bundle_env, "root_env": root_env, + "results": {}} + + +def run_plan(ctx: dict) -> int: + log("== bootstrap_bundle: план apply (ноль мутаций) ==") + for i, (name, summary) in enumerate(build_plan(), 1): + log(f" {i}. {name:<14} {summary}") + facts = collect_facts(ctx["bundle_env"]) + blockers, warnings, resume = preflight_verdict(facts) + log("\n-- preflight-диагностика (read-only):") + for w in warnings: + log(f" ⚠ {w}") + for b in blockers: + log(f" ✗ {b}") + if resume: + log(" ℹ найдены тома/контейнеры orchestrator-bundle: apply пойдёт в ensure-режиме") + if not blockers: + log(" ✓ предусловия хоста выполнены — запускайте: " + "python3 scripts/bootstrap_bundle.py apply") + return EXIT_OK + log(f" итог: {len(blockers)} блокеров — устраните и повторите (канон — {DOC})") + return EXIT_MANUAL + + +def run_apply(ctx: dict) -> int: + log("== bootstrap_bundle: apply ==") + for name, fn in APPLY_STEPS: + log(f"\n→ шаг {name}") + status = fn(ctx) + ctx["results"][name] = status + log("\n== итоговая сводка ==") + for name, _ in APPLY_STEPS: + log(f" [{ctx['results'].get(name, '—'):>8}] {name}") + if ctx.get("onboard_manual"): + log("\n🖐 Остались ручные пункты onboard-отчёта (порядок статусов на доске и т.п.)") + log(" Выполните их и перезапустите verify. Exit 2 (незавершённые шаги).") + return EXIT_MANUAL + log(f"\n✓ Bundled-инсталляция готова. Следующий шаг — smoke: {DOC} §11 " + "(чек-лист REPLICATION.md §4).") + return EXIT_OK + + +def run_verify(ctx: dict) -> int: + """Read-only пост-проверка: health-контракты + onboard verify.""" + log("== bootstrap_bundle: verify (read-only) ==") + orch_p = bundle_ports(ctx["bundle_env"])[0] + failed = False + for path in ("/health", "/queue", "/metrics"): + status, _ = _http(f"http://127.0.0.1:{orch_p}{path}", timeout=10) + ok = status == 200 + failed = failed or not ok + log(f" GET {path} → {'PASS' if ok else f'FAIL (HTTP {status})'}") + if os.path.exists(VENV_PY) and ctx["root_env"].get("ORCH_PLANE_API_TOKEN"): + proc = _run([VENV_PY, *_onboard_args(ctx, "verify")], + env=_onboard_env(ctx), timeout=300) + log(f" onboard verify → exit {proc.returncode}") + failed = failed or proc.returncode == 1 + if proc.returncode == 2: + return EXIT_MANUAL + else: + log(" onboard verify пропущен (нет .venv или ORCH_PLANE_API_TOKEN) → exit 2") + return EXIT_MANUAL + return EXIT_ERROR if failed else EXIT_OK + + +def main(argv: list | None = None) -> int: + args = build_arg_parser().parse_args(argv) + ctx = _load_ctx(args) + try: + if args.mode == "plan": + return run_plan(ctx) + if args.mode == "verify": + return run_verify(ctx) + return run_apply(ctx) + except ManualStop as e: + log(f"\n🖐 ОСТАНОВКА (exit {EXIT_MANUAL}): {e}") + log(" Выполните шаг и перезапустите apply — завершённые шаги будут пропущены.") + return EXIT_MANUAL + except BootstrapError as e: + log(f"\n✗ ОШИБКА (exit {EXIT_ERROR}): {e}") + return EXIT_ERROR + except KeyboardInterrupt: + log(f"\n✗ прервано оператором (exit {EXIT_ERROR})") + return EXIT_ERROR + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bootstrap_script.py b/tests/test_bootstrap_script.py new file mode 100644 index 0000000..dd22b97 --- /dev/null +++ b/tests/test_bootstrap_script.py @@ -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 # загрузка по файлу diff --git a/tests/test_bundle_compose.py b/tests/test_bundle_compose.py new file mode 100644 index 0000000..e58cbe5 --- /dev/null +++ b/tests/test_bundle_compose.py @@ -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 diff --git a/tests/test_bundled_setup_doc.py b/tests/test_bundled_setup_doc.py new file mode 100644 index 0000000..176121f --- /dev/null +++ b/tests/test_bundled_setup_doc.py @@ -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(" $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")