feat(replication): ORCH-10b Bundled-тираж — bundle-compose всего стека + bootstrap-скрипт

Закрывает Type B эпика ORCH-10 (по ADR-001 ORCH-103, D1–D11):

- deploy/bundled/docker-compose.yml — самодостаточный compose всего стека
  (орк + watchdog + Gitea 1.22.6 + зеркало upstream Plane CE v0.23.1,
  ~14 контейнеров); project name orchestrator-bundle (узнаваемый префикс),
  container_name не пиннится, staging-контура нет; одна bridge-сеть,
  машинный трафик — сервис-DNS, наружу только человеческие порты;
  GITEA__webhook__ALLOWED_HOST_LIST=orchestrator; все образы пиннованы
  неподвижными тегами. Корневой compose/Dockerfile/src/** — байт-в-байт.
- deploy/bundled/.env.example — конфиг-канон bundle (плейсхолдеры, ни одного
  дефолтного пароля; key-set-sync интерполяций держит тест).
- scripts/bootstrap_bundle.py — python stdlib-only, режимы plan/apply/verify,
  step-движок check→ensure, exit 0/2/1: preflight (fail-fast до мутаций) →
  секреты (gen_secrets.py + stdlib secrets, без перетирания) → up+готовность →
  init Gitea автоматом → init Plane (manual-step с API-верификацией) →
  онбординг строго onboard_project.py apply+verify → token-remote клон →
  сборка .env/.env.watchdog (единственный писатель, права 600) → health.
  Delete-операций нет вообще (D9), секреты не печатаются (NFR-3).
- CHANGELOG.md, CLAUDE.md (абзац Type B), .gitignore (deploy/bundled/repos/).

Док BUNDLED_SETUP.md, REPLICATION §1, arch README, adr-0038 и три структурных
тест-модуля (TC-01…TC-11) — в предыдущих коммитах ветки; полный регресс
1844 passed, ruff по файлам задачи чистый.

Refs: ORCH-103

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 02:01:50 +03:00
committed by orchestrator-deployer
parent 215930fb90
commit f0cd19d748
7 changed files with 1411 additions and 2 deletions

3
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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 (D1D4):** новый 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 (D5D8):** `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`).

View File

@@ -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`

View File

@@ -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=

View File

@@ -0,0 +1,338 @@
# ORCH-103 (Type B Bundled, ADR-001 D1D4): самодостаточный 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:

972
scripts/bootstrap_bundle.py Normal file
View File

@@ -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())