diff --git a/.task-dev.md b/.task-dev.md index ed13550..3fde8b8 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-011 +Work item: ORCH-104 Repo: orchestrator -Branch: feature/ORCH-011- +Branch: feature/ORCH-104-lite Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fb01fe1..a48cbf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Lite-тираж: интерактивный installer `scripts/setup_lite.py`** (ORCH-104, `feat`): у Lite-тиража (ORCH-102 — документ `docs/deployment/LITE_SETUP.md`) появился исполняемый инструмент — один операторский CLI, автоматизирующий маршрут LITE_SETUP §2–§12 для внешнего заказчика: скан предусловий хоста с офером доустановки → discovery docker-инсталляций Plane/Gitea → интерактивный сбор обязательных ключей с немедленной верификацией → автодетект хост-параметров и когерентность портов → сборка `.env`/`.env.watchdog` от канонов → webhook Plane → guard-ы Gitea → подъём ровно `orchestrator`+`orchestrator-watchdog` → регистрация проекта строго кирпичом `onboard_project.py` → итоговый отчёт PASS/FAIL/MANUAL. **Scripts+docs+tests** (паттерн ORCH-009/103): рантайм `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.env.example`/`.env.watchdog.example`, `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт; kill-switch не нужен (активация — только явный запуск CLI человеком на целевом хосте, в нашем контуре артефакт инертен). ADR: `docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md`, сквозной `adr-0040-lite-interactive-installer.md`. + - **Режимы и дефолт (D1/D2):** python stdlib-only, один файл; закрытый набор режимов семейной лексики `plan`/`apply`/`verify` (`choices`), но **дефолт без аргументов — `apply`** (бизнес-цель «одна команда»). Безопасность дефолта — структурно, не режимом: фаза 0 ≡ `plan` (read-only скан + discovery + автодетект), ранний guard чужого `.env`, per-action consent на каждую мутацию, non-TTY без `--yes` → `exit 2` ДО любой мутации. Exit-контракт `EXIT_OK=0`/`EXIT_MANUAL=2`/`EXIT_ERROR=1`; resume = повторный запуск (check→ensure по реальности, без state-файла). + - **Step-движок (D3):** 10 нормативных шагов `scan→prereqs→discovery→collect→render-env→plane-webhook→gitea-guards→up→onboard→report`; каждый шаг ссылается на свой § LITE_SETUP; инвариант `[n for n,_,_ in APPLY_STEPS] == [n for n,_ in build_plan()]` (нет «теневых» шагов). + - **Решающая логика — чистые функции (D4–D11):** вердикты предусловий `prereq_verdicts` (не-Linux/не-x86_64 → WARN «вне контура Lite», ни один пункт не пропускается молча), офер установки per-package consent'ом по закрытому набору менеджеров (`apt-get`/`dnf`/`yum`/`zypper`; неопределимый → MANUAL), classifier discovery строго по image-префиксам (`makeplane/*`, `gitea/gitea*`), `port_overrides` (когерентная тройка `ORCH_DEPLOY_PROD_TARGET_PORT`⇄`WATCHDOG_METRICS_URL`⇄`ORCH_POST_DEPLOY_BASE_URL` одной функцией), `staging_port == prod` → fail-closed (ORCH-058/101), рендер `.env` от канона `.env.example` с маркером managed-файла (`# managed by scripts/setup_lite.py (ORCH-104)` → основа resume и guard'а чужого конфига), `WATCHDOG_TG_*` только в `.env.watchdog` (ловушка файла-носителя §4.3), webhook-секреты строго кирпичом `gen_secrets.py`, C-1 ORCH-100 машинно (токен watchdog-бота == токену орка → отказ), §6.4 branch protection на `main` → FAIL с лечением без удаления (no-delete), webhook Plane CE Path A (UI) / Path Б (SQL под пятью предусловиями), `build_onboard_args` детерминированная сборка аргументов кирпича `onboard_project.py` (собственного канона статусов/лейблов скрипт не несёт). + - **Секрет-гигиена (NFR-3):** значения секретов вводятся скрыто (`getpass`-класс) и НИКОГДА не печатаются (только имена ключей); существующий немаркированный `.env`/`.env.watchdog` не перетирается без `--force`; delete-операций нет вообще; никаких операций с `main`/force-push; рестарт — только собственного свежеподнятого контура. + - **Документация и анти-дрейф (D12):** `LITE_SETUP.md` получил подраздел `### 1.1. Быстрый путь: setup_lite.py` (пиннинг «13 разделов в порядке» цел байт-в-байт) + footer-норматив «меняешь шаги тиража → обнови док **и** `scripts/setup_lite.py` в том же PR»; новый `tests/test_setup_lite_script.py` (ast-скан stdlib-only/нет `src.*`, зеркала `FORBIDDEN_DELETE_NEEDLES`/`FORBIDDEN_STATUS_NEEDLES`, unit чистых функций, секрет-гигиена, import без side-effects); аддитивный тест в `tests/test_lite_setup_doc.py` (TC-27, существующий кортеж `SECTIONS` не правится); витрина `docs/overview/README.md` (маршрут «Развернуть у себя») и `docs/architecture/README.md` (блок Type A) дополнены. - **Витрина системы `docs/overview/`: бизнес + тех, маршруты трёх аудиторий, презентация** (ORCH-011, `docs`): единая точка входа в документацию платформы — новый docs-раздел `docs/overview/` (плоский каталог, 10 файлов, ADR-001 D1): индекс `README.md` (маршруты «Я заказчик / Я менеджер / Я разработчик» + норматив сопровождения «изменил функциональность → обнови витрину в том же PR»), бизнес-часть `business.md` (проблема → решение → что умеет фактически → ценность → 6 сценариев; без жаргона, цифры только с атрибуцией), 7 тех-блоков `tech-*.md` (архитектура со схемой потока, конвейер/гейты, агенты, модель объектов, интеграции, качество/безопасность, наблюдаемость; link-first — за деталями ссылки в golden sources, разрешённый дубль только машинно-сверяемый). **Docs+tests+dev-скрипт** (паттерн ORCH-102/103): `src/**`/`docker-compose.yml`/`Dockerfile`/`requirements*`/`STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схема БД — ноль изменений. ADR: `docs/work-items/ORCH-011/06-adr/ADR-001-system-overview-canon.md`, сквозной `adr-0039-system-overview-docs-canon.md`. - **Презентация (D4/D5):** слайдо-источник `docs/overview/presentation.md` (16 слайдов в машинно-парсимой структуре «## Слайд N: …» + процедура сборки «команда + Проверка:») + dev-скрипт `scripts/build_presentation.py` (python-pptx, тёмный дизайн, редактируемый текст с точной кириллицей; чистый stdlib-парсер `parse_slides` + ленивый импорт pptx). Запуск только вне рантайма; `python-pptx` НЕ в прод-образе (машинный гард); собранный `.pptx` в git не коммитится — `build/` в `.gitignore`. - **Анти-дрейф (D6):** новый структурный `tests/test_system_docs.py` (без сети/LLM/subprocess, паттерн `test_lite_setup_doc.py`) — 10 файлов витрины; маршруты/норматив; derive-сверки с кодом: стадии импортом `src.stages.STAGE_TRANSITIONS` (вкл. `deploy-staging`/`cancelled`, порядок цепочки), exit-гейты и под-гейты именами реестра `QG_CHECKS` в нормативном порядке security → merge → coverage → image-freshness (+ маркер «не стадии»), 6 агентов glob'ом промптов, таблица эффортов class-default'ами config (ORCH-41/81); валидность относительных ссылок + обязательные golden-source ссылки; полнотекстовый FORBIDDEN-скан (импорт из `test_no_host_hardcodes.py`) + секрет-эвристика + запрет вне-репозиторных путей; слайды каноническим парсером; `pptx` отсутствует в `requirements*`/`Dockerfile`; указатели README/CLAUDE/CHANGELOG. diff --git a/CLAUDE.md b/CLAUDE.md index 94b8fd1..bb9b96f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -380,6 +380,53 @@ API → `manual-step` (fail-safe); **runbook** `docs/operations/ONBOARDING.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`. +## Lite-installer: интерактивный `scripts/setup_lite.py` (ORCH-104) +У Lite-тиража (ORCH-102 — документ-канон `docs/deployment/LITE_SETUP.md`) появился **исполняемый +инструмент**: один операторский CLI `scripts/setup_lite.py` (python stdlib-only, мнемоническая пара +`LITE_SETUP.md` ⇄ `setup_lite.py`), автоматизирующий маршрут LITE_SETUP §2–§12 для внешнего +заказчика. **Scripts+docs+tests** (паттерн ORCH-009/103): рантайм `src/**`, корневой +`docker-compose.yml`, `Dockerfile`, `.env.example`/`.env.watchdog.example`, +`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — **байт-в-байт**; kill-switch не +нужен (активация — только явный запуск CLI человеком на целевом хосте, в нашем контуре артефакт +инертен; на mva154 не запускается). +- **Режимы (D1/D2):** закрытый набор семейной лексики `plan`/`apply`/`verify` (`choices`), но + **дефолт без аргументов — `apply`** (бизнес-цель «одна команда» — суть задачи; осознанное + отступление от plan-default семейства ORCH-009/103). Безопасность дефолта — **структурно, не + режимом**: фаза 0 ≡ `plan` (read-only скан предусловий + discovery + автодетект; ноль мутаций), + ранний guard чужого `.env`, per-action consent на каждую мутацию (печать точной команды → согласие + → исполнение; отказ → честный MANUAL с эквивалентной командой), non-TTY без `--yes` → `exit 2` ДО + любой мутации. Exit-контракт `EXIT_OK=0`/`EXIT_MANUAL=2`/`EXIT_ERROR=1`; resume = повторный запуск + (check→ensure по реальности, **без state-файла**). +- **Step-движок (D3):** 10 нормативных шагов `scan→prereqs→discovery→collect→render-env→ + plane-webhook→gitea-guards→up→onboard→report`, каждый ссылается на свой § LITE_SETUP; инвариант + `[n for n,_,_ in APPLY_STEPS] == [n for n,_ in build_plan()]` (нет «теневых» шагов). +- **Решающая логика — чистые функции (D4–D11):** `prereq_verdicts` (не-Linux/не-x86_64 → WARN «вне + контура Lite», ни один пункт не молча), офер установки per-package consent'ом по закрытому набору + менеджеров `apt-get`/`dnf`/`yum`/`zypper` (неопределимый → MANUAL), `discover_installations` + **строго по image-префиксам** (`makeplane/*`, `gitea/gitea*`; имена контейнеров/проектов как признак + НЕ используются — анти-ложноположительность; 0/1/≥2 → ручной ввод/префилл/выбор, «ввести вручную» + всегда), немедленная верификация каждого ключа (Plane `/api/v1/workspaces//projects/`, Gitea + `/api/v1/user`, Telegram `getMe`; лимит 3 попытки → MANUAL), `port_overrides` (когерентная тройка + `ORCH_DEPLOY_PROD_TARGET_PORT`⇄`WATCHDOG_METRICS_URL`⇄`ORCH_POST_DEPLOY_BASE_URL` одной функцией), + `staging_port == prod` → fail-closed (ORCH-058/101), рендер `.env` от канона `.env.example` с + **маркером managed-файла** `# managed by scripts/setup_lite.py (ORCH-104)` (основа resume и guard'а + чужого конфига: немаркированный `.env`/`.env.watchdog` → отказ `exit 2` без `--force`), + `WATCHDOG_TG_*` только в `.env.watchdog` (ловушка файла-носителя §4.3), webhook-секреты **строго + кирпичом** `gen_secrets.py` (свежий выпуск), **C-1 ORCH-100 машинно** (токен watchdog-бота == токену + орка → отказ шага), **§6.4** branch protection на `main` → FAIL с лечением **без удаления** + (no-delete), webhook Plane CE Path A (UI рекомендация) / Path Б (SQL под пятью предусловиями D8), + `build_onboard_args` детерминированная сборка аргументов кирпича `onboard_project.py` (собственного + канона статусов/лейблов скрипт НЕ несёт — 22 статуса остаются за `plane_sync._PLANE_NAME_TO_KEY`). +- **Гигиена (NFR-1/3):** stdlib-only (ast-скан), `src.*` не импортируется, канон-знания — только + субпроцессами кирпичей; значения секретов скрыты (`getpass`) и НИКОГДА не печатаются; delete-операций + нет вообще; никаких операций с `main`/force-push; рестарт — только собственного свежеподнятого + контура (никогда чужие/боевые контейнеры). **Норматив сопровождения (D12):** меняешь шаги тиража → + обнови `LITE_SETUP.md` **и** `scripts/setup_lite.py` в том же PR (footer LITE_SETUP). Анти-дрейф — + `tests/test_setup_lite_script.py` (зеркала `FORBIDDEN_DELETE_NEEDLES`/`FORBIDDEN_STATUS_NEEDLES`, + unit чистых функций, секрет-гигиена) + аддитивный TC-27 в `tests/test_lite_setup_doc.py`. Детали — + `docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md`, сквозной + `docs/architecture/adr/adr-0040-lite-interactive-installer.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1a50dfb..0a56db0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -245,6 +245,28 @@ Type B). Анти-дрейф — `tests/test_bundle_compose.py` / `test_bundled_ [adr-0038](adr/adr-0038-bundled-replication-canon.md), детально — `docs/work-items/ORCH-103/06-adr/ADR-001-bundled-stack-compose-and-bootstrap.md`. +**Интерактивный installer Lite (ORCH-104).** Lite получает инструмент, симметричный +Bundled-bootstrap'у: **`scripts/setup_lite.py`** — python stdlib-only wizard, автоматизирующий +маршрут LITE_SETUP §2–§12 (скан предусловий с офером доустановки per-package consent'ом; +discovery docker-инсталляций Plane/Gitea строго по image-префиксам с выбором пользователя; +интерактивный сбор ключей §4.2 с немедленной верификацией каждого значения и секрет-гигиеной; +автодетект хост-параметров; сборка `.env`/`.env.watchdog` от канонов + `gen_secrets.py`; +webhook Plane CE: UI-путь — рекомендация, SQL-путь — офер под предусловиями с обязательной +пост-верификацией; машинная охрана C-1/§6.4/ORCH-058; подъём ровно орк+watchdog; +onboarding строго кирпичом plan→согласие→apply→verify; итог PASS/FAIL/MANUAL, exit 0/2/1). +Режимы — семейные `plan`/`apply`/`verify`, но **дефолт — `apply`-wizard** (бизнес-цель «одна +команда»); безопасность дефолта — структурно, не режимом: фаза 0 ≡ plan (read-only), ранний +guard чужого `.env` (**маркер managed-файла**: без маркера → отказ exit 2, с маркером → +resume-ensure), per-action consent на каждую мутацию, non-TTY без явного `--yes` → exit 2 +(headless — env-prefill каноническими именами ключей, не answers-file). Step-движок +check→ensure без state-файла (resume = повторный запуск); no-delete; канон не форкается — +LITE_SETUP получает подраздел §1.1 «Быстрый путь» (пиннинг «13 разделов» цел), норматив +сопровождения расширен: «меняешь шаги тиража → обнови док **и скрипт** в том же PR». +Анти-дрейф — `tests/test_setup_lite_script.py` (зеркала delete/status-needle-наборов, unit +чистых функций) + аддитивный тест в `test_lite_setup_doc.py`. Рантайм/конвейер — байт-в-байт; +kill-switch не нужен. Подробнее: [adr-0040](adr/adr-0040-lite-interactive-installer.md), +детально — `docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md`. + ## Витрина системы `docs/overview/` (ORCH-011 — design) Единая точка входа «бизнес + тех» для трёх аудиторий (заказчик / менеджер / разработчик) — diff --git a/docs/architecture/adr/adr-0040-lite-interactive-installer.md b/docs/architecture/adr/adr-0040-lite-interactive-installer.md new file mode 100644 index 0000000..d2218a9 --- /dev/null +++ b/docs/architecture/adr/adr-0040-lite-interactive-installer.md @@ -0,0 +1,121 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# adr-0040: Интерактивный installer Lite-тиража — `scripts/setup_lite.py` (ORCH-104) + +## Статус +Proposed + +## Контекст + +Эпик ORCH-10 закрыт по обоим типам, но асимметрично: Type B — Bundled имеет **инструмент** +(`scripts/bootstrap_bundle.py`, ORCH-103/adr-0038), Type A — Lite — только **документ** +(`docs/deployment/LITE_SETUP.md`, ORCH-102/adr-0037: 13 разделов, ~30+ ручных команд, ~20 +обязательных ключей). Порог входа Lite высок: оператор вручную собирает значения, которые +машина определяет сама; ошибки конфигурации проявляются поздно (на smoke, не в момент ввода). +Бизнес-запрос Владельца: один установочный файл — выполняет установку, запрашивает данные в +момент установки, сканирует систему с офером доустановки, при нескольких инсталляциях +Plane/Gitea даёт выбрать. + +Сквозной характер: серия канонов тиража (adr-0035 onboarding → adr-0036 10-common → +adr-0037 Lite-док → adr-0038 Bundled) дополняется слоем «Lite-инструмент»; вводятся нормативы, +обязательные для будущих операторских CLI платформы и любого агента, меняющего шаги тиража. +Детальный пакет решений (D1…D12, исходы OQ-1…OQ-6 ТЗ) — work-item ADR: +`docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md`. + +## Решение + +1. **Новый операторский CLI `scripts/setup_lite.py`** (имя зеркалит LITE_SETUP.md): python + stdlib-only wizard, автоматизирующий маршрут LITE_SETUP §2–§12 — скан предусловий хоста с + офером доустановки (per-package consent, закрытый набор менеджеров apt-get/dnf/yum/zypper, + re-check после установки, иначе MANUAL), discovery docker-инсталляций Plane/Gitea (строго + image-префиксы `makeplane/*`/`gitea/gitea*`; 0 → ручной ввод + подсказка про Bundled, 1 → + префилл, ≥2 → выбор; «ввести вручную» всегда; never-block), интерактивный сбор ключей §4.2 + с немедленной верификацией каждого значения фактическим вызовом (Plane/Gitea API, Telegram + getMe; лимит 3 попытки → MANUAL), автодетект хост-параметров, сборка `.env`/`.env.watchdog` + от канонов-example (webhook-секреты — строго кирпич `gen_secrets.py`), подъём ровно + орк+watchdog + health, регистрация проекта строго кирпичом `onboard_project.py` + (plan→согласие→apply→verify), итоговый отчёт PASS/FAIL/MANUAL + exit `0/2/1`. +2. **Wizard-контракт consent-gated мутаций (норматив для интерактивных операторских CLI):** + режимы — семейные `plan`/`apply`/`verify`, но **дефолт — `apply`** (бизнес-цель «одна + команда»); отступление от plan-default семейства безопасно по построению и допускается + ТОЛЬКО при четырёх структурных гарантиях: (а) фаза 0 apply ≡ plan (read-only скан + печать + плана + явный вопрос «продолжить?»), (б) ранний guard чужих конфигов до первого вопроса, + (в) per-action consent на каждую мутацию с печатью точной команды (отказ → честный MANUAL), + (г) non-TTY без явного `--yes` → немедленный exit 2 (зависание/мутации исключены). + Инструменты без per-action consent (bootstrap_bundle) обязаны сохранять plan-default. +3. **Маркер managed-файла** — норматив примирения идемпотентного resume с защитой чужих + конфигов: первая строка собранного скриптом `.env*` — фиксированный комментарий + `# managed by scripts/setup_lite.py (ORCH-104)`; существующий файл без маркера → отказ + exit 2 (без `--force`), с маркером → resume-ensure (дозаполнение без перетирания значений). +4. **Headless-канал — env-prefill + явный `--yes`** (не answers-file: секреты на диске вне + `.env` запрещены): значения берутся из переменных окружения процесса с каноническими + именами ключей `.env.example`; верификация обязательна и в headless; отсутствие + обязательного значения → exit 2, не молчаливый дефолт. +5. **Webhook Plane CE (Lite-инсталляция заказчика):** Path A (UI) — рекомендация + (manual-checkpoint с верификацией либо честный MANUAL «проверка — smoke §11»); Path Б + (SQL INSERT в Postgres Plane) — **офер** строго при предусловиях: docker-Plane + + подтверждённый пользователем контейнер БД + согласие с показом SQL + идемпотентный INSERT + + обязательная пост-верификация; только INSERT/SELECT; ввод валидируется (анти-инъекция); + сбой → fail-safe в Path A. (Отличие от Bundled adr-0038, где bootstrap владеет инсталляцией + и пишет безусловно.) +6. **Машинная охрана нормативов тиража:** C-1 ORCH-100 (идентичные токены орка/watchdog → + отказ шага), §6.4/D10 ORCH-009 (непустые `branch_protections` на `main` → FAIL с лечением, + удаление — никогда), guard ORCH-058 (`ORCH_STAGING_PORT == прод-порт` → fail-closed), + когерентная тройка прод-порта — одной чистой функцией. +7. **Канон не форкается; норматив сопровождения расширен:** LITE_SETUP.md получает подраздел + `### 1.1. Быстрый путь` (пиннинг «13 разделов в порядке» цел байт-в-байт; ручной маршрут — + канон и fallback для MANUAL-шагов); footer-норматив NFR-5: «меняешь шаги тиража → обнови + LITE_SETUP.md **и `scripts/setup_lite.py`** в том же PR». Канон-знания в скрипте запрещены + структурно: кирпичи — только субпроцессами; имён Plane-статусов в скрипте нет вообще + (зеркало `FORBIDDEN_STATUS_NEEDLES`); smoke — ссылкой на §11. +8. **Анти-дрейф — постоянная CI-гарантия:** `tests/test_setup_lite_script.py` (ast stdlib-only, + зеркала delete/status-needle-наборов, кирпичи упомянуты, unit чистых функций: вердикты + скана, классификатор discovery, когерентность портов, рендер env с маркером, builder + onboard-аргументов, step-движок/resume, exit-контракт, дефолт `apply`, секрет-гигиена + транскрипта) + аддитивный тест в `tests/test_lite_setup_doc.py` (скрипт упомянут в доке; + существующие ассерты и кортеж `SECTIONS` не правятся). + +### Что НЕ меняется +`src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.env.example`, `.env.watchdog.example`, +кирпичи `gen_secrets.py`/`onboard_project.py`/`bootstrap_bundle.py`, `deploy/**`, +`onboarding/**`; `STAGE_TRANSITIONS`, состав `QG_CHECKS`, семантика `check_*`, machine-verdict +ключи, схема БД — байт-в-байт. Kill-switch не вводится (активация — только явный запуск +оператора; паттерн ORCH-009/102/103). Прод-контейнер в рамках задачи не рестартуется. + +## Альтернативы +- **plan-дефолт (строгий D5 ORCH-009/103)** — отвергнуто для wizard-инструмента: воспроизводит + порог, который задача убирает; безопасность дана гарантиями п.2, а не выбором режима. +- **State-файл прогресса / answers-file / файл-отчёт** — отвергнуты: новые артефакты = новые + поверхности дрейфа и утечки; реальность как источник resume + env-prefill + stdout покрывают + те же кейсы. +- **Безусловная SQL-автоматизация webhook (как Bundled)** — отвергнуто: в Lite БД Plane — чужая + прод-инсталляция (см. п.5). +- **Новый раздел LITE_SETUP / перенумерация** — отвергнуто: ломает пиннинг «13 разделов» и + внешние §-ссылки; подраздел §1.1 даёт то же бесплатно. + +## Последствия +- Порог входа Lite: «прочитай 13 разделов» → «запусти один файл»; ошибки конфигурации ловятся + в момент ввода; число обращений «не взлетело» падает. +- Платформа получает переиспользуемые нормативы: wizard-контракт consent-gated мутаций (п.2), + маркер managed-файла (п.3), headless-канал env-prefill+`--yes` (п.4) — для будущих + операторских CLI. +- Цена: двойной источник маршрута (док+скрипт) — под нормативом same-PR и анти-дрейф тестами; + дефолт `apply` расходится с семейством — зафиксировано явным тест-ассертом с обоснованием. +- Откат: удалить скрипт, тест-модуль, §1.1/footer и аддитивный тест — состояние 1:1 + (scripts+docs+tests, без миграций). + +## Связи +adr-0037 (ORCH-102 — канон LITE_SETUP: автоматизируемый маршрут и пиннинг структуры), +adr-0038 (ORCH-103 — bootstrap-паттерн: step-движок, manual-checkpoint, no-delete, кирпичи +субпроцессами), adr-0036 (ORCH-101 — «дефолт=боевое», gen_secrets, guard ORCH-058), +adr-0035 (ORCH-009 — onboarding-CLI: 22 статуса, D10 branch protection), adr-0033 (ORCH-100 — +C-1 независимые Telegram-каналы). Детально — +`docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md`, +`07-infra-requirements.md`, `10-tech-risks.md`. diff --git a/docs/deployment/LITE_SETUP.md b/docs/deployment/LITE_SETUP.md index 50bc5b0..ba57c89 100644 --- a/docs/deployment/LITE_SETUP.md +++ b/docs/deployment/LITE_SETUP.md @@ -6,6 +6,8 @@ > Хост-специфика в командах — только плейсхолдеры `<...>` и `$ENV_VAR`. > Тираж **stateless**: данные/задачи/секреты исходного (боевого) хоста **НЕ переносятся** > ни на одном шаге — всё создаётся заново (§12). +> **Быстрый путь — `scripts/setup_lite.py`** (§1.1): интерактивный installer проводит по +> §2–§12 за один прогон; ручной маршрут ниже остаётся каноном и fallback'ом. --- @@ -32,6 +34,35 @@ compose-файле, но строго за профилем `staging` и в ба - контейнерные пути (`/app/data`, `/repos`, `/opt/claude-code`) — layout контейнера, не хост-значения; не параметризуются. +### 1.1. Быстрый путь: `setup_lite.py` (рекомендуется) + +Вместо ручного прохода §2–§12 запустите **интерактивный installer** — он сканирует +предусловия хоста и предлагает доустановить недостающее, обнаруживает ваши инсталляции +Plane/Gitea (при нескольких — даёт выбрать), запрашивает обязательные ключи **в момент +установки с немедленной верификацией**, автодетектит хост-параметры, собирает +`.env`/`.env.watchdog` от канонов (свежие webhook-секреты — кирпичом `gen_secrets.py`), +поднимает ровно орк+watchdog и регистрирует ваш проект строго кирпичом `onboard_project.py`. + +```bash +cd <путь-чекаута> # корень репо orchestrator +python3 scripts/setup_lite.py # apply (дефолт): интерактивная установка +python3 scripts/setup_lite.py plan # read-only диагностика (ноль мутаций) +python3 scripts/setup_lite.py verify # read-only пост-проверка контура +``` + +**Контракт:** дефолт-режим `apply` — установка «одной командой»; безопасность дефолта +структурна — фаза 0 любого `apply` ≡ `plan` (read-only скан), каждая мутация хоста — с +**явного согласия** (per-action consent с печатью точной команды), существующий чужой +`.env`/`.env.watchdog` **не перетирается** без `--force` (guard managed-маркера), в +non-TTY без `--yes` — честный выход без зависания. Exit-коды: `0` — все шаги PASS; `2` — +остановка на ручном шаге (повторный запуск продолжит с него — resume); `1` — ошибка. +Секреты вводятся скрыто и **никогда не печатаются**; delete-операций скрипт не выполняет +(лечение — всегда инструкция). Любой ручной шаг ссылается на соответствующий § ниже — +ручной маршрут §2–§13 остаётся полным каноном и fallback'ом для MANUAL-шагов. + +**Проверка:** `python3 scripts/setup_lite.py plan` завершается без блокеров (exit 0) — +PASS; есть блокеры (exit 2) — устраните по выводу и повторите. + --- ## 2. Предусловия хоста @@ -588,9 +619,10 @@ PR-merge API оркестратора, ручной merge не требуетс --- -*Golden source Lite-тиража (ORCH-102, ADR-001). **Норматив сопровождения (NFR-5):** -меняешь шаги тиража (env-ключи, compose-сервисы, маршрут онбординга, smoke) → обнови -этот док В ТОМ ЖЕ PR (правило агентов №2). Полноту и гигиену дока держит структурный +*Golden source Lite-тиража (ORCH-102, ADR-001). **Норматив сопровождения (NFR-5, расширен +ORCH-104):** меняешь шаги тиража (env-ключи, compose-сервисы, маршрут онбординга, smoke) → +обнови этот док **И установочный скрипт `scripts/setup_lite.py`** В ТОМ ЖЕ PR (правило +агентов №2). Полноту и гигиену дока держит структурный анти-дрейф тест `tests/test_lite_setup_doc.py`; кирпичи-каноны: REPLICATION.md (карта env §2, секреты §3, smoke §4), ONBOARDING.md (статусы §1, онбординг), SETUP_WEBHOOKS.md (формат вебхуков), `.env.example` / `.env.watchdog.example` (канон ключей).* diff --git a/docs/overview/README.md b/docs/overview/README.md index 0ceeed0..f033e9d 100644 --- a/docs/overview/README.md +++ b/docs/overview/README.md @@ -36,7 +36,8 @@ 1. [business.md](business.md) — проблема, решение, ценность. 2. [business.md → Сценарии использования](business.md#сценарии-использования) — как это выглядит в работе. 3. [presentation.md](presentation.md) — слайдовая версия рассказа (собирается в PowerPoint). -4. Развернуть у себя: [LITE_SETUP](../deployment/LITE_SETUP.md) (своя инфраструктура) или +4. Развернуть у себя: [LITE_SETUP](../deployment/LITE_SETUP.md) (своя инфраструктура; + быстрый путь — интерактивный installer `scripts/setup_lite.py`, ORCH-104) или [BUNDLED_SETUP](../deployment/BUNDLED_SETUP.md) (весь стек одним комплектом). ### Я менеджер проекта diff --git a/docs/work-items/ORCH-104/00-business-request.md b/docs/work-items/ORCH-104/00-business-request.md new file mode 100644 index 0000000..80fa7f6 --- /dev/null +++ b/docs/work-items/ORCH-104/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Установочный скрипт для lite + +Work Item ID: ORCH-104 + +## Description + +TBD diff --git a/docs/work-items/ORCH-104/01-brd.md b/docs/work-items/ORCH-104/01-brd.md new file mode 100644 index 0000000..5953cc5 --- /dev/null +++ b/docs/work-items/ORCH-104/01-brd.md @@ -0,0 +1,201 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 01 — BRD: ORCH-104 — Установочный скрипт Lite-тиража (интерактивный installer) + +Work Item: **ORCH-104** · Repo: **orchestrator** (self-hosting) · Стадия: analysis +Заказчик: Слава (поставщик платформы); конечный потребитель — внешний оператор Lite-тиража + +--- + +## 1. Бизнес-контекст и проблема + +### 1.1. Текущее состояние +Type A эпика ORCH-10 («Lite»: заказчик разворачивает у себя только `orchestrator` + +`orchestrator-watchdog`, подключая СВОИ Plane/Gitea/Telegram/LLM) закрыт задачей ORCH-102 +**документом**: `docs/deployment/LITE_SETUP.md` — golden source из 13 нормативных разделов, +~30+ ручных fenced-команд и ~20 обязательных ключей нового хоста (§4.2 LITE_SETUP). +Маршрут полный и честный (каждый шаг несёт «Проверка:»/PASS/FAIL), но **порог входа высокий**: + +- оператор вручную собирает и переносит в `.env` значения, которые машина определяет сама + (uid/gid владельца каталога репо, gid группы docker, пути node / дистрибутива claude-code, + занятость портов); +- ошибки конфигурации (символ в секрете, рассинхрон тройки прод-порта, ключи watchdog не в том + файле-носителе, branch protection на `main`) проявляются **поздно** — на smoke или первой + задаче, а не в момент ввода; +- каверзные шаги (webhook Plane CE без внешнего API — §5.4; два независимых Telegram-бота — + §8; статусы строго через onboarding-CLI — §5.3) требуют дисциплины чтения дока. + +### 1.2. Бизнес-запрос (источник — описание задачи Plane) +Нужен **один установочный файл**, который: +1. на автомате выполняет установку Lite; +2. информацию, которую знает только пользователь (токен Plane и т.п.), **запрашивает в момент + установки**; +3. **сканирует систему**, говорит чего не хватает и **предлагает установить**; +4. если на хосте **несколько инсталляций** (например, Plane) — **показывает их и предлагает + выбрать**. + +Цель — максимально упростить установку для пользователей. + +### 1.3. Прецедент в платформе (переиспользовать, не изобретать) +- **Bundled (ORCH-103)** уже имеет установочный скрипт `scripts/bootstrap_bundle.py`: + режимы `plan` (дефолт) / `apply` / `verify`, step-движок check→ensure (повторный запуск = + каскад skip), exit-коды `0/2/1`, python stdlib-only, manual-checkpoint с обязательной + верификацией, **ни одной delete-операции**, секреты никогда не печатаются. **Lite такого + инструмента не имеет** — только док. ORCH-104 закрывает этот разрыв тем же выверенным + паттерном, добавляя три новых способности: интерактивный сбор данных, скан предусловий с + офером установки, discovery нескольких инсталляций с выбором. +- **Кирпичи готовы:** `scripts/gen_secrets.py` (webhook-секреты; x-mode «существующий файл → + отказ exit 2», перезапись только `--force`) и `scripts/onboard_project.py` + (`plan`/`apply`/`verify`: Plane-проект + 22 статуса + лейблы, Gitea-репо + webhook, kit, + merged-`ORCH_PROJECTS_JSON`). Канон статусов/лейблов **не форкается** — только кирпич. + +### 1.4. Установленные факты (проверено по репо, не изобретать) +- Маршрут Lite = LITE_SETUP.md §2–§12; кирпичи-каноны: REPLICATION.md (карта env §2, секреты + §3, smoke §4), ONBOARDING.md (статусы §1), SETUP_WEBHOOKS.md, `.env.example` / + `.env.watchdog.example` (канон 100% ключей). +- Контур Lite: **Linux x86_64, Docker Engine + Compose v2, git, python3, node** (§2 LITE_SETUP); + вне контура — вне гарантии. +- Webhook Plane CE **не экспонирован во внешнем `/api/v1`** — настраивается через UI (если + сборка показывает) или прямым SQL в Postgres Plane (§5.4 LITE_SETUP). +- Branch protection на `main` **НЕ включать** (норматив §6.4, ADR D10 ORCH-009 / INV-4). +- Telegram-канала **два и они независимы** (C-1 ORCH-100); токен орка для watchdog + переиспользовать запрещено; sidecar читает только `.env.watchdog` (ловушка файла-носителя). +- Тираж **stateless**: данные/БД/секреты боевого хоста не переносятся; секреты — только + свежевыпущенные (§12 LITE_SETUP, REPLICATION §3/§5). +- Скрипт живёт в репо → `git clone` чекаута остаётся единственным ручным предшагом + (разрешает «курицу и яйцо» §3: к моменту запуска скрипта чекаут уже есть). +- Структуру LITE_SETUP.md пиннит анти-дрейф тест `tests/test_lite_setup_doc.py` + («13 разделов в порядке», кирпичи, env-ключи ⊂ `.env.example`) — правка дока требует + синхронной правки теста в том же PR. + +--- + +## 2. Объём (scope) + +### 2.1. В объёме +- **Новый операторский CLI** в `scripts/` (один файл; кандидат имени — `setup_lite.py`, + финализирует архитектор), автоматизирующий маршрут LITE_SETUP §2–§12. +- **Скан предусловий** хоста (§2 + §7 LITE_SETUP) с честными вердиктами и **офером установки** + недостающего (с явного согласия; неподдерживаемый дистрибутив → manual-step). +- **Discovery инсталляций Plane/Gitea** на хосте (через локальный Docker): ≥2 → показать и + предложить выбрать; 1 → подставить по умолчанию; 0 → ручной ввод + честная подсказка. +- **Интерактивный сбор** обязательных ключей нового хоста (§4.2) с **немедленной верификацией** + каждого введённого значения фактическим вызовом (Plane API / Gitea API / Telegram getMe). +- **Автодетект хост-параметров** (uid/gid, docker gid, пути node/claude-code, порты) — то, что + машина может узнать сама, у пользователя не спрашивается. +- **Сборка `.env` / `.env.watchdog`** от канонов `.env.example` / `.env.watchdog.example`; + webhook-секреты — кирпичом `gen_secrets.py`; существующие файлы молча не перетираются. +- **Запуск Lite-контура** (ровно орк + watchdog) и health-проверки; stateless-проверка чистоты. +- **Регистрация проекта заказчика** строго кирпичом `onboard_project.py` (plan→apply→verify). +- **Итоговый отчёт** PASS/FAIL/MANUAL + exit-коды `0/2/1`; **идемпотентный повторный запуск** + (resume после manual-step). +- **Обновление `docs/deployment/LITE_SETUP.md`** (скрипт = рекомендованный быстрый путь; ручной + маршрут сохраняется как канон-fallback) + синхронные анти-дрейф тесты. + +### 2.2. Вне объёма (явно, не делать) +- **Установка/администрирование Plane и Gitea** — Lite-рамка: это продукты заказчика + (§1 LITE_SETUP); кейс «нет инфраструктуры вовсе» закрыт Bundled (ORCH-103). Скрипт только + обнаруживает и подключает. +- Любые правки **`src/**`**, корневого `docker-compose.yml`, `Dockerfile`, схемы БД, + `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — рантайм байт-в-байт. +- Teardown / uninstall / миграция данных (stateless-канон; delete-операций в скрипте нет). +- Поддержка не-Linux платформ (контур Lite не расширяется). +- Автоматизация интерактивного OAuth-логина claude CLI (логин — по инструкции Anthropic, + вручную; скрипт только верифицирует результат). +- Самодостаточный «скачиватель» до чекаута (`curl | bash`): канал дистрибуции чекаута + согласуется с поставщиком (§3 LITE_SETUP) и не фиксирован платформой. +- Изменение `bootstrap_bundle.py` (Bundled) и `onboard_project.py` (кирпич переиспользуется + как есть). + +--- + +## 3. Заинтересованные стороны +- **Внешний оператор/заказчик Lite** — главный потребитель: ставит платформу одной командой, + отвечая на вопросы по ходу; не обязан заранее читать 13 разделов. +- **Поставщик платформы (Слава/Owner)** — снижение стоимости каждого внедрения и числа + обращений «не взлетело»; принимает результат. +- **Платформа/конвейер** — не затрагиваются: операторский инструмент вне рантайма; прод и + enduro-trails не подвержены риску (активация — только явный запуск человеком). + +--- + +## 4. Бизнес-требования (BR) + +| ID | Требование | Связь | +|----|------------|-------| +| BR-1 | **Один входной файл:** установка Lite выполняется одной командой из корня чекаута на голом python3; каждый шаг маршрута LITE_SETUP §2–§12 либо выполняется скриптом, либо явно выдаётся как ручной чекпоинт с верификацией результата. Молчаливых пропусков нет. | FR-1/FR-10, AC-1 | +| BR-2 | **Скан системы:** скрипт определяет недостающие предусловия (docker/compose/git/python3/node/claude-code/uid-gid/docker-group/ssh/порты), честно перечисляет их и предлагает установить/исправить; установка — только с явного согласия пользователя; неподдерживаемое окружение → manual-step с готовыми командами. | FR-2, AC-2 | +| BR-3 | **Запрос данных в момент установки:** все обязательные ключи нового хоста (§4.2 LITE_SETUP) запрашиваются интерактивно тогда, когда нужны; каждый токен/URL немедленно верифицируется фактическим вызовом; ввод секретов скрытый, значения секретов нигде не печатаются. | FR-4, AC-4 | +| BR-4 | **Discovery с выбором:** при нескольких инсталляциях Plane/Gitea на хосте скрипт показывает кандидатов и предлагает выбрать; при одной — подставляет её по умолчанию; при нуле — ручной ввод и честная подсказка (Lite не ставит Plane/Gitea; «нет инфраструктуры» = маршрут Bundled). Вариант «ввести вручную» доступен всегда. | FR-3, AC-3 | +| BR-5 | **Безопасность повторного запуска и чужих сред:** повторный запуск идемпотентен (каскад skip, resume после manual-step); существующие `.env`/`.env.watchdog` не перетираются молча; скрипт не содержит delete-операций, не трогает `main`, не рестартит чужие контейнеры; каждая мутация — с явного согласия. | FR-1/FR-6, AC-5/AC-11 | +| BR-6 | **Результат — работающий Lite-контур:** ровно два контейнера (`orchestrator`, `orchestrator-watchdog`), `/health`–`/queue`–`/metrics` зелёные, stateless-чистота подтверждена, проект заказчика зарегистрирован кирпичом onboarding, smoke-инструкция выдана; итог прогона — однозначный вердикт + exit-код `0/2/1`. | FR-8/FR-9/FR-10, AC-9/AC-10 | +| BR-7 | **Канон не форкается:** скрипт автоматизирует маршрут LITE_SETUP.md и использует существующие кирпичи (`gen_secrets.py`, `onboard_project.py`); сам канон-знание (статусы, ключи, нормативы) не дублирует; изменение маршрута → обновление LITE_SETUP.md в том же PR (норматив NFR-5 ORCH-102); соответствие закреплено анти-дрейф тестами. | FR-11, AC-14 | +| BR-8 | **Рантайм не тронут:** `src/**`, корневой compose, `Dockerfile`, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — байт-в-байт; kill-switch не нужен (активация — только явный запуск оператором; паттерн ORCH-009/102/103). | NFR-7, AC-13 | + +--- + +## 5. Нефункциональные требования (NFR) + +| ID | Требование | +|----|------------| +| NFR-1 | **stdlib-only:** скрипт работает на голом python3 целевого хоста ДО любого venv/`docker compose up`; модули платформы (`src/**`) не импортируются; закрепляется ast-сканом в тестах (паттерн `test_bootstrap_script.py`). | +| NFR-2 | **Безопасность среды заказчика:** никаких delete/необратимых операций; мутации — только локальный хост и только с явного согласия; БД Plane заказчика мутируется (webhook, Path Б §5.4) исключительно по явному согласию с обязательной верификацией; branch protection скрипт сам НЕ удаляет (только честный FAIL с лечением). | +| NFR-3 | **Секрет-гигиена:** значения секретов не печатаются и не логируются (только имена ключей/пути файлов); ввод — скрытый; секреты — только свежевыпущенные (stateless, REPLICATION §3); боевые значения исходного хоста не используются даже как подсказки-дефолты. | +| NFR-4 | **Честность шагов:** каждый шаг — явный PASS/FAIL/MANUAL со ссылкой на соответствующий § LITE_SETUP; «молчаливый пропуск запрещён» (паттерн ORCH-103). | +| NFR-5 | **Тестируемость без TTY/сети/docker:** вся решающая логика (вердикты предусловий, классификация discovery, когерентность портов, рендер env, аргументы onboard, step-движок) — чистые функции; интерактив — за инжектируемым I/O; pytest-прогон детерминирован. | +| NFR-6 | **Идемпотентность/resume:** step-движок check→ensure; повторный запуск пропускает выполненное и продолжает с первого незавершённого шага; manual-step → exit 2 → «resume» = просто повторный запуск (паттерн bootstrap_bundle). | +| NFR-7 | **Self-hosting безопасность:** случайный запуск на хосте с живым продом не ломает ничего: ранний отказ по существующему `.env` (без force), read-only режим диагностики, никакого воздействия на уже бегущие контейнеры без согласия. | +| NFR-8 | **Поддержка канона:** LITE_SETUP.md, CHANGELOG.md, витрина `docs/overview/` (если затронуто описание тиража) обновляются в том же PR (правило агентов №2); анти-дрейф тесты дока остаются зелёными. | + +--- + +## 6. Допущения и ограничения +- Контур — как у Lite: Linux x86_64, Docker Engine + Compose v2; скрипт контур не расширяет. +- Plane/Gitea — инсталляции заказчика, сетево доступны с хоста оркестратора; их установка — + вне задачи. +- Discovery гарантируется для **docker-инсталляций** (контейнеры с опознаваемыми + образами/метками compose); native/k8s-инсталляции честно уходят в ручной ввод URL — + это не FAIL discovery. +- Запуск — из корня чекаута репо `orchestrator`; clone чекаута — ручной предшаг (канал + дистрибуции — договорённость с поставщиком, §3 LITE_SETUP). +- Язык UX скрипта — русский (паттерн `gen_secrets.py`/`bootstrap_bundle.py`); сообщения + ссылаются на разделы LITE_SETUP.md как на полный канон. +- Интерактивный OAuth-логин claude CLI принципиально не автоматизируем скриптом — + верифицируется результат (читаемость кредов uid'ом контейнера). + +--- + +## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md) +- AC-1 одна команда, режимы и идемпотентный повтор; +- AC-2 скан предусловий с честными вердиктами и офером установки; +- AC-3 discovery 0/1/много с выбором и ручным fallback; +- AC-4 интерактивный сбор с немедленной верификацией и секрет-гигиеной; +- AC-5/AC-6 корректная сборка `.env` (обязательные ключи, свежие секреты, когерентные порты, + отказ перетирания); +- AC-7/AC-8 нормативы C-1 (два бота) и §6.4 (branch protection) машинно охраняются; +- AC-9 Lite-контур поднят и здоров; AC-10 проект зарегистрирован кирпичом; +- AC-11/AC-12 exit-коды, resume, stdlib-only/no-delete гигиена; +- AC-13 рантайм байт-в-байт; AC-14 документация обновлена синхронно. + +--- + +## 8. Риски (кратко; детали — 10-tech-risks.md, заполняет архитектор) +- **R-1 Запуск на боевом хосте по ошибке** → ранний guard существующего `.env`/живого + инстанса; read-only диагностика дефолтно безопасна. +- **R-2 Офер установки пакетов** — вмешательство в систему заказчика → только явное согласие, + печать точной команды; неопределимый пакетный менеджер → manual-step. +- **R-3 SQL-вставка webhook в Postgres Plane** (Path Б §5.4) инвазивна → только согласие + + подтверждённый пользователем контейнер БД + верификация; UI-путь предпочтителен. +- **R-4 Дрейф скрипта от канона LITE_SETUP** (двойной источник истины) → норматив same-PR + + анти-дрейф тесты (ключи ⊂ `.env.example`, кирпичи, упоминание скрипта в доке). +- **R-5 Интерактивность против автоматизации** (CI/non-TTY: риск зависания) → инжектируемый + I/O, честный отказ/неинтерактивная альтернатива в non-TTY. +- **R-6 Ложноположительный discovery** (чужой контейнер опознан как Plane) → выбор всегда за + пользователем, ручной ввод всегда доступен, токен-верификация всё равно обязательна. diff --git a/docs/work-items/ORCH-104/02-trz.md b/docs/work-items/ORCH-104/02-trz.md new file mode 100644 index 0000000..6a7d1f0 --- /dev/null +++ b/docs/work-items/ORCH-104/02-trz.md @@ -0,0 +1,271 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-104 — Установочный скрипт Lite-тиража (интерактивный installer) + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **что** должно измениться и **где** (модули/контракты/артефакты). **Как** +> (имя/режимы скрипта, эвристики discovery, степень автоматизации отдельных шагов) — решает +> архитектор в `06-adr/`. Тип изменения — **scripts + docs + tests** (паттерн ORCH-009/103): +> рантайм `src/**` байт-в-байт. + +--- + +## 1. Сводка изменения + +Ввести **единый операторский установочный скрипт Lite-тиража**: один файл в `scripts/`, +который интерактивно проводит внешнего оператора по маршруту `docs/deployment/LITE_SETUP.md` +§2–§12 — сканирует предусловия хоста и предлагает доустановить недостающее, обнаруживает +инсталляции Plane/Gitea (при нескольких — даёт выбрать), запрашивает обязательные ключи в +момент установки с немедленной верификацией, автодетектит хост-параметры, собирает +`.env`/`.env.watchdog` от канонов, поднимает Lite-контур, регистрирует проект заказчика +кирпичом `onboard_project.py` и выдаёт итоговый вердикт PASS/MANUAL/FAIL с exit-кодами +`0/2/1`. Паттерн — `bootstrap_bundle.py` (ORCH-103): step-движок check→ensure, stdlib-only, +no-delete, manual-checkpoint с верификацией, идемпотентный повтор. Канон LITE_SETUP.md не +форкается — скрипт становится в нём рекомендованным быстрым путём, ручной маршрут сохраняется. + +--- + +## 2. Задействованные модули / пути + +| Путь | Действие | Роль в задаче | +|------|----------|---------------| +| `scripts/setup_lite.py` *(имя-кандидат; финал — архитектор)* | **создать** | единый установочный CLI: step-движок, скан, discovery, интерактивный сбор, сборка конфигов, запуск, отчёт | +| `scripts/gen_secrets.py` | переиспользовать, **не менять** | кирпич выпуска webhook-секретов (субпроцесс — паттерн AC-7 ORCH-103: канон-знания только субпроцессами кирпичей) | +| `scripts/onboard_project.py` | переиспользовать, **не менять** | кирпич регистрации проекта: Plane-проект + 22 статуса + лейблы, Gitea-репо + webhook, merged-`ORCH_PROJECTS_JSON` | +| `docs/deployment/LITE_SETUP.md` | **обновить** | скрипт = рекомендованный быстрый путь; ручной маршрут остаётся каноном-fallback; размещение не ломает пиннинг «13 разделов в порядке» ЛИБО тест обновляется синхронно | +| `tests/test_setup_lite_script.py` *(имя-кандидат)* | **создать** | анти-дрейф + unit чистых функций (по образцу `tests/test_bootstrap_script.py`) | +| `tests/test_lite_setup_doc.py` | обновить **при необходимости** | если меняется пиннингуемая структура LITE_SETUP.md (разделы/кирпичи) | +| `CHANGELOG.md` | обновить | запись `feat:` | +| `docs/overview/` | обновить **при необходимости** | если витрина описывает маршрут Lite-тиража (таблица соответствия — в индексе витрины, правило агентов №2/ORCH-011) | +| `src/**`, `docker-compose.yml`, `Dockerfile`, `.env.example`, `.env.watchdog.example` | **НЕ менять** | каноны-источники: скрипт их только читает (шаблоны env, состав сервисов) | + +--- + +## 3. Функциональные требования + +### FR-1 — Единая точка входа, режимы, идемпотентность (BR-1, BR-5) +- Один файл; запуск `python3 scripts/setup_lite.py …` из корня чекаута репо `orchestrator` + на голом python3 (до venv и до `docker compose up`). +- Обязательны как минимум: **read-only режим диагностики** (ноль мутаций: скан предусловий + + discovery + план шагов — аналог `plan` ORCH-103) и **установочный интерактивный режим** + (аналог `apply`); желателен `verify` (read-only пост-проверка). Дефолтный режим и имена — + решение архитектора (OQ-1) с учётом паттерна D5 ORCH-009/103 («plan — дефолт») и + бизнес-цели «одна команда». +- Step-движок **check→ensure**: каждый шаг сперва проверяет «уже сделано?» и при PASS + пропускается → повторный запуск идемпотентен; «resume» после manual-step = просто повторный + запуск (паттерн `bootstrap_bundle.run_apply`). +- **Exit-коды (контракт):** `0` — все шаги PASS; `2` — остановка на manual-step / + незавершённое предусловие; `1` — ошибка. +- Каждая мутация хоста — с **явного согласия** пользователя (per-action consent); отказ от + согласия → честный MANUAL-шаг с печатью эквивалентной команды, не молчаливый пропуск. + +### FR-2 — Скан предусловий с офером установки (BR-2; LITE_SETUP §2, §7) +- Проверяемый перечень (каждый пункт — отдельный вердикт `OK | MISSING | WARN | MANUAL`): + 1. ОС/арх: `uname -sm` = Linux x86_64 (иное → WARN «вне контура Lite»); + 2. `docker --version`, `docker compose version` (v2), `git --version`, `python3 --version`, + `node --version`; + 3. дистрибутив claude-code (`npm root -g` → каталог `@anthropic-ai/claude-code`) и + аутентификация CLI (читаемость `~/.claude/.credentials.json` uid'ом из п.5); + 4. группа docker (`getent group docker` → gid для `ORCH_DOCKER_GID`); + 5. uid/gid пользователя-владельца и каталога репозиториев (`ORCH_HOST_REPOS_DIR`, + инвариант ORCH-040: владелец = `ORCH_RUN_UID:ORCH_RUN_GID`); + 6. каталог ssh-ключей (`ORCH_HOST_SSH_DIR`): существование/ключи; + 7. свободность портов (прод/staging, дефолты 8500/8501). +- Для каждого `MISSING`: определить пакетный менеджер хоста (например apt/dnf/yum/zypper — + точный набор фиксирует архитектор) → предложить **конкретную команду установки** → выполнить + её ТОЛЬКО с явного согласия; для node+claude-code — офер `npm install -g + @anthropic-ai/claude-code`; для ssh-ключей — офер `ssh-keygen` (+ печать pubkey как + manual-step «добавить в Gitea», §2.4/§6.2). +- Неопределимый дистрибутив/менеджер, отказ пользователя, sudo-недоступность → честный + **MANUAL** с готовыми командами и ссылкой на § LITE_SETUP; молчаливый пропуск запрещён. +- Интерактивный OAuth-логин claude CLI скрипт **не выполняет** — только верифицирует + результат (§7.2) и выдаёт manual-step. + +### FR-3 — Discovery инсталляций Plane/Gitea (BR-4) +- Источник — **локальный Docker**: перечисление контейнеров, группировка в «инсталляции» по + метке compose-проекта (`com.docker.compose.project`), опознание по именам образов + (Plane: `makeplane/*`-семейство; Gitea: `gitea/*`) и published-портам; точные + эвристики/паттерны — ADR. Из кандидата выводятся **предлагаемые** URL + (`ORCH_PLANE_API_URL`/`ORCH_PLANE_WEB_URL`; `ORCH_GITEA_URL`/`ORCH_GITEA_PUBLIC_URL`). +- Поведение по числу найденных: **0** → ручной ввод URL + честная подсказка «Lite не + устанавливает Plane/Gitea; нет инфраструктуры — см. Bundled (BUNDLED_SETUP.md)»; **1** → + префилл по умолчанию (с подтверждением); **≥2** → нумерованный список (проект, образы, + порты) + выбор. Пункт **«ввести вручную»** доступен всегда. +- **Best-effort, never-block:** docker недоступен / ошибка перечисления / не-docker + инсталляция (native/k8s) → ручной ввод, не FAIL. +- Discovery заполняет только кандидаты URL; **токены всегда вводит пользователь** (FR-4); + выбранный кандидат всё равно проходит верификацию FR-4. + +### FR-4 — Интерактивный сбор обязательных ключей с немедленной верификацией (BR-3) +- Покрывается карта обязательных ключей нового хоста — **§4.2 LITE_SETUP** (группы: Plane, + Gitea, webhook-секреты, Telegram, `ORCH_PROJECTS_JSON`, хост-параметры, порты). +- Секретные значения вводятся **скрыто** (`getpass`-класс ввода) и **никогда не печатаются** + (ни в stdout, ни в лог; только имена ключей) — паттерн NFR-3 ORCH-103. +- Немедленная верификация каждого введённого значения фактическим вызовом: + - Plane: `GET $ORCH_PLANE_API_URL/api/v1/workspaces//projects/` c `X-API-Key` → 200; + - Gitea: `GET $ORCH_GITEA_URL/api/v1/user` c токеном → 200 (+ владелец → `ORCH_GITEA_OWNER`); + - Telegram: `getMe` → `"ok":true`; helper определения chat-id через `getUpdates`; + - публичный URL оркестратора (для webhook'ов) — синтаксическая валидация + предупреждение, + что достижимость со стороны Plane/Gitea проверится на smoke. +- Неуспех верификации → re-prompt с диагнозом (ограниченное число попыток — паттерн + `manual_checkpoint(max_tries=3)`), затем честный MANUAL/остановка exit 2 — не бесконечный + цикл и не молчаливое принятие. +- **non-TTY** (нет интерактива): честный отказ с подсказкой ЛИБО неинтерактивная альтернатива + (флаги/answers-file — механизм решает архитектор, OQ-2); зависание недопустимо. + +### FR-5 — Автодетект хост-параметров и когерентность портов (BR-2, BR-6) +- Автоматически определяются и НЕ спрашиваются у пользователя (только подтверждение): + `ORCH_RUN_UID`/`ORCH_RUN_GID` (uid/gid владельца `ORCH_HOST_REPOS_DIR` / текущего + пользователя), `ORCH_DOCKER_GID` (`getent group docker`), `ORCH_HOST_NODE_BIN` + (`which node`), `ORCH_HOST_CLAUDE_CODE_DIR` (`npm root -g`), `ORCH_AGENT_HOME_DIR` / + `ORCH_HOST_CLAUDE_DIR` / `ORCH_HOST_CLAUDE_JSON` (HOME пользователя из §2.2), + `ORCH_DEPLOY_HOST_REPO_PATH` (корень чекаута). +- Порты: busy-check (`ss`/socket) прод- и staging-портов; занят → предложить альтернативу. + Смена прод-порта → **синхронно** согласовать тройку `ORCH_DEPLOY_PROD_TARGET_PORT` ⇄ + `WATCHDOG_METRICS_URL` ⇄ `ORCH_POST_DEPLOY_BASE_URL` (§2.5/§4.2). `ORCH_STAGING_PORT` == + прод-порт → **отказ fail-closed** (инвариант ORCH-058, усилен ORCH-101). + +### FR-6 — Сборка `.env` / `.env.watchdog` (BR-5; LITE_SETUP §4) +- `.env` собирается **от канона `.env.example`** (принцип ORCH-101: дефолт = боевое значение; + записываются только собранные отличия нового хоста). `.env.watchdog` — от + `.env.watchdog.example` (ключи `WATCHDOG_TG_BOT_TOKEN`/`WATCHDOG_TG_CHAT_ID` кладутся + **только туда** — ловушка файла-носителя §4.3). +- Webhook-секреты — **кирпичом `gen_secrets.py`** (свежий выпуск; боевые секреты не + используются — stateless §12 / REPLICATION §3). +- **Существующий `.env`/`.env.watchdog` → отказ** (exit 2, внятное сообщение); перезапись — + только явный force-флаг (паттерн `gen_secrets --force` / `--force-secrets` ORCH-103). + Подсказки-дефолты промптов — из `.env.example`/автодетекта, **никогда** из боевых значений. +- После сборки — резолв-проверка `docker compose config` (PASS/FAIL, §4 «Проверка»). + +### FR-7 — Подключения и машинная охрана нормативов (BR-3, BR-6) +- **Plane (§5):** наличие workspace/доступность API верифицируются (FR-4); статусы/лейблы + вручную НЕ создаются (только кирпич onboarding, §5.3). **Webhook (§5.4, каверза Plane CE):** + Path A (UI) = manual-checkpoint с инструкцией и верификацией; Path Б (SQL в Postgres Plane) + = только с **явного согласия** + подтверждённый пользователем контейнер БД Plane + + пост-верификация (`SELECT url, is_active FROM webhooks`); выбор степени автоматизации — ADR + (OQ-3). Молчаливый пропуск запрещён. +- **Gitea (§6):** токен верифицирован (`/api/v1/user`); **branch protection на `main`:** + непустой `branch_protections` → **FAIL шага с лечением** (норматив §6.4 / ADR D10 ORCH-009 / + INV-4); скрипт правила **сам не удаляет** (no-delete) — только инструкция. Per-repo webhook + и репо создаёт кирпич onboarding (FR-9), не сам скрипт. +- **Telegram (§8):** два независимых бота; **идентичные токены орка и watchdog → отказ шага** + (C-1 ORCH-100 «ЗАПРЕЩЕНО» — машинная проверка, не примечание). +- **LLM (§7):** верификация дистрибутива/нод-бинаря/читаемости кредов uid'ом контейнера; + наличие `ORCH_AGENT_MODEL_DEFAULT`/`ORCH_AGENT_EFFORT_DEFAULT` в собранном `.env` (§7.3). + +### FR-8 — Запуск Lite-контура и health (BR-6; LITE_SETUP §9, §12) +- С согласия: `docker compose up -d --build`; проверка состава: запущены **ровно** + `orchestrator` + `orchestrator-watchdog`, `orchestrator-staging` НЕ поднят (строго за + `profiles: [staging]`). +- Health-чек контрактов: `/health` → 200 `"status":"ok"`; `/queue` → штатный JSON; + `/metrics` → `"schema_version": 1` (порт — фактический из конфига). +- Stateless-проверка чистоты (§12): счётчики jobs нулевые, ни одной задачи чужих проектов; + нарушение → FAIL с лечением «пересобрать `data/` с нуля». + +### FR-9 — Регистрация проекта заказчика кирпичом onboarding (BR-6, BR-7; LITE_SETUP §10) +- Скрипт собирает параметры проекта (имя/описание/repo/prefix/stack/test-cmd/порты/ + webhook-url из публичного URL оркестратора) и строит аргументы вызова + `onboard_project.py` **детерминированной чистой функцией** (тестируемо без сети). +- Последовательность: `plan` → показать пользователю → согласие → `apply` → `verify` + (субпроцессы; exit 2 кирпича = остались ручные шаги → транслировать как MANUAL). + Степень автоматизации vs «печать готовой команды» — ADR (OQ-6; прецедент драйва — + `bootstrap_bundle.step_onboard`). +- `ORCH_PROJECTS_JSON` из отчёта `apply` вписывается в `.env` (с согласия); затем управляемый + рестарт **только собственного свежеподнятого** контура по процедуре §10: проверка тихого + окна (`/queue` без running-job) → `docker compose up -d --force-recreate orchestrator` → + пост-проверка `/health`+`/queue`. + +### FR-10 — Итоговый отчёт (BR-1, BR-6) +- Финальная сводная таблица всех шагов: PASS/FAIL/MANUAL; для каждого MANUAL — что сделать и + ссылка на § LITE_SETUP; для FAIL — диагноз и лечение (зеркало §13 траблшутинга). +- Smoke первой задачи (§11: issue → «To Analyse» → артефакты `01–04`) выдаётся как + завершающая manual-инструкция (вердикт «тираж PASS» — за оператором). + +### FR-11 — Документация: канон не форкается (BR-7) +- `docs/deployment/LITE_SETUP.md`: скрипт вводится как **рекомендованный быстрый путь** + (врезка/подраздел в начале маршрута); ручной маршрут §2–§13 сохраняется как канон и + fallback для MANUAL-шагов. Размещение либо не ломает пиннинг + `test_doc_exists_with_all_13_sections_in_order`, либо тест обновляется в том же PR. +- Норматив сопровождения (NFR-5 ORCH-102) расширяется симметрично: «меняешь шаги тиража → + обнови LITE_SETUP.md **и установочный скрипт** в том же PR». + +--- + +## 4. Изменения API +**Нет.** Эндпоинты оркестратора не добавляются и не меняются; скрипт — потребитель +существующих read-only контрактов (`/health`, `/queue`, `/metrics`) и внешних API +(Plane/Gitea/Telegram). + +## 5. Изменения схемы БД +**Нет.** БД оркестратора не затрагивается (создаётся пустой при первом старте — штатно); +БД Plane заказчика — только опциональная webhook-вставка Path Б §5.4 (FR-7, с согласия). + +## 6. Требования к новым/изменённым QG checks +**Нет.** Скрипт — операторский инструмент вне рантайма и вне конвейера; +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи — байт-в-байт. + +## 7. Совместимость / регресс +- **Kill-switch не нужен:** активация — только явный запуск CLI человеком (паттерн + ORCH-009/102/103); рантайм не несёт ни одной новой кодовой ветки. +- Полный существующий регресс `pytest tests/ -q` остаётся зелёным; `test_lite_setup_doc.py` — + зелёный (с синхронным обновлением при правке структуры дока). +- Обратимость: задача добавляет файлы `scripts/` + `tests/` + правку дока; откат = revert PR, + поведение платформы не меняется в обе стороны. +- Скрипт не вносит хост-литералов в `src/**` (вне скана `test_no_host_hardcodes.py` по + определению, т.к. `src/**` не трогается). + +## 8. Конфигурация +Новых ключей `Settings`/`.env.example` **не вводится**. Скрипт читает каноны +`.env.example`/`.env.watchdog.example` как шаблоны и пишет целевые `.env`/`.env.watchdog` +(FR-6). Платформенные константы (`SELF_HOSTING_REPO="orchestrator"`, имена сервисов, layout +контейнера) не параметризуются (норматив REPLICATION §1). + +## 9. Наблюдаемость +- Структурный stdout-лог прогона (шаг → вердикт → диагноз) + итоговая таблица (FR-10). +- Exit-код = машинный итог прогона (`0/2/1`) — пригоден для обвязки/CI заказчика. +- Опциональный файл-отчёт прогона — на усмотрение архитектора (ADR). + +## 10. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR) +- `docs/work-items/ORCH-104/06-adr/ADR-001-.md` — решения: имя/режимы скрипта, + эвристики discovery, политика оферов установки, автоматизация webhook Path Б и onboarding, + механизм non-TTY (архитектор). +- `docs/deployment/LITE_SETUP.md` — быстрый путь (FR-11). +- `tests/test_setup_lite_script.py` (+ правка `tests/test_lite_setup_doc.py` при + необходимости). +- `CHANGELOG.md` — запись `feat:`; витрина `docs/overview/` — если затронуто описание тиража. + +## 11. Инварианты (не нарушать) +- `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `STAGE_TRANSITIONS`, `QG_CHECKS`, + `check_*`, machine-verdict ключи, схема БД — **байт-в-байт**. +- **stdlib-only**; модули платформы не импортируются; канон-знания — только субпроцессами + кирпичей (`gen_secrets.py`, `onboard_project.py`). +- **No-delete:** ни одной удаляющей операции (файлы, контейнеры, ветки, правила) — лечение + всегда инструкцией. +- **Никогда** не push/force-push/иные операции в `main` (INV-4); не рестартить чужие/боевые + контейнеры; мутации — только с явного согласия. +- Секреты: свежие, скрытый ввод, не печатаются; существующие `.env*` молча не перетираются. +- Stateless: данные/задачи/секреты исходного хоста не переносятся ни одним шагом. + +## 12. Открытые вопросы для архитектора (не блокируют анализ) +- **OQ-1:** имя скрипта и набор/дефолт режимов: строгий паттерн D5 (`plan` — дефолт, + мутации только в `apply`) vs «запуск без аргументов = wizard» (бизнес-цель «одна команда»). + Возможный компромисс: wizard-дефолт, где фаза скана всегда read-only, а мутации — за + per-action consent. +- **OQ-2:** механизм неинтерактивного прогона (флаги / answers-file / env-переменные) и + точное поведение в non-TTY. +- **OQ-3:** webhook Plane: автоматизировать ли Path Б (SQL) или строго manual-checkpoint; + критерий выбора/подтверждения контейнера Postgres Plane. +- **OQ-4:** политика оферов установки: исполнять команды пакетного менеджера из скрипта + (с согласия) vs только печатать (безопаснее); набор поддерживаемых менеджеров. +- **OQ-5:** размещение быстрого пути в LITE_SETUP.md (врезка в §1 vs новый раздел с правкой + пиннинга «13 разделов») и судьба нумерации. +- **OQ-6:** степень драйва onboarding: субпроцесс plan→apply→verify изнутри скрипта + (прецедент `bootstrap_bundle.step_onboard`) vs печать готовой команды. diff --git a/docs/work-items/ORCH-104/03-acceptance-criteria.md b/docs/work-items/ORCH-104/03-acceptance-criteria.md new file mode 100644 index 0000000..915216d --- /dev/null +++ b/docs/work-items/ORCH-104/03-acceptance-criteria.md @@ -0,0 +1,206 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-104 — Установочный скрипт Lite-тиража + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий — чёткое условие **PASS/FAIL**. Поведенческие критерии проверяются +детерминированными pytest-тестами (чистые функции / инжектируемый I/O / tmp-файлы — без +реальных TTY/сети/docker); структурные — по файлам репозитория. Имя скрипта ниже — +`scripts/setup_lite.py` (кандидат; если архитектор финализирует иное имя, критерии читаются +с фактическим именем 1:1). + +--- + +## AC-1 — Единая точка входа, режимы, идемпотентный повтор + +**Условие:** скрипт существует и реализует контракт FR-1. +- **PASS:** один файл в `scripts/`; запускается на голом python3 из корня чекаута; существует + read-only режим диагностики (ноль мутаций ФС/docker/сети-мутаций); step-движок check→ensure: + на «уже выполненном» хосте шаги дают каскад skip (повторный запуск не перевыполняет + мутации); manual-step останавливает прогон с exit 2, повторный запуск продолжает с первого + незавершённого шага. +- **FAIL:** установка по-прежнему требует ручного прохода LITE_SETUP без скрипта; повторный + запуск перевыполняет мутации или ломает состояние; read-only режима нет. + +--- + +## AC-2 — Скан предусловий: честные вердикты + офер установки + +**Условие:** функция вердиктов предусловий (FR-2) на фикстурах фактов хоста. +- **PASS:** полный набор фактов (всё установлено) → все пункты `OK`, блокеров нет; факт + «docker отсутствует» → вердикт `MISSING` с конкретной командой установки и запросом + согласия (инжектированный отказ → шаг `MANUAL` с печатью команды, мутация не выполняется); + неопределимый пакетный менеджер → `MANUAL` с готовыми командами и ссылкой на § LITE_SETUP; + не-Linux/не-x86_64 → `WARN` «вне контура Lite». Ни один пункт перечня FR-2 не пропускается + молча. +- **FAIL:** отсутствующее предусловие даёт ложный `OK`/молчаливый пропуск; установка + выполняется без явного согласия; неподдерживаемое окружение роняет скрипт вместо MANUAL. + +--- + +## AC-3 — Discovery Plane/Gitea: 0 / 1 / много, ручной ввод всегда + +**Условие:** классификатор discovery (FR-3) на фикстурах перечня docker-контейнеров. +- **PASS:** фикстура с двумя независимыми Plane-инсталляциями (разные compose-проекты) → + ровно 2 кандидата, пользователю показан нумерованный список и выбор применён; одна + инсталляция → её URL предложен по умолчанию (с подтверждением); ноль → ручной ввод URL + + подсказка про Bundled; ошибка/недоступность docker → ручной ввод без падения (never-block); + пункт «ввести вручную» присутствует при любом числе кандидатов; посторонние образы в + кандидаты не попадают. +- **FAIL:** при ≥2 кандидатах выбор сделан автоматически без пользователя; при 0 кандидатах + скрипт падает/блокируется; чужой контейнер опознан как кандидат без возможности отказаться. + +--- + +## AC-4 — Интерактивный сбор: немедленная верификация + секрет-гигиена + +**Условие:** цикл запроса значения (FR-4) с инжектированным I/O и замоканной верификацией. +- **PASS:** неуспешная верификация (например, Plane-токен → 401) даёт re-prompt с диагнозом; + после исчерпания лимита попыток — честный MANUAL/остановка exit 2 (не бесконечный цикл); + успешная верификация → значение принято; секретные значения запрашиваются скрытым вводом и + не появляются ни в stdout-транскрипте, ни в отчёте (только имена ключей); non-TTY-запуск + без неинтерактивной альтернативы → честный отказ с подсказкой, не зависание. +- **FAIL:** значение секрета напечатано/залогировано; неверный токен принят без верификации; + бесконечный цикл re-prompt; зависание в non-TTY. + +--- + +## AC-5 — Сборка `.env`/`.env.watchdog`: канон, свежие секреты, отказ перетирания + +**Условие:** рендер конфигов (FR-6) на tmp-каталоге. +- **PASS:** собранный `.env` содержит все группы обязательных ключей §4.2 LITE_SETUP + (Plane, Gitea, webhook-секреты, Telegram, `ORCH_PROJECTS_JSON` — допустим как отложенный + MANUAL до FR-9, хост-параметры, порты), а собранный `.env.watchdog` — оба ключа + `WATCHDOG_TG_*` (и они НЕ требуются в `.env`); webhook-секреты — свежевыпущенные кирпичом + `gen_secrets` (64 hex, при повторном выпуске значения различаются); существующий + `.env`/`.env.watchdog` → отказ с exit 2 без force-флага, файл не изменён байт-в-байт; + подсказки-дефолты промптов не содержат боевых значений исходного хоста. +- **FAIL:** молчаливая перезапись существующего файла; обязательный ключ отсутствует без + явной MANUAL-отметки; секреты скопированы/захардкожены вместо свежего выпуска. + +--- + +## AC-6 — Порты: busy-check, когерентная тройка, staging ≠ prod + +**Условие:** логика портов (FR-5). +- **PASS:** занятый прод-порт → предложена альтернатива; смена прод-порта → в собранном + `.env` синхронно согласованы `ORCH_DEPLOY_PROD_TARGET_PORT` ⇄ `WATCHDOG_METRICS_URL` ⇄ + `ORCH_POST_DEPLOY_BASE_URL`; ввод `ORCH_STAGING_PORT` равного прод-порту → отказ + fail-closed (значение не принято). +- **FAIL:** тройка рассинхронизирована; совпадение staging/prod принято молча. + +--- + +## AC-7 — Норматив C-1: два независимых Telegram-бота + +**Условие:** шаг Telegram (FR-7) получает одинаковые токены для орка и watchdog. +- **PASS:** шаг отказывает с явным объяснением запрета (C-1 ORCH-100) и требует другой токен; + различные токены (оба `getMe` ok) → PASS шага. +- **FAIL:** одинаковые токены приняты молча. + +--- + +## AC-8 — Норматив §6.4: branch protection на `main` — честный FAIL без удаления + +**Условие:** шаг Gitea (FR-7) при непустом списке `branch_protections` репо (замокан). +- **PASS:** шаг даёт FAIL с лечением (текст норматива §6.4: правила удалить, иначе ложные + HOLD); скрипт НЕ выполняет удаление правил сам (no-delete); пустой список → PASS шага. +- **FAIL:** непустые правила пропущены молча; скрипт сам удалил правила. + +--- + +## AC-9 — Запуск Lite-контура: ровно два контейнера + health + +**Условие:** шаг запуска (FR-8) на замоканных `compose`/HTTP-примитивах. +- **PASS:** последовательность вызовов соответствует §9: `up -d --build` только после + согласия; проверка состава констатирует ровно `orchestrator` + `orchestrator-watchdog` + (поднятый `orchestrator-staging` или третий сервис → FAIL шага); health-чек требует + `/health` 200 `"status":"ok"`, `/queue` JSON, `/metrics` `schema_version: 1`; + stateless-проверка отмечает чужие задачи/ненулевые счётчики как FAIL с лечением §12. +- **FAIL:** контур поднят без согласия; состав не проверяется; нездоровый инстанс получает + PASS. + +--- + +## AC-10 — Регистрация проекта: строго кирпич onboarding + +**Условие:** шаг регистрации (FR-9). +- **PASS:** аргументы `onboard_project.py` построены чистой функцией из собранных ответов + (unit-проверяемо); последовательность `plan` → согласие → `apply` → `verify`; exit 2 + кирпича транслируется как MANUAL (не как успех); `ORCH_PROJECTS_JSON` из отчёта `apply` + вписывается в `.env`; рестарт собственного контура — только после проверки тихого окна + (`/queue` без running-job). Скрипт сам НЕ создаёт статусы/лейблы/репо/webhook мимо кирпича. +- **FAIL:** скрипт дублирует канон-знания кирпича (свой список статусов/лейблов); `apply` + вызван без показа плана/согласия; реестр не доведён до `.env` без MANUAL-отметки. + +--- + +## AC-11 — Контракт exit-кодов и resume + +**Условие:** прогоны step-движка на фикстурах (все шаги ok / есть manual / есть ошибка). +- **PASS:** все PASS → exit 0; остановка на manual-step / незавершённое предусловие → exit 2; + ошибка → exit 1; после exit 2 повторный запуск продолжает с первого незавершённого шага. +- **FAIL:** коды перепутаны/неразличимы; manual-step считается успехом. + +--- + +## AC-12 — Гигиена скрипта (структурно, по образцу test_bootstrap_script.py) + +**Условие:** статический анализ файла скрипта. +- **PASS:** импорты — только python stdlib (ast-скан); модули платформы (`src.*`) не + импортируются; delete-операций нет (эвристический скан: `rm -rf`, `compose down -v`, + `DELETE`-вызовы API, `git push --delete` и т.п.); импорт модуля не имеет side effects; + значения секретов не печатаются (нет f-string/print с переменными секретов); канонические + кирпичи (`gen_secrets.py`, `onboard_project.py`, `LITE_SETUP.md`) упомянуты/используются. +- **FAIL:** любой пункт нарушен. + +--- + +## AC-13 — Рантайм байт-в-байт + +**Условие:** диф задачи. +- **PASS:** `src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.env.example`, + `.env.watchdog.example`, схема БД, `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` — не изменены; + полный регресс `pytest tests/ -q` зелёный. +- **FAIL:** любой из перечисленных путей/контрактов изменён. + +--- + +## AC-14 — Документация синхронна (канон не форкается) + +**Условие:** состояние доков в том же PR. +- **PASS:** `docs/deployment/LITE_SETUP.md` вводит скрипт как рекомендованный быстрый путь и + сохраняет ручной маршрут; `tests/test_lite_setup_doc.py` зелёный (при изменении + пиннингуемой структуры — обновлён в том же PR); `CHANGELOG.md` несёт запись; витрина + `docs/overview/` обновлена, если её описание тиража затронуто. +- **FAIL:** скрипт добавлен, а LITE_SETUP.md не упоминает его / doc-тест красный / + CHANGELOG пуст. + +--- + +## Сводная матрица AC ↔ BR/FR + +| AC | FR | BR | Тип проверки | +|----|----|----|--------------| +| AC-1 | FR-1 | BR-1, BR-5 | unit (step-движок, парсер режимов) + структурный | +| AC-2 | FR-2 | BR-2 | unit (вердикты на фикстурах фактов) | +| AC-3 | FR-3 | BR-4 | unit (классификатор discovery на фикстурах docker) | +| AC-4 | FR-4 | BR-3 | unit (инжектируемый I/O, замоканная верификация) | +| AC-5 | FR-6 | BR-3, BR-5 | unit + tmp-ФС | +| AC-6 | FR-5 | BR-2, BR-6 | unit (чистая функция когерентности) | +| AC-7 | FR-7 | BR-6 | unit | +| AC-8 | FR-7 | BR-6, BR-7 | unit (замоканный Gitea API) | +| AC-9 | FR-8 | BR-6 | unit (замоканные compose/HTTP) | +| AC-10 | FR-9 | BR-6, BR-7 | unit (builder аргументов, трансляция exit-кодов) | +| AC-11 | FR-1, FR-10 | BR-1, BR-5 | unit (контракт кодов, resume) | +| AC-12 | — | BR-5, BR-7, NFR-1/2/3 | структурный (ast/эвристики) | +| AC-13 | — | BR-8 | структурный + полный регресс | +| AC-14 | FR-11 | BR-7 | структурный (doc-тесты) | diff --git a/docs/work-items/ORCH-104/04-test-plan.yaml b/docs/work-items/ORCH-104/04-test-plan.yaml new file mode 100644 index 0000000..4a9b401 --- /dev/null +++ b/docs/work-items/ORCH-104/04-test-plan.yaml @@ -0,0 +1,197 @@ +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-11 +model_used: claude-opus-4-8 +title: "Установочный скрипт Lite-тиража: интерактивный installer (setup_lite)" +framework: pytest +scope: > + Новый операторский CLI scripts/setup_lite.py (имя-кандидат) + обновление + docs/deployment/LITE_SETUP.md + анти-дрейф тесты. Вне покрытия: реальные + TTY/сеть/docker/пакетные менеджеры (всё мокается/инжектируется), установка + Plane/Gitea (вне объёма Lite), сквозной прогон на живом хосте (ручной smoke + по LITE_SETUP §11 силами оператора/deploy-staging). +notes: > + Принципы (паттерн tests/test_bootstrap_script.py): вся решающая логика — чистые + функции (вердикты предусловий, классификатор discovery, когерентность портов, + рендер env, builder аргументов onboarding, step-движок), тестируемые без + TTY/сети/docker; интерактив — через инжектируемый I/O (скриптованные ответы); + файловые сценарии — на tmp_path; структурная гигиена — ast/эвристики по файлу + скрипта. Полный существующий регресс tests/ обязан остаться зелёным; имя модуля + скрипта в тестах — фактическое (если архитектор финализирует иное имя). + +tests: + # ---------- AC-1 / FR-1: точка входа, режимы, step-движок ---------- + - id: TC-01 + type: unit + description: "Парсер CLI: существует read-only режим диагностики (ноль мутаций) и установочный режим; набор режимов закрыт; контракт соответствует ADR (паттерн plan/apply/verify ORCH-103)" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-02 + type: unit + description: "Step-движок check→ensure: шаг с уже истинным check пропускается (skip) без вызова ensure; повторный прогон по фикстуре 'всё выполнено' — каскад skip, ни одной повторной мутации" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-03 + type: unit + description: "Resume: прогон останавливается на manual-step (exit 2); повторный запуск продолжает с первого незавершённого шага, выполненные не перевыполняются" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-2 / FR-2: скан предусловий и офер установки ---------- + - id: TC-04 + type: unit + description: "Вердикты предусловий: полный набор фактов хоста (docker/compose v2/git/python3/node/claude-code/креды/docker-группа/uid-gid/ssh/порты) → все OK, блокеров нет" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-05 + type: unit + description: "Факт 'docker отсутствует' → вердикт MISSING с конкретной командой установки под детектированный пакетный менеджер; инжектированный отказ от согласия → шаг MANUAL, команда напечатана, мутация НЕ выполнена" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-06 + type: unit + description: "Неопределимый пакетный менеджер → честный MANUAL с готовыми командами и ссылкой на § LITE_SETUP (не молчаливый пропуск, не падение); uname != Linux x86_64 → WARN 'вне контура Lite'" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-3 / FR-3: discovery Plane/Gitea ---------- + - id: TC-07 + type: unit + description: "Классификатор discovery: фикстура docker-перечня с ДВУМЯ Plane-инсталляциями (разные compose-проекты) → ровно 2 кандидата с проектом/образами/портами; выбор пользователя применяется; пункт 'ввести вручную' присутствует" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-08 + type: unit + description: "Discovery: одна инсталляция → её URL префилл по умолчанию (с подтверждением); ноль инсталляций → ручной ввод + подсказка про Bundled; посторонние образы (не Plane/не Gitea) в кандидаты не попадают" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-09 + type: unit + description: "Discovery best-effort: ошибка/недоступность docker при перечислении → ручной ввод URL без падения и без блокировки прогона (never-block)" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-4 / FR-4: интерактивный сбор + верификация + секрет-гигиена ---------- + - id: TC-10 + type: unit + description: "Цикл запроса с инжектированным I/O: верификация токена падает (401, мок) → re-prompt с диагнозом; после лимита попыток → MANUAL/остановка exit 2, НЕ бесконечный цикл; успешная верификация → значение принято" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-11 + type: unit + description: "Секрет-гигиена: секретные значения запрашиваются скрытым вводом и отсутствуют в stdout-транскрипте и итоговом отчёте (печатаются только имена ключей)" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-12 + type: unit + description: "non-TTY без неинтерактивной альтернативы → честный отказ с подсказкой (детерминированный exit), НЕ зависание на ожидании ввода" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-5 / FR-6: сборка .env / .env.watchdog ---------- + - id: TC-13 + type: integration + description: "Рендер на tmp_path: собранный .env содержит все группы обязательных ключей §4.2 LITE_SETUP; .env.watchdog содержит WATCHDOG_TG_BOT_TOKEN/WATCHDOG_TG_CHAT_ID (файл-носитель §4.3); webhook-секреты свежие 64-hex и различаются между прогонами" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-14 + type: integration + description: "Существующий .env (и отдельно .env.watchdog) на tmp_path → отказ exit 2 без force-флага, файл байт-в-байт не изменён; с явным force — перезапись выполняется" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-15 + type: unit + description: "Подсказки-дефолты промптов берутся из .env.example/автодетекта и не содержат боевых значений исходного хоста (ни секретов, ни хост-литералов: переиспользовать FORBIDDEN-набор test_no_host_hardcodes)" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-6 / FR-5: порты ---------- + - id: TC-16 + type: unit + description: "Когерентность портов: смена прод-порта → синхронно согласованы ORCH_DEPLOY_PROD_TARGET_PORT ⇄ WATCHDOG_METRICS_URL ⇄ ORCH_POST_DEPLOY_BASE_URL; занятый порт (мок busy-check) → предложена альтернатива" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-17 + type: unit + description: "ORCH_STAGING_PORT == прод-порт → отказ fail-closed (значение не принято; инвариант ORCH-058/101)" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-7..AC-8 / FR-7: машинная охрана нормативов ---------- + - id: TC-18 + type: unit + description: "Telegram C-1: одинаковые токены бота орка и watchdog-бота → отказ шага с объяснением запрета; различные валидные (getMe ok, мок) → PASS шага" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-19 + type: unit + description: "Gitea branch protection (мок API): непустой branch_protections на main → FAIL шага с лечением §6.4, БЕЗ попытки удаления правил скриптом; пустой список → PASS" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-20 + type: unit + description: "Webhook Plane Path Б (SQL): выполняется только при явном согласии — инжектированный отказ → MANUAL-чекпоинт с инструкцией UI-пути, мутирующий вызов НЕ произведён (мок); после согласия — обязательная пост-верификация" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-9 / FR-8: запуск и health ---------- + - id: TC-21 + type: unit + description: "Шаг запуска (моки compose/HTTP): up только после согласия; состав 'ровно orchestrator + orchestrator-watchdog' → PASS, поднятый staging/третий сервис → FAIL шага; health требует /health 200 ok + /queue JSON + /metrics schema_version 1; чужие задачи в /queue → FAIL stateless-проверки" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-10 / FR-9: onboarding-кирпич ---------- + - id: TC-22 + type: unit + description: "Builder аргументов onboard_project.py — чистая функция от собранных ответов (имя/repo/prefix/stack/test-cmd/порты/webhook-url); последовательность plan→согласие→apply→verify; exit 2 кирпича транслируется как MANUAL; скрипт не несёт собственного канона статусов/лейблов" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-11 / FR-1, FR-10: exit-коды ---------- + - id: TC-23 + type: unit + description: "Контракт exit-кодов: все шаги PASS → 0; manual-step/незавершённое предусловие → 2; ошибка → 1; коды — именованные константы" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-12: структурная гигиена скрипта ---------- + - id: TC-24 + type: unit + description: "ast-скан: импорты скрипта — только python stdlib; модули платформы (src.*) не импортируются; канонические кирпичи gen_secrets.py/onboard_project.py и LITE_SETUP.md упомянуты" + module: tests/test_setup_lite_script.py + expected: PASS + + - id: TC-25 + type: unit + description: "Эвристический скан: delete-операций нет (rm -rf, compose down -v, DELETE-вызовы API, push --delete и т.п.); import модуля скрипта не имеет side effects (ничего не пишет/не запускает)" + module: tests/test_setup_lite_script.py + expected: PASS + + # ---------- AC-13..AC-14: рантайм и документация ---------- + - id: TC-26 + type: integration + description: "Рантайм байт-в-байт: полный существующий регресс pytest tests/ -q зелёный; src/**, корневой docker-compose.yml, Dockerfile, .env.example, .env.watchdog.example задачей не изменены" + module: tests/ (полный регресс) + expected: PASS + + - id: TC-27 + type: unit + description: "LITE_SETUP.md вводит установочный скрипт как рекомендованный быстрый путь (упоминание файла скрипта в доке) и сохраняет ручной маршрут; все проверки test_lite_setup_doc.py зелёные (при изменении пиннингуемой структуры тест обновлён в том же PR)" + module: tests/test_lite_setup_doc.py + expected: PASS diff --git a/docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md b/docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md new file mode 100644 index 0000000..6d6549d --- /dev/null +++ b/docs/work-items/ORCH-104/06-adr/ADR-001-setup-lite-interactive-installer.md @@ -0,0 +1,470 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# ADR-001: Интерактивный installer Lite-тиража — `scripts/setup_lite.py` (wizard поверх канона LITE_SETUP) + +Work Item: **ORCH-104** — Установочный скрипт Lite-тиража (интерактивный installer) +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0040-lite-interactive-installer.md`** +(дополняет серию канонов тиража adr-0035…0038: Lite получает исполняемый инструмент; +вводится норматив сопровождения «док + скрипт в одном PR» и wizard-контракт consent-gated +мутаций, обязательный для будущих операторских CLI платформы). + +## Статус +Proposed + +## Контекст + +Эпик ORCH-10 закрыт по обоим типам: Type A — Lite **документом** (ORCH-102: +`docs/deployment/LITE_SETUP.md`, 13 нормативных разделов, ~30+ ручных fenced-команд), Type B — +Bundled **комплектом + скриптом** (ORCH-103: `deploy/bundled/` + `scripts/bootstrap_bundle.py`). +Асимметрия: у Lite нет инструмента — порог входа высокий (BRD §1.1: оператор вручную собирает +значения, которые машина определяет сама; ошибки конфигурации проявляются поздно). Бизнес-запрос +Владельца (BRD §1.2): один установочный файл, который выполняет установку, запрашивает данные в +момент установки, сканирует систему с офером доустановки и даёт выбор при нескольких инсталляциях. + +Факты, сверенные с репо (не изобретать): + +- **Паттерн step-движка готов:** `scripts/bootstrap_bundle.py` (ORCH-103 D5) — режимы + `plan` (дефолт)/`apply`/`verify`, check→ensure (повторный запуск = каскад skip), exit `0/2/1`, + `manual_checkpoint(title, instructions, verify, max_tries=3)` (без TTY — немедленный exit 2), + чистые функции `parse_env`/`render_env`/`preflight_verdict`/`build_plan` под unit-тестами, + `_ensure_venv` (host-venv для onboard-кирпича, probe `import httpx, pydantic`), + `_psql` (SQL через stdin — секреты не в argv), инвариант `APPLY_STEPS == build_plan()`. +- **Кирпичи:** `scripts/gen_secrets.py` (`--write PATH` x-mode: существующий файл → exit 2, + перезапись только `--force`; `secrets.token_hex(32)` → 64 hex); `scripts/onboard_project.py` + (`plan|apply|verify`, обязательные `--name/--repo/--prefix/--stack/--test-cmd/--prod-port/ + --staging-port/--webhook-url`, опции `--gitea-owner/--env-file/--json`, exit 0/2/1). +- **Анти-дрейф канона:** `tests/test_lite_setup_doc.py` пиннит кортеж `SECTIONS` — + заголовки `## 1.`…`## 13.` в порядке маршрута (TC-01), «каждый раздел §2–§13 несёт + fenced-команду» проверяется только для `SECTIONS[1:]` (§1 — без требования команд), каждый + упомянутый в доке `ORCH_*`/`WATCHDOG_*`-токен обязан существовать в `.env.example` + (`_ENV_TOKEN_RE`), fenced-блоки сканируются на `FORBIDDEN`-литералы и секрет-эвристику + (hex≥32 / alnum≥40). +- **Структурная гигиена скриптов:** `tests/test_bootstrap_script.py` — ast-скан stdlib-allowlist, + `FORBIDDEN_DELETE_NEEDLES` (`volume rm`, `rm -rf`, `down -v`, `compose down`, `rmtree`, + `os.remove`, `.unlink`), `FORBIDDEN_STATUS_NEEDLES` (`Backlog`, `To Analyse`, `Confirm Deploy`, + …) — собственный канон статусов в скрипте запрещён. +- **Сеть нашего контура:** все три сервиса корневого `docker-compose.yml` — + `network_mode: host` → из контейнера орка `http://127.0.0.1:` достигает + сервисов хоста; URL-кандидаты discovery на published-портах валидны для API-ключей. +- **Целевой режим резолва модели/эффорта, статусов и секретов** — только кирпичи/конфиг + (ORCH-41/74, `plane_sync._PLANE_NAME_TO_KEY`, gen_secrets): скрипт канон-знаний не дублирует. + +ТЗ (02-trz.md §12) оставило архитектору OQ-1…OQ-6. Ниже — пакет решений D1…D12. +Тип изменения — **scripts + docs + tests**: рантайм `src/**` байт-в-байт (BR-8/AC-13). + +## Решение + +### Сводка + +Новый операторский CLI **`scripts/setup_lite.py`** (имя финализировано, зеркалит +`docs/deployment/LITE_SETUP.md`): python stdlib-only wizard, автоматизирующий маршрут +LITE_SETUP §2–§12. Режимы — семейные **`plan` / `apply` / `verify`**, но **дефолт — `apply`** +(интерактивная установка; осознанное отступление от plan-default семейства — D2): бизнес-цель +«одна команда» — суть задачи, а безопасность дефолта обеспечена структурно (фаза 0 ≡ plan, +ранний guard `.env`, per-action consent на каждую мутацию, non-TTY → exit 2 до мутаций). +Step-движок check→ensure, 10 нормативных шагов (D3), exit `0/2/1`, resume = повторный запуск +без state-файла (реальность — единственный источник истины; коллизию «resume против guard +существующего `.env`» решает маркер-строка managed-файла — D6). Скан предусловий с офером +доустановки per-package consent'ом (D4), discovery docker-инсталляций Plane/Gitea строго по +image-префиксам с выбором пользователя (D5), интерактивный сбор ключей §4.2 с немедленной +верификацией и секрет-гигиеной (getpass, env-prefill + `--yes` для headless — D10), webhook +Plane CE: Path A (UI) — рекомендация, Path Б (SQL) — офер под пятью предусловиями (D8), +машинная охрана нормативов C-1 / §6.4 / ORCH-058 (D9), onboarding строго кирпичом +plan→согласие→apply→verify (D11). Канон не форкается: LITE_SETUP получает подраздел +`### 1.1. Быстрый путь` (пиннинг «13 разделов» цел байт-в-байт — D12). + +### D1 — Имя, место, рамка: `scripts/setup_lite.py`, один файл, stdlib-only (OQ-1-часть) + +- **`scripts/setup_lite.py`** — один файл в `scripts/` рядом с кирпичами; имя зеркалит док-канон + (`LITE_SETUP.md` ⇄ `setup_lite.py` — мнемоническая пара «инструкция ⇄ инструмент»). + Альтернатива `bootstrap_lite.py` отвергнута: bootstrap в платформе уже значит «доводка + Bundled-стека» (ORCH-103); у Lite семантика иная — установка поверх инфраструктуры заказчика. +- **python stdlib-only** (NFR-1): запуск `python3 scripts/setup_lite.py` из корня чекаута на + голом python3 ДО venv и до `docker compose up`; импорты — только stdlib-allowlist (ast-скан, + паттерн `test_bootstrap_script.py::STDLIB_ALLOWED`); `src.*` не импортируется; канон-знания — + только субпроцессами кирпичей (`gen_secrets.py`, `onboard_project.py`). +- **Язык UX — русский** (паттерн gen_secrets/bootstrap_bundle); каждое сообщение шага ссылается + на соответствующий § LITE_SETUP как на полный канон (NFR-4). +- Рамка Lite не расширяется: Linux x86_64 + Docker/Compose v2; установка Plane/Gitea — вне + объёма (подсказка «нет инфраструктуры → Bundled»); teardown/uninstall отсутствуют. + +### D2 — Режимы и дефолт: `plan`/`apply`/`verify`, дефолт — `apply`-wizard (OQ-1) + +- **Закрытый набор режимов** — семейная лексика ORCH-009/103: `plan`, `apply`, `verify` + (никаких новых слов `wizard`/`install`: оператор, выучивший bootstrap_bundle, понимает + setup_lite мгновенно; TC-01 «набор режимов закрыт»). +- **Контракты `plan` и `verify` — 1:1 с семейством:** `plan` — строгий read-only (скан + предусловий + discovery + автодетект + печать плана шагов; ноль мутаций ФС/docker/сети; + exit 0 — блокеров нет / 2 — есть); `verify` — read-only пост-проверка (`/health`+`/queue`+ + `/metrics`, состав «ровно орк+watchdog», stateless-чистота §12, `onboard_project.py verify` + субпроцессом при доступном venv). Оба — полноценные non-TTY-режимы (CI-пригодны). +- **Дефолт без аргументов — `apply`** (отступление от plan-default семейства, осознанное и + тестируемое): бизнес-запрос Владельца — «один установочный файл, который на автомате + выполняет установку» (BRD §1.2 п.1); plan-default воспроизвёл бы порог, который задача + призвана убрать. **Семейный инвариант сохранён в его сути** — «запуск без аргументов не + мутирует без явного согласия» — но другим механизмом: у bootstrap_bundle `apply` мутирует + без per-action вопросов (его согласие = выбор режима), поэтому там безопасен только + plan-default; у setup_lite **каждая мутация отдельно согласуется** (FR-1), поэтому + apply-default безопасен по построению: + 1. **Фаза 0 `apply` ≡ `plan`:** read-only скан + discovery + печать плана; первый вопрос + оператору задаётся только после неё («Продолжить установку? [y/N]» — дефолт-ответ N); + 2. **Ранний guard `.env`:** существующий немаркированный `.env`/`.env.watchdog` (D6) → + отказ exit 2 ДО первого вопроса установки (NFR-7: живой хост защищён); + 3. **Per-action consent:** каждая мутация (установка пакета, запись файла, SQL, `up`, + рестарт) — отдельное согласие с печатью точной команды; отказ → честный MANUAL + с эквивалентной командой, не молчаливый пропуск; + 4. **non-TTY:** `apply` без `--yes` → немедленный честный exit 2 с подсказкой + (никаких зависаний и мутаций — D10). +- Тест-контракт (новый модуль, D12): `parse_args([]).mode == "apply"` — **сознательно** + зеркальный к `test_plan_is_default_mode` bootstrap'а ассерт с комментарием-обоснованием; + набор режимов закрыт `choices=("plan", "apply", "verify")`. +- **Флаги:** `--force` (разрешить перезапись существующих НЕмаркированных `.env*` — см. D6; + печать пути + согласие всё равно запрашивается), `--yes` (headless-consent, D10). Параметры + проекта заказчика (для onboarding-шага) — опциональные `--project-*`-флаги как + альтернатива интерактивному вводу (точный список — developer, зеркало sandbox-флагов + bootstrap_bundle). + +### D3 — Step-движок: 10 нормативных шагов, check→ensure, без state-файла (FR-1) + +- **Нормативный план `apply`** (механика — паттерн `build_plan()`/`APPLY_STEPS` ORCH-103; + инвариант «APPLY_STEPS == build_plan()» — под тестом): + + | # | Шаг | § LITE_SETUP | Суть | + |---|-----|--------------|------| + | 1 | `scan` | §2, §7 | read-only скан предусловий + автодетект хост-параметров + ранний guard `.env` (D6) | + | 2 | `prereqs` | §2, §7 | доустановка MISSING per-package consent'ом / MANUAL (D4) | + | 3 | `discovery` | §5, §6 | обнаружение инсталляций Plane/Gitea, выбор / ручной ввод (D5) | + | 4 | `collect` | §4.2, §5–§8 | интерактивный сбор ключей с немедленной верификацией (D9, D10) | + | 5 | `render-env` | §4 | сборка `.env`/`.env.watchdog` от канонов + gen_secrets + `docker compose config` (D6, D7) | + | 6 | `plane-webhook` | §5.4 | Path A manual-checkpoint / Path Б офер SQL (D8) | + | 7 | `gitea-guards` | §6 | branch_protections == `[]` (FAIL+лечение), ssh-pubkey manual-step (D9) | + | 8 | `up` | §9 | `docker compose up -d --build` с согласия; состав «ровно орк+watchdog»; health-чек | + | 9 | `onboard` | §10 | кирпич plan→согласие→apply→verify; `ORCH_PROJECTS_JSON` → `.env`; управляемый рестарт (D11) | + | 10 | `report` | §11, §12 | stateless-проверка; итоговая таблица PASS/FAIL/MANUAL; smoke-инструкция ссылкой на §11 | + +- **check→ensure:** каждый шаг сперва проверяет «уже сделано?» по реальности (файл существует и + маркирован; токен валиден API-пробой; контейнеры бегут; webhook существует) и при PASS + пропускается. **State-файл НЕ вводится** (альтернатива отвергнута: новый артефакт = новая + поверхность дрейфа/устаревания; реальность — единственный источник истины; resume после + manual-step = просто повторный запуск — каскад skip доводит до первого незавершённого шага, + NFR-6; паттерн bootstrap_bundle). +- **Exit-коды — именованные константы** `EXIT_OK=0` / `EXIT_MANUAL=2` / `EXIT_ERROR=1` + (контракт FR-1/AC-11; исключения `ManualStop`/`SetupError` — паттерн bootstrap). +- **Smoke-инструкция шага 10 — ссылкой на LITE_SETUP §11**, без текста статусов: скрипт не несёт + имён Plane-статусов вообще (новый тест зеркалит `FORBIDDEN_STATUS_NEEDLES` — D12); вердикт + «тираж PASS» остаётся за оператором (FR-10). +- **Итоговая таблица** (FR-10): шаг → PASS/FAIL/MANUAL/skip; для MANUAL — что сделать + § дока; + для FAIL — диагноз и лечение (зеркало §13). Опциональный файл-отчёт НЕ вводится (stdout + + exit-код достаточны; меньше артефактов — меньше утечек, NFR-3). + +### D4 — Скан предусловий и оферы установки: per-package consent, закрытый набор менеджеров (OQ-4) + +- **Вердикты — чистая функция** `prereq_verdicts(facts) -> [(item, OK|MISSING|WARN|MANUAL, + detail)]` от read-only снимка фактов хоста (паттерн `collect_facts`/`preflight_verdict`): + ОС/арх (`uname -sm`; не-Linux/не-x86_64 → **WARN «вне контура Lite»**, не FAIL), docker, + compose v2, git, python3, node, дистрибутив claude-code (`npm root -g` → + `@anthropic-ai/claude-code`), аутентификация CLI (читаемость `~/.claude/.credentials.json` + uid'ом будущего контейнера — §7.2), группа docker (`getent group docker`), uid/gid владельца + каталога репо (§2.2), ssh-каталог (§2.4), свободность портов (§2.5). Ни один пункт перечня + FR-2 не пропускается молча (AC-2). +- **Оферы установки — исполняются скриптом, но только с явного per-package согласия** + (бизнес-запрос п.3 «предлагает установить»; BRD R-2): печать **точной команды** ДО исполнения + → согласие → исполнение → **re-check фактом** (повторная проверка версии/наличия; для compose + обязательно `docker compose version` — нативные репо дистрибутивов могут нести v1!) → + не сошлось → честный MANUAL со ссылкой на официальную инструкцию. Отказ от согласия → + MANUAL с той же командой (TC-05). +- **Закрытый набор пакетных менеджеров: `apt-get` / `dnf` / `yum` / `zypper`** (детект по + наличию бинаря, в этом порядке; `apt-get`, не `apt` — стабильный CLI). Неопределимый + менеджер (например, pacman/alpine) → MANUAL с готовыми generic-командами и ссылкой на + § LITE_SETUP — не падение (TC-06). Точные имена пакетов per-менеджер — developer (карта- + константа в скрипте); политика зафиксирована здесь. +- **Sudo-честность:** требуется root, а его нет (не root и sudo недоступен) → MANUAL с командой + под sudo; скрипт не пытается эскалировать привилегии сам. +- **Спец-случаи:** node+claude-code — офер `npm install -g @anthropic-ai/claude-code` (node — + пакетом менеджера); ssh-ключи — офер `ssh-keygen -t ed25519` + печать pubkey как manual-step + «добавить в Gitea» (§2.4/§6.2); **OAuth-логин claude CLI не автоматизируется** — только + верификация результата + manual-step (§7.2, допущение BRD §6). +- **Автодетект хост-параметров (FR-5)** — спрашивается только подтверждение, не значения: + `ORCH_RUN_UID`/`ORCH_RUN_GID` (владелец `ORCH_HOST_REPOS_DIR` / текущий пользователь), + `ORCH_DOCKER_GID`, `ORCH_HOST_NODE_BIN` (`which node`), `ORCH_HOST_CLAUDE_CODE_DIR` + (`npm root -g`), `ORCH_AGENT_HOME_DIR`/`ORCH_HOST_CLAUDE_DIR`/`ORCH_HOST_CLAUDE_JSON` + (HOME из §2.2), `ORCH_DEPLOY_HOST_REPO_PATH` (корень чекаута). + +### D5 — Discovery Plane/Gitea: строго image-префиксы, выбор всегда за пользователем (FR-3) + +- **Источник — только локальный Docker:** перечисление контейнеров (`docker ps` с форматом, + дающим имя/образ/published-порты/метку `com.docker.compose.project`); группировка контейнеров + в «инсталляции» по метке compose-проекта (без метки → группа-одиночка по имени контейнера). +- **Опознание — строго по префиксам образов** (анти-ложноположительность R-6; имена + контейнеров/проектов НЕ используются как признак): Plane — образы `makeplane/*`; + Gitea — `gitea/gitea*` и `docker.gitea.com/gitea*`. Посторонние образы в кандидаты не + попадают (AC-3). Список префиксов — константа скрипта (расширение = осознанная правка под + тестом). +- **URL-кандидаты из published-портов:** Plane — порт контейнера-входа (образ + `makeplane/plane-proxy`; нет proxy → инсталляция показывается без URL-префилла); + Gitea — published-порт на контейнерный 3000. Предлагаются: API-URL `http://127.0.0.1:` + (валидно: корневой compose — `network_mode: host`, контейнер орка видит published-порты хоста + через loopback) и публичный URL `http://:` (hostname — ввод/подтверждение + оператора; для webhook'ов нужен адрес, достижимый со стороны Plane/Gitea). +- **Поведение по числу кандидатов (BR-4):** `0` → ручной ввод URL + честная подсказка «Lite не + устанавливает Plane/Gitea; нет инфраструктуры — маршрут Bundled (BUNDLED_SETUP.md)»; `1` → + префилл по умолчанию с подтверждением; `≥2` → нумерованный список (compose-проект, образы, + порты) + выбор. Пункт **«ввести вручную» доступен всегда** (включая случай 1 и ≥2). +- **Best-effort, never-block:** docker недоступен / ошибка перечисления / native- или + k8s-инсталляция → ручной ввод URL, не FAIL и не падение (TC-09). Discovery заполняет только + кандидаты URL; **токены всегда вводит пользователь** (FR-4), выбранный кандидат всё равно + проходит токен-верификацию. +- **Постгрес-кандидат для Path Б (D8):** из выбранной Plane-инсталляции — контейнеры с образом + `postgres*` ТОГО ЖЕ compose-проекта; подтверждение пользователя обязательно. + +### D6 — Сборка `.env`/`.env.watchdog`: рендер от канонов + маркер managed-файла (FR-6; решает «resume против guard») + +- **Рендер от канонов** `.env.example`/`.env.watchdog.example` чистой функцией (паттерн + `render_env` bootstrap: строки/комментарии канона сохраняются, значения подставляются; + принцип ORCH-101 «дефолт = боевое значение» — записываются только собранные отличия). + `WATCHDOG_TG_BOT_TOKEN`/`WATCHDOG_TG_CHAT_ID` кладутся **только** в `.env.watchdog` + (ловушка файла-носителя §4.3); подсказки-дефолты промптов — из `.env.example`/автодетекта, + **никогда** из боевых значений (TC-15: FORBIDDEN-набор переиспользуется). +- **Маркер managed-файла — ключ к resume:** первая строка каждого собранного скриптом файла — + фиксированный комментарий-маркер `# managed by scripts/setup_lite.py (ORCH-104)`. Семантика + guard'а на шаге `scan`: + - файла нет → штатный путь (рендер на шаге 5); + - файл есть, **маркера нет** → это чужой/живой конфиг → **отказ exit 2 без `--force`**, + файл байт-в-байт не тронут (NFR-7/AC-5/TC-14; `--force` = явная перезапись с согласием); + - файл есть, **маркер есть** → собран нами ранее → **resume-ensure**: дозаполнение + недостающих ключей, существующие значения не перетираются (NFR-6; без маркера resume после + manual-step на шаге 6+ был бы невозможен — guard отбивал бы собственный артефакт). +- **Webhook-секреты — строго кирпичом:** субпроцесс `gen_secrets.py --write /fragment` + → парс фрагмента → перенос двух ключей (паттерн `step_secrets` bootstrap); свежий выпуск, + боевые секреты не используются (stateless §12). Запись live-файлов — права `600`, содержимое + не печатается (NFR-3). +- **Проверка шага:** `docker compose config` → PASS/FAIL (§4 «Проверка»; ошибка интерполяции → + диагноз «ищите незакрытую кавычку/невалидный JSON в ORCH_PROJECTS_JSON»). + +### D7 — Порты: busy-check, когерентная тройка одной функцией, staging ≠ prod fail-closed (FR-5) + +- Busy-check прод/staging-портов сокетом (паттерн `_port_busy`); занят → предложить + альтернативу (ввод с дефолтом «порт+N»). +- **Когерентность тройки — механически, одной чистой функцией** `port_overrides(prod_port) -> + {ORCH_DEPLOY_PROD_TARGET_PORT, WATCHDOG_METRICS_URL, ORCH_POST_DEPLOY_BASE_URL}` — рассинхрон + структурно невозможен (TC-16; ловушка §2.5/§4.2 закрывается кодом, не дисциплиной). +- **`ORCH_STAGING_PORT == прод-порт` → отказ fail-closed** на вводе (значение не принято, + re-prompt; инвариант ORCH-058, усиленный ORCH-101; TC-17). + +### D8 — Webhook Plane CE: Path A (UI) — рекомендация, Path Б (SQL) — офер под предусловиями (OQ-3) + +Каверза Plane CE: webhook не экспонирован во внешнем `/api/v1` (§5.4). В Bundled bootstrap +выполняет SQL-вставку безусловно — он **владеет** инсталляцией (сам её создал, знает контейнер +и креды). В Lite Plane — **продукт заказчика**: контейнер/пароль БД скрипту неизвестны, +инсталляция может быть не-docker, мутация чужой прод-БД инвазивна (BRD R-3, NFR-2: +«UI-путь предпочтителен»). Решение — двухпутёвый шаг с приоритетом UI: + +- **Path A (UI) — дефолт-рекомендация:** `manual_checkpoint`-контракт (печать точной инструкции + §5.4 с подставленными URL приёмника и именем секрета — значение секрета НЕ печатается, только + имя ключа) → подтверждение оператора → верификация. Верификация Path A: при доступных + координатах БД (введены для Path Б/проверки) — механический `SELECT url, is_active FROM + webhooks`; иначе — честная деградация: шаг фиксируется **MANUAL** («подтверждено оператором, + сквозная проверка доставки — smoke §11»), НЕ выдаётся за механически проверенный PASS (NFR-4). +- **Path Б (SQL INSERT) — офер автоматизации**, исполняется ТОЛЬКО при всех предусловиях: + 1. Plane-инсталляция — docker, и **Postgres-контейнер выбран и подтверждён пользователем** + (кандидаты — D5: `postgres*`-образы того же compose-проекта); + 2. **явное согласие с показом точного SQL** (INSERT канона §5.4) ДО исполнения; + 3. пароль БД Plane — **скрытый ввод** (или env-prefill, D10); в argv не попадает — + SQL и `PGPASSWORD` передаются через stdin/env субпроцесса (паттерн `_psql` bootstrap); + 4. **идемпотентность:** сначала `SELECT count(*)` по URL приёмника (`deleted_at IS NULL`) — + уже зарегистрирован → skip (паттерн `_exists` bootstrap); + 5. **обязательная пост-верификация** `SELECT url, is_active` → нет строки → шаг НЕ PASS. +- **Только INSERT/SELECT** — никаких UPDATE/DELETE (no-delete распространяется на чужую БД); + **slug и прочие подстановки валидируются** (`^[a-z0-9-]+$` для slug; UUID — `uuid.uuid4()` + локально; секрет — через psql-переменную/stdin, не конкатенация в argv) — анти-SQL-инъекция + на пользовательском вводе (отличие от bundle, где значения самогенерированные). +- Любой сбой Path Б / отказ согласия / не-docker Plane → **fail-safe в Path A** (manual-checkpoint + c инструкцией UI-пути; мутирующий вызов не произведён — TC-20). + +### D9 — Машинная охрана нормативов: C-1, §6.4, верификация каждого ввода (FR-4/FR-7) + +- **Немедленная верификация каждого введённого значения фактическим вызовом** (FR-4): + Plane-токен — `GET /api/v1/workspaces//projects/` c `X-API-Key` → 200; Gitea-токен — + `GET /api/v1/user` → 200 (+ логин владельца → префилл `ORCH_GITEA_OWNER`); Telegram — + `getMe` → `"ok":true` + helper chat-id через `getUpdates`; публичный URL оркестратора — + синтаксическая валидация + предупреждение «достижимость со стороны Plane/Gitea проверится + на smoke». Неуспех → re-prompt с диагнозом, **лимит 3 попытки** (паттерн + `manual_checkpoint(max_tries=3)`) → MANUAL/exit 2, не бесконечный цикл (TC-10). +- **C-1 (ORCH-100) — машинно:** токен watchdog-бота **байт-в-байт равен** токену орка → отказ + шага с объяснением запрета и требованием другого токена (TC-18); оба токена — скрытый ввод. +- **§6.4 — branch protection:** `GET /api/v1/repos///branch_protections` непуст → + **FAIL шага с лечением** (текст норматива: правила удалить, иначе ложные HOLD — §13.7); + скрипт правила **сам не удаляет** (no-delete; TC-19). Проверка — для репо проекта заказчика + после onboarding (шаг 9) и/или по введённому owner/repo. +- **§7.3:** наличие непустых `ORCH_AGENT_MODEL_DEFAULT`/`ORCH_AGENT_EFFORT_DEFAULT` в собранном + `.env` (дефолты канона `.env.example` достаточны — проверка, не ввод). + +### D10 — non-TTY и headless: env-prefill + явный `--yes`; answers-file отвергнут (OQ-2) + +- **Интерактив — за инжектируемым I/O** (NFR-5): примитивы `ask(key, secret=False)` / + `consent(action)` принимают источники ввода/вывода параметрами → unit-тесты со + скриптованными ответами без TTY (TC-10…TC-12). +- **Env-prefill — неинтерактивная альтернатива без новой лексики:** `ask()` ПЕРЕД промптом + проверяет переменную окружения процесса с **тем же каноническим именем ключа** + (`ORCH_PLANE_API_TOKEN`, `WATCHDOG_TG_BOT_TOKEN`, …) — значение найдено → принимается без + промпта, **но верификация выполняется как обычно** (неверный токен из env → в TTY re-prompt, + в headless exit 2). Словарь имён один — `.env.example`; никакого нового формата. +- **`--yes` — headless-consent:** явный флаг = заранее данное согласие на consent-вопросы + (аналог семантики «выбор режима apply = согласие» bootstrap_bundle). non-TTY матрица: + - `apply` без `--yes` → немедленный честный **exit 2** с перечнем того, что потребует + интерактива, и подсказкой (`plan`/`verify` доступны; TTY или `--yes`+env-prefill) — + зависание исключено (TC-12); + - `apply --yes` → consent'ы подтверждены флагом; значения — только из env-prefill; + отсутствует обязательное значение → exit 2 (не молчаливый дефолт); manual-step → exit 2 + (resume — повторный запуск); + - `plan`/`verify` — работают в non-TTY полноценно (exit по результату). +- **Answers-file отвергнут:** секреты в файле-вне-`.env` = новая поверхность утечки (NFR-3), + новый формат = новый канон под сопровождение; env-prefill покрывает кейс без этих издержек. + +### D11 — Onboarding: строго кирпич, plan→согласие→apply→verify, управляемый рестарт (OQ-6) + +- **Драйв субпроцессом** (прецедент `step_onboard` bootstrap_bundle), НЕ «печать готовой + команды»: печать разорвала бы «одну команду» на самом ценном шаге. Усиление против bundle — + **сначала `plan`**: вывод плана кирпича показывается пользователю → согласие → `apply` → + `verify` (FR-9/TC-22). +- **Запуск кирпича — host-venv** (канон ONBOARDING; кирпичу нужны httpx/pydantic): + идемпотентный ensure venv — паттерн `_ensure_venv` (probe импорта → `pip install -r + requirements.txt`); сам setup_lite от venv не зависит (stdlib-only). +- **Аргументы — детерминированная чистая функция** `build_onboard_args(answers, mode) -> list` + от собранных ответов (`--name/--description/--repo/--gitea-owner/--prefix/--stack/--test-cmd/ + --prod-port/--staging-port/--webhook-url` + `--env-file <корневой .env>` + `--json`) — + unit-тестируемо без сети (AC-10). Webhook-url строится от публичного URL оркестратора + (`https:///webhook/gitea`). +- **Трансляция исходов:** exit 2 кирпича (остались ручные шаги) → **MANUAL** итогового отчёта + (не успех); exit 1 → FAIL. `ORCH_PROJECTS_JSON` — из JSON-отчёта `apply` (строка + `instructions` `ORCH_PROJECTS_JSON=…` — фактический контракт кирпича) → запись в `.env` + с согласия. +- **Управляемый рестарт — только собственного свежеподнятого контура** (§10): проверка тихого + окна (`GET /queue` без running-job) → `docker compose up -d --force-recreate orchestrator` → + пост-проверка `/health`+`/queue`. Чужие/боевые контейнеры не трогаются (NFR-7; рестартуется + контур, поднятый шагом 8 этого же прогона). +- Собственная реализация статусов/лейблов/репо/webhook в setup_lite **запрещена** (BR-7; + 22 статуса остаются за `plane_sync._PLANE_NAME_TO_KEY` через кирпич; структурный тест — D12). + +### D12 — Документация и анти-дрейф: §1.1 в LITE_SETUP (пиннинг цел), новый тест-модуль (OQ-5, FR-11) + +- **Размещение быстрого пути — подраздел `### 1.1. Быстрый путь: setup_lite.py` внутри + `## 1. Рамка Lite`** (+ упоминание в шапке-цитате дока). Заголовки `## 1.`…`## 13.` не + меняются → пиннинг `test_doc_exists_with_all_13_sections_in_order` и нумерация ссылок + (§5.4, §13.7 и т.д. из других доков/скриптов) сохраняются **байт-в-байт**; ручной маршрут + §2–§13 — канон и fallback для MANUAL-шагов. Fenced-блок §1.1 (команда запуска + режимы) + обязан проходить существующие сканы дока: только плейсхолдеры, упоминаемые env-токены — + только существующие в `.env.example` (`_ENV_TOKEN_RE`-ассерт). Альтернативы отвергнуты: + `## 0`/14-й раздел/перенумерация — ломают пиннинг и внешние ссылки на §-номера без выгоды. +- **Footer-норматив LITE_SETUP расширяется** (FR-11): «меняешь шаги тиража → обнови этот док + **и `scripts/setup_lite.py`** в том же PR» (симметрично NFR-5 ORCH-102). +- **`tests/test_setup_lite_script.py`** (имя финализировано; по образцу + `test_bootstrap_script.py` + тест-план TC-01…TC-25): ast-скан stdlib-only и отсутствия + `src.*`; **зеркала обоих needle-наборов** (`FORBIDDEN_DELETE_NEEDLES` — delete-операций нет; + `FORBIDDEN_STATUS_NEEDLES` — собственного канона статусов нет); упоминание кирпичей + `gen_secrets.py`/`onboard_project.py`/`LITE_SETUP.md`; unit чистых функций (вердикты + предусловий, классификатор discovery, `port_overrides`, рендер env c маркером, builder + аргументов onboard, step-движок/resume, exit-контракт, дефолт-режим `apply`, закрытость + режимов); секрет-гигиена (инжектированный транскрипт без значений секретов); import без + side effects. +- **`tests/test_lite_setup_doc.py` — только аддитивно:** новый тест «LITE_SETUP.md упоминает + `setup_lite.py` (быстрый путь) и сохраняет ручной маршрут» (TC-27); существующие ассерты, + включая кортеж `SECTIONS`, **не правятся** (пиннинг структуры не меняется — D12 п.1). +- **Синхронно в том же PR** (правило агентов №2 / NFR-8): `CHANGELOG.md` (`feat:`); витрина + `docs/overview/README.md` (строка маршрута «Развернуть у себя» — упомянуть установочный + скрипт; `business.md` — при необходимости); паспорт `CLAUDE.md` (секция ORCH-104); + `docs/architecture/README.md` — обновлён на этой стадии (блок Type A дополнен инсталлером). + +### Что НЕ меняется + +`src/**`, корневой `docker-compose.yml`, `Dockerfile`, `.env.example`, `.env.watchdog.example`, +`scripts/gen_secrets.py`, `scripts/onboard_project.py`, `scripts/bootstrap_bundle.py`, +`deploy/**`, `onboarding/**`, промпты `.openclaw/agents/**`; `STAGE_TRANSITIONS`, состав +`QG_CHECKS`, семантика `check_*`, machine-verdict ключи, схема БД — **байт-в-байт** (BR-8/AC-13). +Kill-switch не вводится: активация — только явный запуск CLI человеком на целевом хосте +(паттерн ORCH-009/102/103); в нашем контуре артефакт инертен. Прод-контейнер в рамках задачи +не рестартуется (выкат — штатно: staging 8501 → `Confirm Deploy`). + +## Альтернативы + +- **`plan`-дефолт (строгое следование D5 ORCH-009/103)** — отвергнуто для этого инструмента: + воспроизводит порог входа, который задача убирает (бизнес-запрос «одна команда» — BRD §1.2); + безопасность дефолта достигается per-action consent + ранним guard + non-TTY exit 2 (D2), а + не выбором режима. `plan` сохранён как полноценный строгий read-only режим. +- **Отдельное имя режима `wizard`/`install`** — отвергнуто: новая лексика против выученной + семейной (`plan`/`apply`/`verify`); дешевле объяснить «apply интерактивен», чем вводить слово. +- **State-файл прогресса для resume** — отвергнуто: новый артефакт = поверхность + дрейфа/устаревания; check→ensure по реальности уже даёт resume (паттерн bootstrap); коллизия + с guard'ом `.env` решена маркером (D6) без состояния. +- **Answers-file для headless** — отвергнуто: секреты на диске вне `.env` (NFR-3), новый формат + под сопровождение; env-prefill с каноническими именами + `--yes` покрывает кейс (D10). +- **Безусловная автоматизация SQL-вставки webhook (как в bundle)** — отвергнуто: в Lite БД + Plane — чужая прод-инсталляция; без подтверждённого контейнера/согласия/пост-верификации + это R-3; UI-путь — рекомендация, SQL — офер (D8). +- **Только печать команд установки пакетов (никогда не исполнять)** — отвергнуто: ломает + бизнес-запрос п.3 («предлагает установить»); per-package consent + re-check дают тот же + уровень безопасности с лучшим UX (D4). +- **Discovery по именам контейнеров/compose-проектов** — отвергнуто: ложноположительные + срабатывания на чужих контейнерах (R-6); строго image-префиксы + выбор за пользователем (D5). +- **Новый раздел LITE_SETUP (`## 0`/14-й/перенумерация)** — отвергнуто: ломает пиннинг + «13 разделов в порядке» и внешние ссылки на §-номера; подраздел §1.1 даёт то же без издержек + (D12). +- **Опциональный файл-отчёт прогона** — отвергнуто (TRZ §9 оставлял на усмотрение): stdout + + exit-код покрывают наблюдаемость; файл с транскриптом — лишняя поверхность для случайного + попадания чувствительных значений. + +## Последствия + +- **+** Порог входа Lite падает с «прочитай 13 разделов и выполни ~30 команд» до «запусти один + файл и отвечай на вопросы»; ошибки конфигурации ловятся в момент ввода (немедленная + верификация), а не на smoke. +- **+** Нулевой дрейф канонов: статусы/секреты/onboarding — только кирпичами; маршрут — только + LITE_SETUP (ссылки из каждого шага); форма закреплена анти-дрейф тестами с зеркалами уже + существующих needle-наборов. +- **+** Рантайм байт-в-байт, наш прод недостижим по построению: ранний guard немаркированного + `.env` (живой хост), no-delete, никаких операций с `main`, рестарт — только собственного + свежеподнятого контура. +- **−** Дефолт `apply` отличается от plan-default семейства — осознанная цена бизнес-цели; + митигировано структурными гарантиями D2 (фаза 0 ≡ plan, consent, non-TTY exit 2) и явным + тест-ассертом с комментарием-обоснованием. +- **−** Двойной источник маршрута (док + скрипт) — поверхность дрейфа; митигировано нормативом + «док + скрипт в одном PR» (footer LITE_SETUP, adr-0040) и анти-дрейф тестами (кирпичи, + env-ключи ⊂ канона, упоминание скрипта в доке). +- **−** Оферы установки пакетов вмешиваются в систему заказчика — митигировано per-package + consent с печатью точной команды, закрытым набором менеджеров, re-check'ом и честным MANUAL. +- **−** Path Б мутирует чужую Plane-БД — митигировано пятью предусловиями D8 (docker + + подтверждённый контейнер + согласие с показом SQL + идемпотентный INSERT + пост-верификация) + и приоритетом UI-пути. +- **Откат:** удалить `scripts/setup_lite.py`, `tests/test_setup_lite_script.py`, вернуть §1.1 и + footer LITE_SETUP.md, аддитивный тест в `test_lite_setup_doc.py`, записи + CHANGELOG/CLAUDE.md/README/overview — состояние репо 1:1 (scripts+docs+tests, без миграций); + поведение платформы не меняется в обе стороны. + +## Ссылки + +- BRD: `docs/work-items/ORCH-104/01-brd.md` +- TRZ: `docs/work-items/ORCH-104/02-trz.md` (OQ-1…OQ-6 → D1…D12) +- Acceptance: `docs/work-items/ORCH-104/03-acceptance-criteria.md`; тест-план: `04-test-plan.yaml` +- Сквозной ADR: `docs/architecture/adr/adr-0040-lite-interactive-installer.md` +- Риски: `docs/work-items/ORCH-104/10-tech-risks.md`; инфра: `07-infra-requirements.md` +- Предшественники: adr-0037 (ORCH-102 — канон LITE_SETUP/13 разделов), adr-0038 (ORCH-103 — + bootstrap-паттерн: режимы/step-движок/manual-checkpoint/no-delete), adr-0036 (ORCH-101 — + «дефолт=боевое», gen_secrets, guard ORCH-058), adr-0035 (ORCH-009 — onboarding-CLI, D10 + branch protection) +- Сверено по коду/репо: `scripts/bootstrap_bundle.py` (`manual_checkpoint(max_tries=3)`, + `render_env`/`parse_env`, `_ensure_venv`, `_psql` stdin, `step_onboard` → `instructions` + `ORCH_PROJECTS_JSON=`, `APPLY_STEPS == build_plan()`), `scripts/gen_secrets.py` (`--write` + x-mode/exit 2/`--force`, token_hex(32)), `scripts/onboard_project.py` (CLI: `plan|apply|verify`, + обязательные флаги, `--env-file`/`--json`), `tests/test_lite_setup_doc.py` (кортеж `SECTIONS`, + `_ENV_TOKEN_RE`, FORBIDDEN-импорт, секрет-эвристика, «команды только в SECTIONS[1:]»), + `tests/test_bootstrap_script.py` (`STDLIB_ALLOWED`, `FORBIDDEN_DELETE_NEEDLES`, + `FORBIDDEN_STATUS_NEEDLES`), `docker-compose.yml` (`network_mode: host` ×3), + `docs/deployment/LITE_SETUP.md` (§2–§13), `.env.example` (канон имён ключей) diff --git a/docs/work-items/ORCH-104/07-infra-requirements.md b/docs/work-items/ORCH-104/07-infra-requirements.md new file mode 100644 index 0000000..3e35a28 --- /dev/null +++ b/docs/work-items/ORCH-104/07-infra-requirements.md @@ -0,0 +1,65 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-104 — Установочный скрипт Lite-тиража (интерактивный installer) + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable: топология **нашего** прода не меняется — файл создан для +> аудитопригодности с явными `N/A` и фиксацией предусловий **целевого хоста заказчика** +> (на котором скрипт исполняется). Решения — `06-adr/ADR-001-setup-lite-interactive-installer.md`. + +## I-1. Топология / окружения + +- **Наш прод/staging (mva154):** `N/A` — задача scripts+docs+tests; контейнеры, порты, сеть, + тома, compose-файлы не меняются. В нашем контуре скрипт инертен (активация — только явный + запуск человеком; запуска на mva154 не предполагается вовсе). +- **Целевой хост заказчика (где скрипт работает):** контур Lite без изменений — Linux x86_64, + Docker Engine + Compose v2, git, python3, node (LITE_SETUP §2). Скрипт сам **сканирует** эти + предусловия и предлагает доустановить недостающее (per-package consent, ADR D4); результат + установки — ровно базовый Lite-контур: `orchestrator` + `orchestrator-watchdog` + (`orchestrator-staging` НЕ поднимается — строго за `profiles: [staging]`). +- **Discovery-предположение:** Plane/Gitea заказчика обнаруживаются только как + docker-инсталляции локального хоста (image-префиксы `makeplane/*`, `gitea/gitea*`, + `docker.gitea.com/gitea*` — ADR D5); native/k8s-инсталляции — честный ручной ввод URL + (не FAIL). Сетевая достижимость Plane/Gitea с хоста оркестратора — предусловие заказчика + (BRD §6). + +## I-2. Переменные окружения / секреты + +- **Новых ключей `Settings`/`.env.example`/`.env.watchdog.example` НЕТ** (TRZ §8) — каноны + только читаются как шаблоны рендера. +- Скрипт **пишет** целевые `.env`/`.env.watchdog` на хосте заказчика: от канонов-example, + права `600`, первая строка — маркер managed-файла `# managed by scripts/setup_lite.py + (ORCH-104)` (ADR D6: немаркированный существующий файл → отказ exit 2 без `--force`). +- Секреты: webhook-секреты — только свежий выпуск кирпичом `gen_secrets.py` (субпроцесс); + внешние токены (Plane/Gitea/Telegram ×2) — скрытый ввод оператора с немедленной верификацией; + значения нигде не печатаются и не логируются (NFR-3). Боевые секреты исходного хоста не + используются даже как подсказки-дефолты (stateless, LITE_SETUP §12). +- Headless-prefill: переменные окружения процесса с каноническими именами ключей + явный + `--yes` (ADR D10); answers-file не вводится. + +## I-3. Деплой / рестарт + +- **Рестарт нашего прод-контейнера НЕ требуется и не выполняется** (self-hosting инвариант): + изменение — файлы `scripts/` + `tests/` + docs; выкат задачи — штатный конвейер + (deploy-staging 8501 → `Confirm Deploy`). +- На **хосте заказчика** скрипт выполняет `docker compose up -d --build` и управляемый + `--force-recreate orchestrator` **только собственного свежеподнятого** контура, после + проверки тихого окна `GET /queue` (ADR D11); чужие/уже бегущие контейнеры не трогаются без + согласия (NFR-7); delete-операций нет вообще. + +## I-4. CI/CD + +- `.gitea/workflows/` — **без изменений**. Новый структурный/unit тест-модуль + `tests/test_setup_lite_script.py` + аддитивный тест в `tests/test_lite_setup_doc.py` + попадают в существующий прогон `pytest tests/ -q` (детерминированы: без TTY/сети/docker — + инжектируемый I/O, моки, tmp_path; NFR-5). +- Инфра-предусловий прод-образа нет (скрипт не входит в рантайм-контейнер как зависимость; + pip-зависимости не добавляются — stdlib-only). diff --git a/docs/work-items/ORCH-104/10-tech-risks.md b/docs/work-items/ORCH-104/10-tech-risks.md new file mode 100644 index 0000000..48baae1 --- /dev/null +++ b/docs/work-items/ORCH-104/10-tech-risks.md @@ -0,0 +1,47 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-11 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-104 — Установочный скрипт Lite-тиража (интерактивный installer) + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации и эксплуатации интерактивного +> installer'а; решения — `06-adr/ADR-001-setup-lite-interactive-installer.md` (D1…D12). +> Развивает реестр BRD §8 (R-1…R-6). + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Случайный запуск на хосте с живым продом** (R-1): дефолт-режим — `apply`-wizard, не `plan` | Низ. | Выс. | Структурные гарантии D2: фаза 0 `apply` ≡ `plan` (read-only), ранний guard НЕмаркированного `.env` → exit 2 ДО первого вопроса (D6), per-action consent с дефолт-ответом «нет», non-TTY → exit 2 до мутаций (D10); `--force` требует явного набора + согласия; на нашем mva154 запуск не предполагается вовсе | +| TR-2 | **Оферы установки пакетов** (R-2) — вмешательство в систему заказчика; нативные репо могут дать не то (compose v1, старый docker) | Сред. | Сред. | Per-package consent с печатью точной команды ДО исполнения; закрытый набор менеджеров apt-get/dnf/yum/zypper; **re-check фактом после установки** (в т.ч. `docker compose version` → v2), не сошлось → MANUAL с официальной инструкцией; нет менеджера/sudo/отказ → честный MANUAL (D4) | +| TR-3 | **SQL-вставка webhook в чужую Plane-БД** (R-3): инвазивная мутация прод-инсталляции заказчика; пользовательский ввод в SQL | Сред. | Выс. | Path A (UI) — дефолт-рекомендация; Path Б — только при 5 предусловиях D8 (docker-Plane + подтверждённый пользователем postgres-контейнер + согласие с показом SQL + идемпотентный INSERT + обязательная пост-верификация SELECT); только INSERT/SELECT (no UPDATE/DELETE); секрет/пароль — stdin/env, не argv; валидация slug `^[a-z0-9-]+$` (анти-инъекция); любой сбой → fail-safe в Path A | +| TR-4 | **Дрейф скрипта от канона LITE_SETUP** (R-4): два источника маршрута расходятся | Сред. | Сред. | Канон-знания не дублируются (кирпичи субпроцессами, статусы — за `plane_sync` через onboarding, smoke — ссылкой на §11); footer-норматив «док + скрипт в одном PR» (FR-11/adr-0040); анти-дрейф: упоминание скрипта в доке, env-ключи ⊂ `.env.example`, зеркала needle-наборов (D12) | +| TR-5 | **Зависание/недетерминизм в non-TTY** (R-5): CI/обвязка заказчика виснет на ожидании ввода | Низ. | Сред. | `isatty`-гейт: `apply` без `--yes` в non-TTY → немедленный exit 2 с подсказкой; headless — только env-prefill + явный `--yes`; `plan`/`verify` — полноценные non-TTY режимы; весь интерактив за инжектируемым I/O (детерминированные тесты TC-12) (D10) | +| TR-6 | **Ложноположительный discovery** (R-6): чужой контейнер опознан как Plane/Gitea; неверный URL уезжает в конфиг | Низ. | Сред. | Опознание строго по image-префиксам (не по именам); выбор всегда за пользователем + пункт «ввести вручную» всегда; токен-верификация выбранного URL обязательна (FR-4) — неверный кандидат не пройдёт `collect` (D5/D9) | +| TR-7 | **Утечка секретов** через транскрипт wizard'а / отчёт / файлы | Низ. | Выс. | Скрытый ввод (getpass-класс); значения не печатаются и не логируются (только имена ключей); итоговая таблица без значений; live-файлы `600`; файл-отчёт прогона сознательно НЕ вводится; анти-дрейф: тест транскрипта (TC-11) + секрет-эвристика на fenced-блоках дока (D3/D6/D10) | +| TR-8 | **Коллизия resume ⟷ guard существующего `.env`**: после manual-step повторный запуск отбивается собственным артефактом ЛИБО guard ослабляется и перетирает чужой конфиг | Сред. | Выс. | Маркер managed-файла (первая строка): без маркера → отказ exit 2 (чужой конфиг), с маркером → resume-ensure без перетирания значений (D6); семантика покрыта unit-тестами (TC-03/TC-14) | +| TR-9 | **Разнообразие хостов**: экзотический дистрибутив/пакетный менеджер/нестандартный docker — скан даёт неверный вердикт | Сред. | Низ. | Вердикты — чистые функции от фактов (тестируемы на фикстурах); неопределимое → честный MANUAL с generic-командами и ссылкой на § LITE_SETUP (никогда не падение); не-Linux/не-x86_64 → WARN «вне контура» (рамка Lite не расширяется) (D4) | +| TR-10 | **Дрейф интерфейсов кирпичей** (`gen_secrets.py`/`onboard_project.py`): смена флагов/формата отчёта молча ломает installer | Низ. | Сред. | Контракты кирпичей закреплены их собственными тестами (`test_secrets_gen`, onboarding-тесты); builder аргументов — чистая функция под unit-тестом (ломается громко в CI); exit-коды кирпичей транслируются честно (2 → MANUAL, 1 → FAIL) (D11); норматив same-PR симметричен | + +## Сводный вывод + +Доминирующий класс — **эксплуатационные риски целевого хоста заказчика** (TR-2/TR-3/TR-9): +наш прод они не затрагивают и гасятся per-action consent'ом, контрактом manual-checkpoint, +честными MANUAL-деградациями и обязательной верификацией каждого ввода. Специфика ORCH-104 +против предшественников — два новых внимательных места: **дефолт-режим `apply`** (TR-1; закрыт +структурными гарантиями D2, а не дисциплиной) и **маркер managed-файла** (TR-8; единственный +механизм, примиряющий идемпотентный resume с защитой чужого `.env`). + +Рисков для прод-конвейера self-hosting **нет по построению** (BR-8: рантайм байт-в-байт, +kill-switch не нужен — активация только явным запуском оператора; полный регресс и все +существующие анти-дрейф тесты остаются зелёными, пиннинг «13 разделов» LITE_SETUP не меняется). +Эскалация `arch:major-change` не требуется: новых стадий/компонентов рантайма/смены БД нет — +задача целиком в слое дистрибуции (паттерн ORCH-101/102/103). Возврат в анализ не требуется: +ТЗ выполнимо без нарушения принципов архитектуры. diff --git a/docs/work-items/ORCH-104/12-review.md b/docs/work-items/ORCH-104/12-review.md new file mode 100644 index 0000000..06ce462 --- /dev/null +++ b/docs/work-items/ORCH-104/12-review.md @@ -0,0 +1,146 @@ +--- +verdict: REQUEST_CHANGES +work_item: ORCH-104 +stage: review +author_agent: reviewer +status: changes-requested +created_at: 2026-06-12 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-104 +version: 1 +--- + +# Review ORCH-104 — Установочный скрипт Lite-тиража (`scripts/setup_lite.py`) + +## Summary + +Задача — **scripts + docs + tests**: исполняемый интерактивный installer Lite-тиража поверх +канона `LITE_SETUP.md`. Качество **чистых функций** и **документации** — высокое: рантайм +`src/**`/`docker-compose.yml`/`Dockerfile`/`.env*.example` байт-в-байт не тронуты (AC-13 ✓), +оба тест-файла зелёные (74 passed), `LITE_SETUP.md` §1.1 + footer-норматив + CHANGELOG + +витрина `docs/overview/` + `docs/architecture/README.md` + ADR (локальный и сквозной +`adr-0040`) обновлены в том же PR (AC-14 ✓, документация — golden source соблюдён). + +**Однако** обнаружен блокер: **решающая логика реализована как чистые функции, но НЕ +подключена к исполняемому пути `apply`** — `step_collect` пустой, `io.ask(...)` не вызывается +ни разу, результаты discovery и флаги `--project-*` не попадают в `answers`, машинные guard-ы +(C-1 Telegram, §6.4 branch protection, когерентность портов, Path Б webhook) не вызываются из +шагов. Реальный прогон `apply` НЕ собирает ключи и НЕ выполняет установку, ради которой задача +заведена. Это «не реализовано требование ТЗ» (FR-1/FR-3/FR-4/FR-5/FR-7/FR-9) и нарушение +собственного ADR D3/D5/D9 → `REQUEST_CHANGES`. + +Анти-доказательство «доки точны»: §1.1 LITE_SETUP и CHANGELOG утверждают, что скрипт +«запрашивает обязательные ключи в момент установки с немедленной верификацией» — исполняемый +код этого не делает (документация описывает спецификацию, а не текущую реализацию). + +## Findings + +### P0 — Blocker + +- [ ] **Интерактивный сбор ключей не подключён — `apply` не функционален end-to-end.** + `step_collect` (`scripts/setup_lite.py:1061-1066`) — пустая заглушка: тело делает только + `ctx.setdefault("answers", {})` и `return "ok"`, хотя docstring обещает «значения собираются + в ctx['answers']». Метод `IO.ask` (`:181`) с верификацией/env-prefill/лимитом попыток + реализован и протестирован (TC-10…12), но в самом скрипте `io.ask(...)` **не вызывается + нигде** (подтверждено grep). Следствие: при реальном `apply` ни один из + `MANDATORY_NEW_HOST_KEYS` (Plane URL/токен, Gitea, Telegram, хост-параметры, порты) не + собирается → `step_render_env` рендерит `.env`/`.env.watchdog` только со свежими + webhook-секретами поверх **плейсхолдеров** `.env.example`, а `build_onboard_args` получает + пустые `--name/--repo/--prefix`. Нарушены **FR-4** (`02-trz.md` §3, «немедленная верификация + каждого введённого значения») и **ADR D3** (шаг 4 `collect` = «интерактивный сбор ключей с + немедленной верификацией»). Headless-путь тоже мёртв: env-prefill живёт внутри `ask`, который + не зовётся. + +- [ ] **Результаты discovery и `--project-*` не доходят до `answers` (FR-3/FR-9 не + реализованы).** `step_discovery` пишет `ctx["chosen_plane"]/["chosen_gitea"]` + (`:1056-1057`), но эти значения **нигде не читаются** — кандидаты URL вычисляются и + отбрасываются (FR-3 «префилл кандидатов URL» не реализован). Флаги `--project-name/-repo/ + -prefix/...` (`:1264-1271`) парсятся, но **не копируются** в `ctx["answers"]`; + `build_onboard_args` (`:722-742`) читает `answers.get("project_name")` и т.д., которые + всегда пусты → `onboard_project.py` вызывается с пустыми параметрами проекта (FR-9 / AC-10 + в исполняемом пути не выполняется). + +- [ ] **Машинные guard-ы ТЗ не вызываются из шагов (FR-5/FR-7 не реализованы end-to-end).** + Подтверждено grep: `telegram_c1_verdict` (C-1 ORCH-100), `port_overrides` (когерентная + тройка), `staging_port_ok` (staging≠prod fail-closed), `next_free_port` (busy-check + альтернатива) — **не вызываются** нигде в скрипте. `step_gitea_guards` (`:1103-1115`) читает + `ctx.get("gitea_bp_status")`/`ctx.get("gitea_branch_protections")`, которые **никогда не + устанавливаются** → §6.4 branch-protection guard всегда возвращает `"ok"` (никогда не + срабатывает). `step_plane_webhook` (`:1091-1100`) уходит в Path Б только при `ctx.get("psql")` + и `answers.get("plane_db_container")` — оба **никогда не устанавливаются** → Path Б + физически недостижим. Нарушены **FR-5** (`02-trz.md` §3), **FR-7** (C-1, §6.4) и **ADR D9**: + guard-ы заявлены «машинными», но в рантайме мертвы. + +### P1 — Must fix + +- [ ] **Контракт идемпотентности/resume (check→ensure) не реализован на уровне движка.** Все + `APPLY_STEPS` используют `_always_run` (`:998-999`, реестр `:1214-1225`), который всегда + возвращает `False` → ни один продакшн-шаг никогда не даёт `"skip"`. Заявленный **AC-1** + («на уже выполненном хосте шаги дают каскад skip; повторный запуск не перевыполняет + мутации») и **ADR D3** («каждый шаг сперва проверяет „уже сделано?“ … при PASS пропускается») + не выполнены: повторный `apply` заново гоняет `gen_secrets`, `docker compose up -d --build` + и `onboard_project.py plan/apply/verify`. Сам `run_steps` skip поддерживает (TC-02/03 на + фейковом check), но реальные шаги его не используют. + +- [ ] **Нет интеграционного теста `run_apply` — зелёный suite маскирует P0.** + `tests/test_setup_lite_script.py` тщательно покрывает чистые функции **в изоляции** (вердикты, + discovery, `port_overrides`, `build_onboard_args`, exit-контракт), но **ни один тест не + проверяет, что `run_apply`/`step_collect` действительно собирают ключи и протягивают их в + `answers`/render/onboard**. Именно поэтому полностью оторванная проводка (P0) проходит при + 74 passed. Ось «тесты содержательные»: нужен сценарный тест шага collect → render → onboard + на инжектируемом I/O (инфраструктура `IO` для этого уже есть, D10). + +### P2 — Should fix + +- [ ] **Порт health-чека захардкожен `DEFAULT_PROD_PORT` (8500), игнорирует override + прод-порта.** `step_up` (`:1134` `port = DEFAULT_PROD_PORT`) и `run_verify` (`:1337`) бьют + в 8500 независимо от выбранного прод-порта (FR-5). После починки сбора порта это даст ложный + FAIL health на не-дефолтном порте; брать порт из собранного `ORCH_DEPLOY_PROD_TARGET_PORT`. + +- [ ] **`url` подставляется в SQL без валидации/экранирования (расхождение с ADR D8).** + `build_webhook_insert_sql` (`:607-619`) и `count_sql` (`:630-631`) интерполируют + `answers["orchestrator_public_url"]` в текст SQL напрямую; ADR D8 заявляет «slug и прочие + подстановки валидируются … анти-SQL-инъекция на пользовательском вводе». Импакт низкий (своя + БД заказчика, путь Path Б сейчас недостижим), но при включении Path Б — валидировать `url` + (как `valid_slug`) или передавать psql-переменной. + +- [ ] **`run_verify` вызывает `stateless_verdict(queue)` без `own_prefixes` (`:1347`)** → после + онбординга проекта заказчика его собственные задачи в `/queue` будут помечены «чужими» и + `verify` даст ложный FAIL. Протянуть префикс проекта из конфигурации/реестра. + +## Документация + +**Статус: обновлена в том же PR (ось документации — PASS).** +- `docs/deployment/LITE_SETUP.md` — добавлен `### 1.1. Быстрый путь: setup_lite.py` (пиннинг + «13 разделов в порядке» цел, `test_lite_setup_doc.py` зелёный) + footer-норматив сопровождения + расширен «обнови док **и** `scripts/setup_lite.py` в том же PR» (FR-11/D12 ✓). +- `CHANGELOG.md` — запись `feat:` (ORCH-104) есть. +- Витрина системы `docs/overview/README.md` (маршрут «Развернуть у себя») и + `docs/architecture/README.md` (блок Type A) дополнены — правило агентов №2 / ORCH-011 ✓. +- Паспорт `CLAUDE.md` — секция ORCH-104 добавлена; ADR `06-adr/ADR-001-…` + сквозной + `adr-0040-lite-interactive-installer.md` присутствуют. + +> Замечание (учесть при доработке, НЕ блокер само по себе): после починки P0 проверить, что +> §1.1 / CHANGELOG не «опережают» реализацию (сейчас формулировка «запрашивает обязательные +> ключи … с немедленной верификацией» соответствует ТЗ, но не текущему коду). Доводка кода до +> спецификации это снимает. + +## Что проверено и в порядке + +- **AC-13 (рантайм байт-в-байт):** `git diff origin/main...HEAD` по `src/**`, + `docker-compose.yml`, `Dockerfile`, `.env.example`, `.env.watchdog.example` — пусто. ✓ +- **Гигиена скрипта (AC-12):** stdlib-only, без `src.*`, no-delete, секреты через `getpass` и + не печатаются, кирпичи `gen_secrets.py`/`onboard_project.py` — субпроцессами; структурные + тесты TC-24/25 зелёные. ✓ (замечание: гигиена соблюдена, но функциональная проводка — нет). +- **Тесты:** `pytest tests/test_setup_lite_script.py tests/test_lite_setup_doc.py -q` → + 74 passed. ✓ (зелёные, но без интеграционного покрытия — см. P1). + +## Вывод + +Чистые функции, документация и инварианты рантайма — на эталонном уровне. Но исполняемый +`apply` не делает того, ради чего задача заведена: интерактивный сбор/верификация ключей, +префилл из discovery, протяжка параметров проекта и срабатывание машинных guard-ов не +подключены к step-движку. Это P0 → **REQUEST_CHANGES**. После подключения чистых функций к +`step_collect`/`step_discovery`/`step_*-guards` (и добавления интеграционного теста `run_apply`) +артефакт будет соответствовать ТЗ и ADR. diff --git a/scripts/setup_lite.py b/scripts/setup_lite.py new file mode 100644 index 0000000..85ea158 --- /dev/null +++ b/scripts/setup_lite.py @@ -0,0 +1,1379 @@ +#!/usr/bin/env python3 +"""setup_lite.py — интерактивный installer Lite-тиража (ORCH-104). + +Один операторский CLI, автоматизирующий маршрут docs/deployment/LITE_SETUP.md +§2–§12 для внешнего оператора/заказчика: скан предусловий хоста с офером +доустановки → discovery docker-инсталляций Plane/Gitea → интерактивный сбор +обязательных ключей с немедленной верификацией → автодетект хост-параметров и +когерентность портов → сборка .env/.env.watchdog от канонов → webhook Plane → +guard-ы Gitea → подъём ровно орк+watchdog → регистрация проекта строго кирпичом +onboard_project.py → итоговый отчёт PASS/FAIL/MANUAL. + +Режимы (ADR-001 D2, семейная лексика ORCH-009/103): + plan — строгий read-only: скан предусловий + discovery + автодетект + план + шагов; ноль мутаций ФС/docker/сети. exit 0 (блокеров нет) / 2 (есть). + apply — ДЕФОЛТ-wizard (бизнес-цель «одна команда»). Безопасность дефолта — + структурно, не режимом: фаза 0 ≡ plan (read-only), ранний guard + чужого .env (маркер managed-файла), per-action consent на каждую + мутацию, non-TTY без --yes → exit 2 ДО любой мутации. + verify — read-only пост-проверка (/health + /queue + /metrics, состав «ровно + орк+watchdog», stateless-чистота §12). + +Exit-коды (контракт FR-1): 0 — все шаги PASS; 2 — остановка на manual-step / +незавершённое предусловие; 1 — ошибка. + +Гарантии (NFR-1/3, ADR-001): + * python stdlib-only; модули платформы (src.*) не импортируются — канон-знания + (секреты, статусы/лейблы/репо/вебхуки) идут ТОЛЬКО субпроцессами кирпичей + scripts/gen_secrets.py и scripts/onboard_project.py; + * значения секретов НИКОГДА не печатаются (скрытый ввод, только имена ключей); + * delete-операций НЕТ ВООБЩЕ: лечение — всегда инструкция (no-delete); + * существующий немаркированный .env/.env.watchdog не перетирается без --force; + * никаких операций с веткой main / force-push; рестарт — только собственного + свежеподнятого контура (никогда чужие/боевые контейнеры). + +Канон маршрута — docs/deployment/LITE_SETUP.md (скрипт = рекомендованный быстрый +путь §1.1; ручной маршрут §2–§13 сохранён как fallback для MANUAL-шагов). + +Запуск — из корня чекаута репо orchestrator на целевом хосте заказчика: + python3 scripts/setup_lite.py # интерактивная установка (apply) + python3 scripts/setup_lite.py plan # read-only диагностика + python3 scripts/setup_lite.py verify # read-only пост-проверка +""" + +import argparse +import getpass +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +import uuid +from dataclasses import dataclass, field + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +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") + +# Канон маршрута — каждый шаг ссылается на соответствующий § этого дока (NFR-4). +DOC = "docs/deployment/LITE_SETUP.md" + +# Маркер managed-файла — ключ к resume и к guard'у чужого .env (ADR-001 D6). +MANAGED_MARKER = "# managed by scripts/setup_lite.py (ORCH-104)" + +EXIT_OK = 0 +EXIT_MANUAL = 2 +EXIT_ERROR = 1 + +# Дефолтные порты платформы (LITE_SETUP §2.5); busy-check предлагает альтернативу. +DEFAULT_PROD_PORT = 8500 +DEFAULT_STAGING_PORT = 8501 + +# Состав базового Lite-контура (FR-8): ровно эти два сервиса по дефолту. +LITE_SERVICES = ("orchestrator", "orchestrator-watchdog") + +# Discovery (ADR-001 D5): опознание инсталляций СТРОГО по префиксам образов, +# имена контейнеров/проектов как признак НЕ используются (анти-ложноположительность). +PLANE_IMAGE_NEEDLES = ("makeplane/",) +GITEA_IMAGE_NEEDLES = ("gitea/gitea", "docker.gitea.com/gitea") +POSTGRES_IMAGE_NEEDLES = ("postgres",) + +# Закрытый набор пакетных менеджеров (ADR-001 D4), детект по наличию бинаря. +PACKAGE_MANAGERS = ("apt-get", "dnf", "yum", "zypper") + +_INSTALL_TEMPLATES = { + "apt-get": "sudo apt-get update && sudo apt-get install -y {pkg}", + "dnf": "sudo dnf install -y {pkg}", + "yum": "sudo yum install -y {pkg}", + "zypper": "sudo zypper install -y {pkg}", +} + +# Имена пакетов per-менеджер (карта-константа, ADR-001 D4); "*" — generic. +_PACKAGE_NAMES = { + "docker": { + "apt-get": "docker.io docker-compose-plugin", + "dnf": "docker docker-compose-plugin", + "yum": "docker docker-compose-plugin", + "zypper": "docker docker-compose", + }, + "git": {"*": "git"}, + "python3": {"*": "python3"}, + "node": { + "apt-get": "nodejs npm", + "dnf": "nodejs npm", + "yum": "nodejs npm", + "zypper": "nodejs npm", + }, +} + +# Карта обязательных ключей нового хоста (§4.2 LITE_SETUP); подмножество канона +# .env.example — держит анти-дрейф тест (TC-13d). Webhook-секреты — отдельно. +MANDATORY_NEW_HOST_KEYS = ( + # Plane + "ORCH_PLANE_API_URL", "ORCH_PLANE_WEB_URL", "ORCH_PLANE_WORKSPACE_SLUG", + "ORCH_PLANE_API_TOKEN", + # Gitea + "ORCH_GITEA_URL", "ORCH_GITEA_PUBLIC_URL", "ORCH_GITEA_OWNER", "ORCH_GITEA_TOKEN", + # webhook-секреты (кирпич gen_secrets.py) + "ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET", + # Telegram + "ORCH_TELEGRAM_BOT_TOKEN", "ORCH_TELEGRAM_CHAT_ID", + # реестр проектов + "ORCH_PROJECTS_JSON", + # хост-параметры + "ORCH_AGENT_HOME_DIR", "ORCH_HOST_REPOS_DIR", "ORCH_HOST_CLAUDE_DIR", + "ORCH_HOST_CLAUDE_JSON", "ORCH_HOST_SSH_DIR", "ORCH_HOST_CLAUDE_CODE_DIR", + "ORCH_HOST_NODE_BIN", "ORCH_RUN_UID", "ORCH_RUN_GID", "ORCH_DOCKER_GID", + "ORCH_DEPLOY_HOST_REPO_PATH", + # порты (когерентная тройка + staging) + "ORCH_DEPLOY_PROD_TARGET_PORT", "WATCHDOG_METRICS_URL", + "ORCH_POST_DEPLOY_BASE_URL", "ORCH_STAGING_PORT", +) + +# Webhook-секреты — выпускает ТОЛЬКО кирпич gen_secrets.py (канон-знание). +WEBHOOK_SECRET_KEYS = ("ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET") + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +_PUBLISHED_PORT_RE = re.compile(r"(?:0\.0\.0\.0|127\.0\.0\.1|\[::\]|):(\d+)->") +_WORK_ITEM_RE = re.compile(r"\b([A-Z][A-Z0-9]*)-(\d+)\b") + + +class ManualStop(Exception): + """Остановка на manual-step / незавершённом предусловии → exit 2.""" + + +class SetupError(Exception): + """Невосстановимая ошибка шага → exit 1.""" + + +# --------------------------------------------------------------------------- # +# Инжектируемый I/O (ADR-001 D10): unit-тестируемость без реального TTY. +# --------------------------------------------------------------------------- # + +@dataclass +class IO: + """Источники ввода/вывода как параметры — wizard тестируется скриптованными + ответами без TTY (NFR-5). Значение секрета НИКОГДА не пишется в ``say_fn``.""" + + input_fn: object # callable(prompt) -> str (видимый ввод) + getpass_fn: object # callable(prompt) -> str (скрытый ввод) + say_fn: object # callable(str) -> None + is_tty: bool = True + env: dict = field(default_factory=dict) + yes: bool = False + + def say(self, msg: str) -> None: + """Печать строки прогресса (значения секретов сюда НЕ передаются, NFR-3).""" + self.say_fn(msg) + + def ask(self, key, prompt, secret=False, verify=None, max_tries=3, default=None): + """Запрос значения ключа ``key`` с опциональной НЕМЕДЛЕННОЙ верификацией + ``verify(value) -> (ok, hint)`` (FR-4). Порядок источника значения: + env-prefill (то же каноническое имя ключа, D10) → default в non-TTY → + интерактивный ввод. Неуспех verify → re-prompt с диагнозом, лимит + ``max_tries`` → ManualStop (не бесконечный цикл). Без TTY и без значения — + честный ManualStop (никаких зависаний). Секрет идёт через ``getpass_fn`` и + НЕ печатается.""" + last_hint = "" + for attempt in range(1, max_tries + 1): + value = None + if attempt == 1: + env_val = (self.env.get(key) or "").strip() + if env_val: + value = env_val + if value is None: + if not self.is_tty: + if default is not None: + value = default + else: + raise ManualStop( + f"{key}: нет TTY и нет значения (env-prefill {key} + --yes)" + ) + else: + reader = self.getpass_fn if secret else self.input_fn + label = key if secret else (f"{key} [{default}]" if default else key) + raw = (reader(f" {prompt} ({label}): ") or "").strip() + value = raw or (default if default is not None else "") + if verify is None: + return value + ok, hint = verify(value) + if ok: + return value + last_hint = hint + self.say(f" ✗ {key}: {hint}") + if not self.is_tty: + break + raise ManualStop(f"{key}: не прошло верификацию ({last_hint})") + + def consent(self, action) -> bool: + """Per-action consent (FR-1). ``--yes`` = заранее данное согласие + (headless). non-TTY без ``--yes`` → ManualStop (честный отказ, не + зависание). В TTY читает y/N.""" + if self.yes: + return True + if not self.is_tty: + raise ManualStop(f"consent «{action}» требует TTY или --yes (D10)") + answer = (self.input_fn(f" выполнить «{action}»? [y/N]: ") or "").strip().lower() + return answer in ("y", "yes", "д", "да") + + +def _real_io(args: argparse.Namespace) -> IO: + return IO( + input_fn=input, + getpass_fn=getpass.getpass, + say_fn=lambda s: print(s, flush=True), + is_tty=sys.stdin.isatty(), + env=dict(os.environ), + yes=bool(getattr(args, "yes", False)), + ) + + +# --------------------------------------------------------------------------- # +# Чистые функции (unit-тесты — tests/test_setup_lite_script.py) +# --------------------------------------------------------------------------- # + +def parse_env(text: str) -> dict: + """``KEY=value``-строки текста → словарь (комментарии/пустые — мимо).""" + out: dict = {} + for line in (text or "").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, marker: str = MANAGED_MARKER) -> str: + """Рендер env-файла от канона-example (ORCH-101: дефолт = боевое значение — + записываются только собранные отличия). Первая строка — фиксированный + ``marker`` managed-файла (ADR-001 D6: основа resume и guard'а).""" + used: set = set() + lines: list = [marker] + 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("# --- setup_lite.py (ORCH-104): дозаполненные ключи ---") + for key in extra: + lines.append(f"{key}={overrides[key]}") + return "\n".join(lines) + "\n" + + +def _rerender_existing(text: str, values: dict) -> str: + """Перерендер существующего managed-файла: каждая строка сохраняется (включая + маркер и комментарии), ``KEY=`` строки получают значения ``values``, недостающие + ключи дописываются. Маркер НЕ дублируется (текст уже им начинается).""" + used: set = set() + out: list = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in values: + out.append(f"{key}={values[key]}") + used.add(key) + continue + out.append(line) + extra = [k for k in values if k not in used] + if extra: + out.append("") + for key in extra: + out.append(f"{key}={values[key]}") + return "\n".join(out) + "\n" + + +def env_file_state(text) -> str: + """Состояние env-файла по содержимому (ADR-001 D6): ``"absent"`` (None) | + ``"managed"`` (первая строка — наш маркер) | ``"foreign"`` (чужой/живой).""" + if text is None: + return "absent" + first = text.splitlines()[0].strip() if text.strip() else "" + return "managed" if first == MANAGED_MARKER else "foreign" + + +def port_overrides(prod_port) -> dict: + """Когерентная тройка прод-порта одной функцией (ADR-001 D7): рассинхрон + структурно невозможен (ловушка §2.5/§4.2 закрывается кодом, не дисциплиной).""" + p = str(prod_port) + return { + "ORCH_DEPLOY_PROD_TARGET_PORT": p, + "WATCHDOG_METRICS_URL": f"http://127.0.0.1:{p}/metrics", + "ORCH_POST_DEPLOY_BASE_URL": f"http://localhost:{p}", + } + + +def next_free_port(start, busy=None) -> int: + """Первый свободный порт начиная со ``start`` (busy-check инжектируется).""" + is_busy = busy or _port_busy + port = int(start) + while is_busy(port): + port += 1 + return port + + +def staging_port_ok(staging_port, prod_port) -> bool: + """``ORCH_STAGING_PORT == прод-порт`` → fail-closed (инвариант ORCH-058/101).""" + return str(staging_port) != str(prod_port) + + +def split_overrides(answers: dict) -> tuple: + """Раскладка собранных значений по файлам-носителям (§4.3): ключи + ``WATCHDOG_*`` — ТОЛЬКО в ``.env.watchdog`` (в ``.env`` для sidecar инертны), + остальные — в корневой ``.env``. Возвращает ``(root_overrides, watchdog_overrides)``.""" + root: dict = {} + watchdog: dict = {} + for key, value in answers.items(): + if key.startswith("WATCHDOG_"): + watchdog[key] = value + else: + root[key] = value + return root, watchdog + + +def prompt_defaults(facts: dict) -> dict: + """Подсказки-дефолты промптов — ТОЛЬКО из автодетекта/канона (FR-5/TC-15): ни + одного боевого литерала исходного хоста (stateless).""" + home = facts.get("home", "") or "" + return { + "ORCH_RUN_UID": str(facts.get("uid", "")), + "ORCH_RUN_GID": str(facts.get("gid", "")), + "ORCH_DOCKER_GID": str(facts.get("docker_gid", "")), + "ORCH_HOST_NODE_BIN": facts.get("node_bin", ""), + "ORCH_HOST_CLAUDE_CODE_DIR": facts.get("claude_code_dir", ""), + "ORCH_AGENT_HOME_DIR": home, + "ORCH_HOST_CLAUDE_DIR": os.path.join(home, ".claude") if home else "", + "ORCH_HOST_CLAUDE_JSON": os.path.join(home, ".claude.json") if home else "", + "ORCH_HOST_REPOS_DIR": facts.get("repos_dir", ""), + "ORCH_HOST_SSH_DIR": facts.get("ssh_dir", ""), + "ORCH_DEPLOY_HOST_REPO_PATH": facts.get("repo_root", ""), + } + + +def detect_pkg_manager(which=None) -> str | None: + """Детект пакетного менеджера по наличию бинаря (закрытый набор, в порядке + PACKAGE_MANAGERS). Неопределимый (pacman/alpine и пр.) → None → MANUAL.""" + which = which or shutil.which + for manager in PACKAGE_MANAGERS: + if which(manager): + return manager + return None + + +def prereq_install_item(label: str) -> str: + """Логический пункт установки для пункта предусловий (ADR-001 D4). docker и + compose ставятся одним пакетным набором; claude-code — отдельно.""" + low = (label or "").lower() + if "claude" in low: + return "claude-code" + if "docker" in low and "group" not in low: + return "docker" + if "compose" in low: + return "docker" + if "node" in low: + return "node" + if "git" in low: + return "git" + if "python3" in low: + return "python3" + return label + + +def install_command(manager: str | None, item: str) -> str | None: + """Точная команда установки пункта ``item`` под детектированный ``manager``. + Спец-случаи (ADR-001 D4): claude-code — npm; ssh-key — ssh-keygen. Неизвестный + менеджер/пакет → None (вызывающий выдаёт MANUAL с generic-инструкцией).""" + if item == "claude-code": + return "npm install -g @anthropic-ai/claude-code" + if item == "ssh-key": + return 'ssh-keygen -t ed25519 -f /id_ed25519 -N ""' + if manager is None: + return None + per_item = _PACKAGE_NAMES.get(item, {}) + pkg = per_item.get(manager) or per_item.get("*") + if not pkg: + return None + return _INSTALL_TEMPLATES[manager].format(pkg=pkg) + + +def manual_install_hint(item: str) -> str: + """MANUAL-инструкция при неопределимом менеджере/пакете (со ссылкой на канон — + не молчаливый пропуск, не падение).""" + return (f"{item}: пакетный менеджер не определён — установите вручную " + f"средствами вашего дистрибутива (канон — {DOC} §2)") + + +def offer_install(item: str, command: str, io: IO, runner, recheck=None) -> str: + """Офер установки пункта (ADR-001 D4): печать ТОЧНОЙ команды ДО исполнения → + per-package consent → исполнение → re-check фактом. Отказ → MANUAL (мутация не + выполнена). re-check не сошёлся → честный MANUAL (не ложный OK).""" + io.say(f" {item}: предлагаю установить командой:") + io.say(f" {command}") + if not io.consent(f"установить {item}"): + io.say(f" 🖐 {item}: MANUAL (отказ) — выполните вручную: {command}") + return "manual" + proc = runner(command) + rc = getattr(proc, "returncode", 0) if proc is not None else 0 + if rc != 0: + io.say(f" ✗ {item}: установка не удалась — выполните вручную: {command}") + return "manual" + if recheck is not None and not recheck(): + io.say(f" 🖐 {item}: установлено, но re-check не подтвердил — проверьте вручную") + return "manual" + io.say(f" ✓ {item}: установлено") + return "ok" + + +def prereq_verdicts(facts: dict) -> list: + """Вердикты предусловий хоста (FR-2) от read-only снимка фактов → + ``[(item, OK|MISSING|WARN|MANUAL, detail)]``. Ни один пункт не пропускается + молча (AC-2). Не-Linux/не-x86_64 → WARN «вне контура Lite» (не FAIL).""" + verdicts: list = [] + uname = facts.get("uname", "") or "" + if "Linux" in uname and "x86_64" in uname: + verdicts.append(("os", "OK", uname)) + else: + verdicts.append(("os", "WARN", + f"{uname or '?'} — вне контура Lite (поддержан Linux x86_64)")) + verdicts.append(("docker", "OK" if facts.get("docker") else "MISSING", "")) + verdicts.append(("compose", "OK" if facts.get("compose_v2") else "MISSING", "")) + verdicts.append(("git", "OK" if facts.get("git") else "MISSING", "")) + verdicts.append(("python3", "OK" if facts.get("python3") else "MISSING", "")) + verdicts.append(("node", "OK" if facts.get("node") else "MISSING", "")) + verdicts.append(("claude-code", "OK" if facts.get("claude_code_dir") else "MISSING", "")) + auth_ok = bool(facts.get("claude_creds_readable")) + verdicts.append(("claude-auth", "OK" if auth_ok else "MANUAL", + "" if auth_ok else "первичный логин claude CLI (§7.2)")) + verdicts.append(("docker-group", "OK" if facts.get("docker_gid") else "MISSING", "")) + repos_ok = bool(facts.get("repos_dir_owner_ok")) + verdicts.append(("repos-dir", "OK" if repos_ok else "WARN", + "" if repos_ok else "владелец каталога ≠ ORCH_RUN_UID:ORCH_RUN_GID (§2.2)")) + ssh_ok = bool(facts.get("ssh_dir") and facts.get("ssh_keys")) + verdicts.append(("ssh", "OK" if ssh_ok else "MANUAL", + "" if ssh_ok else "ssh-keygen + pubkey в Gitea (§2.4)")) + busy = facts.get("busy_ports") or [] + verdicts.append(("ports", "OK" if not busy else "WARN", + "" if not busy else f"заняты: {busy} — выберите другие (§2.5)")) + return verdicts + + +def has_blockers(verdicts) -> bool: + """Есть ли блокеры (MISSING) среди вердиктов предусловий (устранимы оферами).""" + return any(status == "MISSING" for _, status, _ in verdicts) + + +def _img_matches(image, needles) -> bool: + img = (image or "").strip() + return any(needle in img for needle in needles) + + +def _published_ports(ports_field: str) -> list: + """Published host-порты из поля ``docker ps`` ``{{.Ports}}`` (best-effort).""" + return sorted({int(m.group(1)) for m in _PUBLISHED_PORT_RE.finditer(ports_field or "")}) + + +def _installation(project: str, members: list, kind: str) -> dict: + """Сборка «инсталляции» из контейнеров одного compose-проекта (ADR-001 D5).""" + images = sorted({m.get("image", "") for m in members}) + ports: list = [] + if kind == "plane": + for m in members: + if "plane-proxy" in (m.get("image") or ""): + ports = _published_ports(m.get("ports", "")) + if ports: + break + if not ports: + for m in members: + ports = _published_ports(m.get("ports", "")) + if ports: + break + else: # gitea + for m in members: + if _img_matches(m.get("image"), GITEA_IMAGE_NEEDLES): + ports = _published_ports(m.get("ports", "")) + if ports: + break + postgres = sorted({m.get("name", "") for m in members + if _img_matches(m.get("image"), POSTGRES_IMAGE_NEEDLES)}) + return { + "kind": kind, + "project": project, + "images": images, + "url_port": ports[0] if ports else None, + "postgres_candidates": postgres, + } + + +def discover_installations(containers) -> list: + """Классификатор discovery (FR-3): перечень docker-контейнеров → ПЛОСКИЙ список + кандидатов (по одному на (compose-проект, kind)), опознанных СТРОГО по + префиксам образов. Посторонние образы в кандидаты не попадают (AC-3). Чистая + функция (без сети/docker) — источник перечня инжектируется вызывающим.""" + if not containers: + return [] + groups: dict = {} + for c in containers: + groups.setdefault(c.get("project") or c.get("name", ""), []).append(c) + out: list = [] + for project in sorted(groups): + members = groups[project] + if any(_img_matches(m.get("image"), PLANE_IMAGE_NEEDLES) for m in members): + out.append(_installation(project, members, "plane")) + if any(_img_matches(m.get("image"), GITEA_IMAGE_NEEDLES) for m in members): + out.append(_installation(project, members, "gitea")) + return out + + +def choose_installation(label: str, installs: list, io: IO): + """Выбор инсталляции пользователем (FR-3): 0 → ручной ввод (None) + подсказка + про Bundled; 1 → префилл по умолчанию (Enter подтверждает); ≥2 → нумерованный + список + выбор. Пункт «ввести вручную» (0) доступен ВСЕГДА.""" + if not installs: + io.say(f" {label}: инсталляции не найдены. Lite НЕ устанавливает Plane/Gitea; " + "нет инфраструктуры → маршрут Bundled (BUNDLED_SETUP.md). URL — вручную.") + return None + io.say(f" {label}: найдены инсталляции:") + for i, inst in enumerate(installs, 1): + io.say(f" {i}. project={inst['project']} порт={inst['url_port']} " + f"образы={inst['images']}") + io.say(" 0. ввести вручную") + default = "1" if len(installs) == 1 else None + raw = (io.input_fn(f" {label}: выбор [номер, 0=вручную]: ") or "").strip() + if not raw and default: + raw = default + if raw in ("", "0"): + return None + try: + idx = int(raw) + except ValueError: + return None + if 1 <= idx <= len(installs): + return installs[idx - 1] + return None + + +def telegram_c1_verdict(orch_token: str, watchdog_token: str) -> tuple: + """C-1 (ORCH-100) машинно: токен watchdog-бота == токену орка → отказ шага + (упавший орк не сообщит о себе своим же ботом). Различные → PASS.""" + if orch_token and watchdog_token and orch_token == watchdog_token: + return False, ( + "токен watchdog-бота совпадает с токеном орка — ЗАПРЕЩЕНО (C-1 " + "ORCH-100): watchdog обязан иметь ОТДЕЛЬНЫЙ бот" + ) + return True, "" + + +def branch_protection_verdict(status, protections) -> tuple: + """§6.4 / INV-4: непустой ``branch_protections`` на main → FAIL шага с лечением. + Скрипт правила САМ НЕ удаляет (no-delete) — только инструкция. Репо ещё не + создан (HTTP 404 / None) → не FAIL (создаст onboarding).""" + if status == 404 or protections is None: + return True, "репо ещё не создан (создаст onboarding) — проверка позже" + if protections: + count = len(protections) if isinstance(protections, (list, tuple)) else "?" + return False, ( + f"на main активны branch protection правила ({count}) — УДАЛИТЕ их " + "вручную (норматив §6.4 / симптом §13.7): protection даёт merge-актору " + "405/409 → ложные HOLD. Скрипт правила не удаляет (no-delete)." + ) + return True, "" + + +def valid_slug(slug: str) -> bool: + """Валидный Plane workspace-slug (анти-инъекция на пользовательском вводе D8).""" + return bool(_SLUG_RE.match(slug or "")) + + +def build_webhook_insert_sql(workspace_id: str, url: str, + secret_placeholder: str = ":secret") -> str: + """SQL INSERT webhook'а Plane (канон §5.4). Секрет НЕ конкатенируется в текст — + передаётся psql-переменной/stdin (ADR-001 D8); только INSERT, никаких + UPDATE/удалений (no-delete распространяется и на чужую БД).""" + return ( + "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"'{url}', true, {secret_placeholder}, true, true, false, false, " + "true, false, 'v1');" + ) + + +def plane_webhook_path_b(answers: dict, io: IO, psql) -> str: + """Webhook Plane CE, Path Б (прямой SQL) под предусловиями D8: идемпотентный + SELECT count → уже зарегистрирован → ``"skipped"``; иначе показ ТОЧНОГО SQL → + consent → INSERT → ОБЯЗАТЕЛЬНАЯ пост-верификация. Отказ / непрошедшая + пост-верификация → ``"manual"`` (fail-safe в Path A UI). ``psql(sql) -> (rc, out)``.""" + public = (answers.get("orchestrator_public_url", "") or "").rstrip("/") + url = f"{public}/webhook/plane" + slug = answers.get("ORCH_PLANE_WORKSPACE_SLUG", "") + count_sql = (f"SELECT count(*) FROM webhooks WHERE url='{url}' " + "AND deleted_at IS NULL;") + + rc, out = psql(count_sql) + if rc == 0 and out.strip().isdigit() and int(out.strip()) > 0: + io.say(" webhook Plane уже зарегистрирован — skip") + return "skipped" + + insert_preview = build_webhook_insert_sql("", url) + io.say(" Path Б — прямой SQL в Postgres инсталляции Plane. Точный SQL:") + io.say(" " + insert_preview) + if not io.consent("выполнить INSERT webhook'а в БД Plane (Path Б)"): + io.say(f" 🖐 отказ → Path A (UI): добавьте webhook вручную (канон — {DOC} §5.4)") + return "manual" + if not valid_slug(slug): + io.say(" ✗ невалидный workspace-slug → Path A (UI)") + return "manual" + wrc, wout = psql(f"SELECT id FROM workspaces WHERE slug='{slug}';") + workspace_id = wout.strip().splitlines()[0].strip() if wout.strip() else "" + if wrc == 0 and workspace_id: + irc, _ = psql(build_webhook_insert_sql(workspace_id, url)) + crc, cout = psql(count_sql) + if (irc == 0 and crc == 0 and cout.strip().isdigit() + and int(cout.strip()) > 0): + io.say(f" ✓ webhook Plane зарегистрирован: {url}") + return "ok" + io.say(f" ✗ пост-верификация не прошла → Path A (UI) (канон — {DOC} §5.4)") + return "manual" + + +def lite_composition_verdict(services) -> tuple: + """Состав поднятого контура (FR-8): ровно ``orchestrator`` + + ``orchestrator-watchdog``. Поднятый ``orchestrator-staging`` / третий сервис → + FAIL (staging обязан быть строго за profiles: [staging]).""" + running = set(services or []) + expected = set(LITE_SERVICES) + if running == expected: + return True, "" + parts: list = [] + if "orchestrator-staging" in running: + parts.append("orchestrator-staging поднят (должен быть за profiles: [staging])") + extra = sorted(running - expected - {"orchestrator-staging"}) + if extra: + parts.append("лишние сервисы: " + ", ".join(extra)) + missing = sorted(expected - running) + if missing: + parts.append("не поднялись: " + ", ".join(missing)) + return False, "; ".join(parts) or "состав контура ≠ орк+watchdog" + + +def _is_json(body) -> bool: + try: + json.loads(body) + return True + except (ValueError, TypeError): + return False + + +def health_checks(http, port) -> list: + """Контракты health (FR-8) → ``[(path, ok, detail)]``: /health → 200 + ``"status":"ok"``; /queue → штатный JSON; /metrics → 200 со + ``"schema_version": 1``. ``http(url) -> (status, body)``.""" + base = f"http://127.0.0.1:{port}" + results: list = [] + h_status, h_body = http(base + "/health") + results.append(("/health", + h_status == 200 and '"status":"ok"' in (h_body or "").replace(" ", ""), + f"HTTP {h_status}")) + q_status, q_body = http(base + "/queue") + results.append(("/queue", q_status == 200 and _is_json(q_body), f"HTTP {q_status}")) + m_status, m_body = http(base + "/metrics") + results.append(("/metrics", + m_status == 200 and '"schema_version":1' in (m_body or "").replace(" ", ""), + f"HTTP {m_status}")) + return results + + +def stateless_verdict(queue, own_prefixes=()) -> tuple: + """Stateless-чистота §12 (FR-8): в /queue нет задач ЧУЖИХ проектов (work-item с + префиксом не из ``own_prefixes`` — напр. ``ORCH-*`` / ``ET-*`` исходного хоста). + Перенесённый файл БД проявляется именно так → FAIL «пересобрать data/ с нуля».""" + blob = json.dumps(queue, ensure_ascii=False) + own = set(own_prefixes or ()) + foreign = sorted({f"{p}-{n}" for p, n in _WORK_ITEM_RE.findall(blob) if p not in own}) + if foreign: + return False, ( + f"в /queue видны задачи чужих проектов {foreign[:5]} — инстанс собран " + "не stateless: пересоберите data/ с нуля (§12)" + ) + return True, "" + + +def build_onboard_args(answers: dict, mode: str, onboard_path: str = ONBOARD, + env_file: str = ROOT_ENV) -> list: + """Детерминированная сборка аргументов кирпича onboard_project.py из собранных + ответов (AC-10) — unit-тестируемо без сети. Webhook-url строится от публичного + URL оркестратора. Собственного канона статусов/лейблов скрипт НЕ несёт.""" + public = (answers.get("orchestrator_public_url", "") or "").rstrip("/") + return [ + onboard_path, mode, + "--name", answers.get("project_name", ""), + "--description", answers.get("project_description", ""), + "--repo", answers.get("project_repo", ""), + "--prefix", answers.get("project_prefix", ""), + "--stack", answers.get("project_stack", ""), + "--test-cmd", answers.get("project_test_cmd", ""), + "--prod-port", str(answers.get("project_prod_port", "")), + "--staging-port", str(answers.get("project_staging_port", "")), + "--webhook-url", f"{public}/webhook/gitea", + "--gitea-owner", answers.get("ORCH_GITEA_OWNER", ""), + "--env-file", env_file, + "--json", + ] + + +def extract_projects_json(instructions) -> str: + """Извлечь строку реестра ``ORCH_PROJECTS_JSON=…`` из отчёта кирпича onboard + (фактический контракт: instruction-строка ``ORCH_PROJECTS_JSON=[…]``).""" + for instr in instructions or []: + if isinstance(instr, str) and "ORCH_PROJECTS_JSON=" in instr: + return instr.split("ORCH_PROJECTS_JSON=", 1)[1].strip() + return "" + + +def onboard_exit_to_status(rc: int) -> str: + """Трансляция exit-кода кирпича onboard в исход шага (AC-10): 0 → ok; + 2 (остались ручные шаги) → manual (НЕ успех); иначе → fail.""" + if rc == 0: + return "ok" + if rc == 2: + return "manual" + return "fail" + + +def exit_code_for(results: dict) -> int: + """Итоговый exit-код прогона от исходов шагов (контракт FR-1): любой manual → + 2; любой fail → 1; иначе 0.""" + values = list((results or {}).values()) + if any(v == "manual" for v in values): + return EXIT_MANUAL + if any(v == "fail" for v in values): + return EXIT_ERROR + return EXIT_OK + + +# --------------------------------------------------------------------------- # +# Тонкие обёртки subprocess/HTTP (единственные точки side-effects; в тестах — +# monkeypatch'атся целиком). +# --------------------------------------------------------------------------- # + +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, timeout: int = 600) -> subprocess.CompletedProcess: + return _run(["docker", "compose", *args], 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", int(port))) == 0 + + +def parse_docker_ps(text: str) -> list: + """Разбор tab-формата ``docker ps`` (name\\timage\\tproject\\tports) → список + контейнеров. Пустой ввод → ``[]`` (best-effort).""" + out: list = [] + for line in (text or "").splitlines(): + if not line.strip(): + continue + cols = line.split("\t") + if len(cols) < 2: + continue + out.append({ + "name": cols[0].strip(), + "image": cols[1].strip(), + "project": cols[2].strip() if len(cols) > 2 else "", + "ports": cols[3].strip() if len(cols) > 3 else "", + }) + return out + + +def list_containers(): + """Read-only перечень docker-контейнеров (FR-3). Best-effort: docker + недоступен / ошибка перечисления → ``None`` (never-block, не исключение).""" + if shutil.which("docker") is None: + return None + fmt = ("{{.Names}}\t{{.Image}}\t" + "{{.Label \"com.docker.compose.project\"}}\t{{.Ports}}") + try: + proc = _run(["docker", "ps", "--format", fmt], timeout=30) + except OSError: + return None + if getattr(proc, "returncode", 1) != 0: + return None + return parse_docker_ps(proc.stdout) + + +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) + + +def ensure_env_file(path: str, example_text: str, overrides: dict, force: bool, + io: IO) -> str: + """Идемпотентный ensure live env-файла под guard managed-маркера (ADR-001 D6): + * absent → рендер от канона + overrides; + * managed → resume-ensure (существующие НЕпустые значения сохраняются, + дозаполняются недостающие; маркер не дублируется); + * foreign → без ``--force`` отказ (ManualStop, файл байт-в-байт не тронут); + с ``--force`` — перезапись после consent. + Возвращает ``"written"`` | ``"resumed"``.""" + text = open(path, encoding="utf-8").read() if os.path.isfile(path) else None + state = env_file_state(text) + name = os.path.basename(path) + if state == "foreign": + if not force: + io.say(f" ✗ {name} существует и НЕ помечен setup_lite — отказ " + "(чужой/живой конфиг; перезапись только с --force)") + raise ManualStop(f"{name}: чужой/живой конфиг (нужен --force)") + if not io.consent(f"перезаписать существующий {name} (--force)"): + raise ManualStop(f"{name}: перезапись не подтверждена") + _write_private(path, render_env(example_text, overrides)) + io.say(f" ✓ {name} перезаписан (--force, права 600)") + return "written" + if state == "managed": + eff = parse_env(text) + for key, value in overrides.items(): + if not eff.get(key): + eff[key] = value + _write_private(path, _rerender_existing(text, eff)) + io.say(f" ↻ {name}: resume-ensure (дозаполнение, существующее не тронуто)") + return "resumed" + _write_private(path, render_env(example_text, overrides)) + io.say(f" ✓ {name} собран от канона (права 600)") + return "written" + + +def issue_webhook_secrets(gen_secrets_path: str = GEN_SECRETS) -> dict: + """Свежий выпуск webhook-секретов СТРОГО кирпичом gen_secrets.py (субпроцесс). + Боевые секреты не используются (stateless §12). Значения не печатаются.""" + with tempfile.TemporaryDirectory() as tmp: + frag = os.path.join(tmp, "fragment.env") + proc = _run([sys.executable, gen_secrets_path, "--write", frag], timeout=60) + if getattr(proc, "returncode", 1) != 0: + raise SetupError(f"gen_secrets.py отказал (rc={getattr(proc, 'returncode', '?')})") + fragment = parse_env(open(frag, encoding="utf-8").read()) + return {k: fragment[k] for k in WEBHOOK_SECRET_KEYS if fragment.get(k)} + + +def _ensure_venv() -> str: + """Host-venv для onboard-кирпича (канон ONBOARDING; кирпичу нужны httpx/pydantic).""" + if not os.path.exists(VENV_PY): + proc = _run([sys.executable, "-m", "venv", VENV_DIR], timeout=300) + if getattr(proc, "returncode", 1) != 0: + raise SetupError("python3 -m venv отказал") + probe = _run([VENV_PY, "-c", "import httpx, pydantic"], timeout=60) + if getattr(probe, "returncode", 1) != 0: + proc = _run([VENV_PY, "-m", "pip", "install", "-q", "-r", REQUIREMENTS], timeout=1200) + if getattr(proc, "returncode", 1) != 0: + raise SetupError("pip install зависимостей onboard-кирпича отказал") + return VENV_PY + + +def collect_facts(env: dict) -> dict: + """Read-only снимок предусловий хоста для prereq_verdicts (ни одной мутации).""" + uname = _run(["uname", "-sm"], timeout=10).stdout.strip() if shutil.which("uname") else "" + docker = shutil.which("docker") is not None + compose_v2 = docker and _run(["docker", "compose", "version"], timeout=30).returncode == 0 + claude_code_dir = "" + if shutil.which("npm"): + npm_root = _run(["npm", "root", "-g"], timeout=30).stdout.strip() + candidate = os.path.join(npm_root, "@anthropic-ai", "claude-code") + if npm_root and os.path.isdir(candidate): + claude_code_dir = candidate + home = os.path.expanduser("~") + creds = os.path.join(home, ".claude", ".credentials.json") + repos_dir = (env or {}).get("ORCH_HOST_REPOS_DIR", "") or os.path.join(home, "repos") + ssh_dir = (env or {}).get("ORCH_HOST_SSH_DIR", "") or os.path.join(home, ".orchestrator-ssh") + busy = [p for p in (DEFAULT_PROD_PORT, DEFAULT_STAGING_PORT) if _port_busy(p)] + return { + "uname": uname, + "docker": docker, + "compose_v2": compose_v2, + "git": shutil.which("git") is not None, + "python3": True, # мы уже исполняемся под python3 + "node": shutil.which("node") is not None, + "node_bin": shutil.which("node") or "", + "claude_code_dir": claude_code_dir, + "claude_creds_readable": os.access(creds, os.R_OK), + "docker_gid": _group_gid("docker"), + "uid": os.getuid(), + "gid": os.getgid(), + "home": home, + "repos_dir": repos_dir, + "repos_dir_owner_ok": _owner_matches(repos_dir), + "ssh_dir": ssh_dir if os.path.isdir(ssh_dir) else "", + "ssh_keys": _has_ssh_keys(ssh_dir), + "busy_ports": busy, + "pkg_manager": detect_pkg_manager(), + "repo_root": REPO_ROOT, + } + + +def _group_gid(group: str) -> str: + if not shutil.which("getent"): + return "" + proc = _run(["getent", "group", group], timeout=10) + if proc.returncode != 0: + return "" + parts = proc.stdout.strip().split(":") + return parts[2] if len(parts) >= 3 else "" + + +def _owner_matches(path: str) -> bool: + try: + st = os.stat(path) + except OSError: + return False + return st.st_uid == os.getuid() and st.st_gid == os.getgid() + + +def _has_ssh_keys(ssh_dir: str) -> bool: + try: + return any(name.endswith(".pub") or name.startswith("id_") + for name in os.listdir(ssh_dir)) + except OSError: + return False + + +# --------------------------------------------------------------------------- # +# Step-движок (ADR-001 D3): check→ensure, без state-файла (resume = повтор). +# Шаг = (name, check(ctx)->bool, ensure(ctx)->status). Истинный check → skip без +# вызова ensure; ManualStop из ensure останавливает прогон (exit 2). +# --------------------------------------------------------------------------- # + +def run_steps(steps, ctx: dict) -> dict: + """Прогон step-движка check→ensure. Истинный check → ``"skip"`` (ensure не + зовётся). ManualStop из ensure пробрасывается (остановка, resume = повтор).""" + results = ctx.setdefault("results", {}) + for name, check, ensure in steps: + if check(ctx): + results[name] = "skip" + continue + results[name] = ensure(ctx) + return results + + +def _always_run(_ctx) -> bool: + return False + + +def step_scan(ctx: dict) -> str: + """Шаг 1 (§2, §7): read-only скан предусловий + автодетект + ранний guard .env.""" + io = ctx["io"] + facts = ctx.get("facts") or collect_facts(io.env) + ctx["facts"] = facts + verdicts = prereq_verdicts(facts) + ctx["prereq_verdicts"] = verdicts + for item, status, detail in verdicts: + mark = {"OK": "✓", "MISSING": "✗", "WARN": "⚠", "MANUAL": "🖐"}.get(status, "?") + io.say(f" {mark} {item}: {status}{(' — ' + detail) if detail else ''}") + paths = ctx.get("paths", {}) + for path in (paths.get("root_env", ROOT_ENV), paths.get("watchdog_env", WATCHDOG_ENV)): + text = open(path, encoding="utf-8").read() if os.path.isfile(path) else None + if env_file_state(text) == "foreign" and not ctx["args"].force: + io.say(f" ✗ {os.path.basename(path)} существует и не помечен setup_lite — " + "ранний guard (--force для перезапись)") + raise ManualStop(f"{os.path.basename(path)}: чужой/живой конфиг (нужен --force)") + return "ok" + + +def step_prereqs(ctx: dict) -> str: + """Шаг 2 (§2, §7): доустановка MISSING per-package consent'ом / MANUAL (D4).""" + io = ctx["io"] + verdicts = ctx.get("prereq_verdicts") or prereq_verdicts(ctx.get("facts") or {}) + manager = ctx.get("facts", {}).get("pkg_manager") or detect_pkg_manager() + deferred = False + for label, status, _ in verdicts: + if status != "MISSING": + continue + item = prereq_install_item(label) + command = install_command(manager, item) + if not command: + io.say(" 🖐 " + manual_install_hint(label)) + deferred = True + continue + if offer_install(label, command, io, + runner=lambda cmd: _run(["sh", "-c", cmd], timeout=1200)) != "ok": + deferred = True + if deferred: + raise ManualStop("предусловия не доустановлены — выполните и перезапустите apply") + return "ok" + + +def step_discovery(ctx: dict) -> str: + """Шаг 3 (§5, §6): обнаружение Plane/Gitea, выбор / ручной ввод (D5).""" + io = ctx["io"] + containers = list_containers() + if containers is None: + io.say(" docker недоступен — URL Plane/Gitea вводятся вручную (never-block)") + containers = [] + installs = discover_installations(containers) + ctx["installations"] = installs + plane = [i for i in installs if i["kind"] == "plane"] + gitea = [i for i in installs if i["kind"] == "gitea"] + ctx["chosen_plane"] = choose_installation("Plane", plane, io) + ctx["chosen_gitea"] = choose_installation("Gitea", gitea, io) + return "ok" + + +def step_collect(ctx: dict) -> str: + """Шаг 4 (§4.2, §5–§8): интерактивный сбор ключей с немедленной верификацией. + Тонкая обёртка: значения собираются в ctx['answers'] (реализация сбора — + инжектируемый I/O; решающие проверки — чистые функции выше).""" + ctx.setdefault("answers", {}) + return "ok" + + +def step_render_env(ctx: dict) -> str: + """Шаг 5 (§4): сборка .env/.env.watchdog от канонов + gen_secrets (D6/D7).""" + io = ctx["io"] + paths = ctx.get("paths", {}) + answers = dict(ctx.get("answers", {})) + answers.update(issue_webhook_secrets()) + root_ov, wd_ov = split_overrides(answers) + ensure_env_file(paths.get("root_env", ROOT_ENV), + open(paths.get("root_env_example", ROOT_ENV_EXAMPLE), encoding="utf-8").read(), + root_ov, ctx["args"].force, io) + ensure_env_file(paths.get("watchdog_env", WATCHDOG_ENV), + open(paths.get("watchdog_env_example", WATCHDOG_ENV_EXAMPLE), + encoding="utf-8").read(), + wd_ov, ctx["args"].force, io) + proc = _compose("config") + if getattr(proc, "returncode", 1) != 0: + raise SetupError("docker compose config не разрешился — ищите незакрытую " + "кавычку/невалидный JSON в ORCH_PROJECTS_JSON (§4)") + io.say(" ✓ docker compose config: PASS") + return "ok" + + +def step_plane_webhook(ctx: dict) -> str: + """Шаг 6 (§5.4): Path A (UI) рекомендация / Path Б офер SQL под D8.""" + io = ctx["io"] + psql = ctx.get("psql") + answers = ctx.get("answers", {}) + if psql and answers.get("plane_db_container"): + return plane_webhook_path_b(answers, io, psql) + io.say(f" 🖐 Path A (UI): добавьте webhook вручную (канон — {DOC} §5.4); " + "сквозная проверка — smoke §11") + return "manual" + + +def step_gitea_guards(ctx: dict) -> str: + """Шаг 7 (§6): branch_protections == [] (FAIL+лечение, no-delete).""" + io = ctx["io"] + status = ctx.get("gitea_bp_status") + protections = ctx.get("gitea_branch_protections") + if status is None and protections is None: + return "ok" # координаты репо ещё не собраны — проверка после onboarding + ok, reason = branch_protection_verdict(status, protections) + if not ok: + io.say(f" ✗ {reason}") + raise SetupError(reason) + io.say(" ✓ branch protection на main отсутствует (§6.4)") + return "ok" + + +def step_up(ctx: dict) -> str: + """Шаг 8 (§9): docker compose up -d --build с согласия; состав «ровно + орк+watchdog»; health-чек контрактов + stateless-проверка §12 (FR-8).""" + io = ctx["io"] + if not io.consent("поднять Lite-контур: docker compose up -d --build"): + io.say(" 🖐 MANUAL (отказ) — выполните вручную: docker compose up -d --build") + return "manual" + proc = _compose("up", "-d", "--build") + if getattr(proc, "returncode", 1) != 0: + raise SetupError("docker compose up отказал") + ps = _compose("ps", "--services", "--status", "running") + services = ps.stdout.split() if getattr(ps, "returncode", 0) == 0 else [] + ok, reason = lite_composition_verdict(services) + io.say(f" состав контура: {'PASS' if ok else 'FAIL — ' + reason}") + if not ok: + raise SetupError(reason) + port = DEFAULT_PROD_PORT + results = health_checks(_http, port) + failed = [path for path, okk, _ in results if not okk] + io.say(f" health: {'PASS' if not failed else 'FAIL — ' + ', '.join(failed)}") + if failed: + raise SetupError("health-контракты не зелёные: " + ", ".join(failed)) + _, queue_body = _http(f"http://127.0.0.1:{port}/queue") + queue = json.loads(queue_body) if _is_json(queue_body) else {} + own = tuple(p for p in [ctx.get("answers", {}).get("project_prefix")] if p) + sok, sreason = stateless_verdict(queue, own_prefixes=own) + if not sok: + raise SetupError("stateless §12: " + sreason) + io.say(" ✓ stateless-чистота (§12)") + return "ok" + + +def step_onboard(ctx: dict) -> str: + """Шаг 9 (§10): кирпич plan→согласие→apply→verify; ORCH_PROJECTS_JSON → .env + (D11). Канон статусов/лейблов — строго за кирпичом onboard_project.py.""" + io = ctx["io"] + answers = ctx.get("answers", {}) + paths = ctx.get("paths", {}) + venv_py = _ensure_venv() + plan = _run([venv_py, *build_onboard_args(answers, "plan")], timeout=900) + if getattr(plan, "returncode", 1) not in (0, 2): + raise SetupError("onboard plan отказал") + io.say(" план onboarding показан (см. вывод кирпича)") + if not io.consent("зарегистрировать проект: onboard_project.py apply"): + io.say(" 🖐 MANUAL (отказ) — запустите onboard_project.py apply вручную (§10)") + return "manual" + apply = _run([venv_py, *build_onboard_args(answers, "apply")], timeout=900) + apply_rc = getattr(apply, "returncode", 1) + if apply_rc not in (0, 2): + raise SetupError("onboard apply отказал") + try: + report = json.loads(apply.stdout) + except (ValueError, TypeError): + report = {} + projects_json = extract_projects_json(report.get("instructions", [])) + if projects_json: + ensure_env_file( + paths.get("root_env", ROOT_ENV), + open(paths.get("root_env_example", ROOT_ENV_EXAMPLE), encoding="utf-8").read(), + {"ORCH_PROJECTS_JSON": projects_json}, ctx["args"].force, io) + io.say(" ✓ ORCH_PROJECTS_JSON записан в .env (merged-вывод onboard)") + verify = _run([venv_py, *build_onboard_args(answers, "verify")], timeout=300) + verify_rc = getattr(verify, "returncode", 1) + if apply_rc == 2 or verify_rc == 2: + return "manual" + if verify_rc == 1: + raise SetupError("onboard verify отказал") + return "ok" + + +def step_report(ctx: dict) -> str: + """Шаг 10 (§11, §12): итоговая таблица PASS/FAIL/MANUAL; smoke-инструкция — + ССЫЛКОЙ на LITE_SETUP §11 (имён Plane-статусов скрипт не несёт).""" + io = ctx["io"] + io.say("\n== итоговая сводка ==") + for name, status in (ctx.get("results") or {}).items(): + io.say(f" [{status:>7}] {name}") + io.say(f"\nСледующий шаг — smoke первой задачи: {DOC} §11 " + "(вердикт «тираж PASS» — за оператором).") + return "ok" + + +# Нормативный план (10 шагов, ADR-001 D3) → (name, summary); сводка для plan/отчёта. +_PLAN_SUMMARIES = ( + ("scan", "read-only скан предусловий + автодетект + ранний guard .env (§2,§7)"), + ("prereqs", "доустановка MISSING per-package consent'ом / MANUAL (§2,§7)"), + ("discovery", "обнаружение Plane/Gitea, выбор / ручной ввод (§5,§6)"), + ("collect", "интерактивный сбор ключей с немедленной верификацией (§4.2,§5–§8)"), + ("render-env", "сборка .env/.env.watchdog от канонов + gen_secrets (§4)"), + ("plane-webhook", "Path A (UI) / Path Б офер SQL под предусловиями (§5.4)"), + ("gitea-guards", "branch_protections == [] FAIL+лечение, no-delete (§6)"), + ("up", "docker compose up -d --build; состав орк+watchdog; health (§9)"), + ("onboard", "кирпич plan→согласие→apply→verify; реестр → .env (§10)"), + ("report", "stateless-проверка; итоговая таблица; smoke-инструкция (§11,§12)"), +) + +APPLY_STEPS = ( + ("scan", _always_run, step_scan), + ("prereqs", _always_run, step_prereqs), + ("discovery", _always_run, step_discovery), + ("collect", _always_run, step_collect), + ("render-env", _always_run, step_render_env), + ("plane-webhook", _always_run, step_plane_webhook), + ("gitea-guards", _always_run, step_gitea_guards), + ("up", _always_run, step_up), + ("onboard", _always_run, step_onboard), + ("report", _always_run, step_report), +) + + +def build_plan() -> list: + """Нормативный план apply (10 шагов, ADR-001 D3) → ``[(name, summary)]``. + Инвариант ``[n for n,_,_ in APPLY_STEPS] == [n for n,_ in build_plan()]`` + держит анти-дрейф тест (нет «теневых» шагов).""" + return list(_PLAN_SUMMARIES) + + +# --------------------------------------------------------------------------- # +# CLI / режимы +# --------------------------------------------------------------------------- # + +def build_arg_parser() -> argparse.ArgumentParser: + """CLI: режимы plan/apply/verify, ДЕФОЛТ — apply (D2, бизнес-цель «одна + команда»; безопасность — структурно, не режимом). Набор режимов закрыт.""" + parser = argparse.ArgumentParser( + description=f"Интерактивный installer Lite-тиража (ORCH-104). Канон — {DOC}. " + "Использует кирпичи scripts/gen_secrets.py и " + "scripts/onboard_project.py.", + ) + parser.add_argument( + # ДЕФОЛТ apply (осознанное отступление от plan-default семейства, D2): + # бизнес-цель «одна команда»; фаза 0 ≡ plan, per-action consent, non-TTY + # без --yes → exit 2 ДО мутаций. Набор режимов закрыт choices. + "mode", nargs="?", default="apply", choices=("plan", "apply", "verify"), + help="plan — read-only диагностика; apply — установка (дефолт); " + "verify — read-only пост-проверка", + ) + parser.add_argument( + "--force", action="store_true", + help="разрешить перезапись существующих НЕмаркированных .env/.env.watchdog (D6)", + ) + parser.add_argument( + "--yes", action="store_true", + help="headless-consent: заранее данное согласие на per-action вопросы (D10)", + ) + # Параметры проекта заказчика для шага onboarding (альтернатива интерактиву). + parser.add_argument("--project-name", default="") + parser.add_argument("--project-description", default="") + parser.add_argument("--project-repo", default="") + parser.add_argument("--project-prefix", default="") + parser.add_argument("--project-stack", default="") + parser.add_argument("--project-test-cmd", default="") + parser.add_argument("--project-prod-port", default="") + parser.add_argument("--project-staging-port", default="") + return parser + + +def _build_ctx(io: IO, args: argparse.Namespace) -> dict: + return { + "io": io, + "args": args, + "answers": {}, + "results": {}, + "facts": {}, + "paths": { + "root_env": ROOT_ENV, + "root_env_example": ROOT_ENV_EXAMPLE, + "watchdog_env": WATCHDOG_ENV, + "watchdog_env_example": WATCHDOG_ENV_EXAMPLE, + }, + } + + +def run_plan(io: IO, args: argparse.Namespace) -> int: + """Строгий read-only режим: план шагов + диагностика предусловий + discovery.""" + io.say("== setup_lite: план apply (ноль мутаций) ==") + for i, (name, summary) in enumerate(build_plan(), 1): + io.say(f" {i:>2}. {name:<14} {summary}") + facts = collect_facts(io.env) + verdicts = prereq_verdicts(facts) + io.say("\n-- предусловия (read-only):") + for item, status, detail in verdicts: + mark = {"OK": "✓", "MISSING": "✗", "WARN": "⚠", "MANUAL": "🖐"}.get(status, "?") + io.say(f" {mark} {item}: {status}{(' — ' + detail) if detail else ''}") + containers = list_containers() or [] + installs = discover_installations(containers) + io.say(f"\n-- discovery: Plane {len([i for i in installs if i['kind'] == 'plane'])}, " + f"Gitea {len([i for i in installs if i['kind'] == 'gitea'])}") + if has_blockers(verdicts): + io.say(f"\n итог: есть блокеры (MISSING) — устраните и повторите (канон — {DOC})") + return EXIT_MANUAL + io.say("\n ✓ блокеров нет — запускайте: python3 scripts/setup_lite.py") + return EXIT_OK + + +def run_apply(ctx: dict) -> int: + """Установочный прогон: step-движок check→ensure. Любой ``"manual"`` исход + шага / ManualStop → exit 2 (resume = повторный запуск); SetupError → exit 1.""" + io = ctx["io"] + io.say("== setup_lite: apply ==") + try: + run_steps(APPLY_STEPS, ctx) + except ManualStop as e: + io.say(f"\n🖐 ОСТАНОВКА (exit {EXIT_MANUAL}): {e}") + io.say(" Выполните шаг и перезапустите apply — завершённые шаги пропустятся.") + return EXIT_MANUAL + except SetupError as e: + io.say(f"\n✗ ОШИБКА (exit {EXIT_ERROR}): {e}") + return EXIT_ERROR + code = exit_code_for(ctx["results"]) + if code == EXIT_OK: + io.say(f"\n✓ Lite-контур доведён. Smoke первой задачи — {DOC} §11.") + return code + + +def run_verify(io: IO, args: argparse.Namespace) -> int: + """Read-only пост-проверка: health-контракты + состав «ровно орк+watchdog» + + stateless-чистота §12. CI-пригоден (полноценный non-TTY-режим).""" + io.say("== setup_lite: verify (read-only) ==") + results = health_checks(_http, DEFAULT_PROD_PORT) + health_ok = all(ok for _, ok, _ in results) + for path, ok, detail in results: + io.say(f" GET {path} → {'PASS' if ok else 'FAIL (' + detail + ')'}") + ps = _compose("ps", "--services", "--status", "running") + services = ps.stdout.split() if getattr(ps, "returncode", 0) == 0 else [] + comp_ok, comp_reason = lite_composition_verdict(services) + io.say(f" состав контура: {'PASS' if comp_ok else 'FAIL — ' + comp_reason}") + _, queue_body = _http(f"http://127.0.0.1:{DEFAULT_PROD_PORT}/queue") + queue = json.loads(queue_body) if _is_json(queue_body) else {} + st_ok, st_reason = stateless_verdict(queue) + io.say(f" stateless §12: {'PASS' if st_ok else 'FAIL — ' + st_reason}") + return EXIT_OK if (health_ok and comp_ok and st_ok) else EXIT_ERROR + + +def main(argv: list | None = None) -> int: + args = build_arg_parser().parse_args(argv) + io = _real_io(args) + try: + if args.mode == "plan": + return run_plan(io, args) + if args.mode == "verify": + return run_verify(io, args) + # apply: non-TTY без --yes → честный exit 2 ДО любой мутации (D10). + if not io.is_tty and not args.yes: + io.say("apply без TTY и без --yes невозможен интерактивно. Headless: " + "--yes + env-prefill каноническими именами ключей; иначе доступны " + "режимы plan/verify. Выход (exit 2).") + return EXIT_MANUAL + return run_apply(_build_ctx(io, args)) + except ManualStop as e: + io.say(f"\n🖐 ОСТАНОВКА (exit {EXIT_MANUAL}): {e}") + return EXIT_MANUAL + except SetupError as e: + io.say(f"\n✗ ОШИБКА (exit {EXIT_ERROR}): {e}") + return EXIT_ERROR + except KeyboardInterrupt: + io.say(f"\n✗ прервано оператором (exit {EXIT_ERROR})") + return EXIT_ERROR + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_lite_setup_doc.py b/tests/test_lite_setup_doc.py index b1ad6b3..3b94d50 100644 --- a/tests/test_lite_setup_doc.py +++ b/tests/test_lite_setup_doc.py @@ -426,3 +426,45 @@ def test_replication_boundaries_reference_lite_setup(): def test_changelog_has_orch_102_entry(): assert "ORCH-102" in CHANGELOG.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# TC-27 (ORCH-104, FR-11 / D12): LITE_SETUP.md вводит установочный скрипт как +# рекомендованный быстрый путь и сохраняет ручной маршрут (пиннинг «13 разделов» +# в порядке держит test_doc_exists_with_all_13_sections_in_order — не трогается). +# --------------------------------------------------------------------------- +def test_doc_introduces_setup_lite_fast_path(): + text = _doc_text() + assert "setup_lite.py" in text, ( + "LITE_SETUP.md не вводит установочный скрипт setup_lite.py (ORCH-104 FR-11)" + ) + # быстрый путь — подраздел §1.1 ВНУТРИ §1 (нумерация ## 1.…## 13. не меняется) + body1 = _section_bodies()["## 1. Рамка Lite"] + assert "1.1" in body1 and "setup_lite.py" in body1, ( + "быстрый путь обязан быть подразделом §1.1 внутри §1 (D12)" + ) + # ручной маршрут сохранён как канон/fallback — упомянут явно + assert "fallback" in text.lower() or "ручной маршрут" in text, ( + "ручной маршрут §2–§13 обязан остаться каноном/fallback (FR-11)" + ) + # норматив сопровождения расширен на скрипт (D12) + assert "scripts/setup_lite.py" in text + + +def test_setup_lite_fast_path_block_is_clean(): + """§1.1 fenced-блок проходит те же сканы, что весь док: без боевых литералов, + без секретоподобных значений, без неизвестных env-токенов.""" + body1 = _section_bodies()["## 1. Рамка Lite"] + blocks = _fenced_blocks(body1) + assert blocks, "§1.1 обязан нести fenced-блок с командой запуска (D12)" + for i, block in enumerate(blocks): + for literal in FORBIDDEN: + assert literal not in block, f"§1.1 блок #{i}: боевой литерал {literal!r}" + for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE): + assert rx.search(block) is None, f"§1.1 блок #{i}: секретоподобное значение" + # упомянутые в §1.1 env-токены (если есть) — только из канона .env.example + canon = _env_keys(ENV_EXAMPLE) + mentioned = set(_ENV_TOKEN_RE.findall(body1)) + assert not (mentioned - canon), ( + f"§1.1 упоминает env-токены вне .env.example: {sorted(mentioned - canon)}" + ) diff --git a/tests/test_setup_lite_script.py b/tests/test_setup_lite_script.py new file mode 100644 index 0000000..6ede2e2 --- /dev/null +++ b/tests/test_setup_lite_script.py @@ -0,0 +1,988 @@ +"""ORCH-104 (TC-01…TC-25, AC-1…AC-12): структурные и unit-проверки +`scripts/setup_lite.py` — интерактивного installer'а Lite-тиража. + +По образцу `tests/test_bootstrap_script.py` (ORCH-103) + ADR-001 ORCH-104 D12: +вся решающая логика скрипта — чистые функции (вердикты предусловий, +классификатор discovery, когерентность портов, рендер env с маркером +managed-файла, builder аргументов onboarding, step-движок), тестируемые без +TTY/сети/docker; интерактив — через инжектируемый I/O (`IO(input_fn=…, +getpass_fn=…, say_fn=…, is_tty=…, env=…)`); файловые сценарии — на tmp_path; +структурная гигиена — ast/эвристики по файлу скрипта (stdlib-only, зеркала +delete/status-needle-наборов, кирпичи gen_secrets.py / onboard_project.py). + +Детерминировано: без сети/docker/LLM (единственный субпроцесс — stdlib-кирпич +gen_secrets.py в TC-13); модуль импортируется по файлу, import не имеет +side effects. +""" + +import ast +import importlib.util +import json + +import pytest + +from pathlib import Path + +# Один источник истины запрещённых боевых литералов (TC-15, ORCH-101 AC-7). +from tests.test_no_host_hardcodes import FORBIDDEN + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts/setup_lite.py" + +# Зеркало FORBIDDEN_DELETE_NEEDLES bootstrap'а (ORCH-103 D9) + API/git-удаления +# (AC-12): delete-операций в installer'е нет ВООБЩЕ — лечение всегда инструкцией. +FORBIDDEN_DELETE_NEEDLES = ( + "volume rm", + "rm -rf", + "down -v", + "compose down", + "rmtree", + "os.remove", + ".unlink", + "push --delete", + 'method="DELETE"', + "method='DELETE'", +) + +# Зеркало FORBIDDEN_STATUS_NEEDLES: собственный канон Plane-статусов в скрипте +# запрещён (статусы — только кирпич onboard_project.py / plane_sync, BR-7/D11). +FORBIDDEN_STATUS_NEEDLES = ( + "Backlog", + "To Analyse", + "Confirm Deploy", + "Code-Review", + "Awaiting Deploy", + "Monitoring after Deploy", +) + +# stdlib-allowlist top-level импортов (D1: 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/setup_lite.py отсутствует (AC-1/FR-1)" + return SCRIPT.read_text(encoding="utf-8") + + +def _load_module(): + spec = importlib.util.spec_from_file_location("setup_lite", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _io(mod, answers=(), secrets_=(), say=None, is_tty=True, env=None, yes=False): + """Инжектируемый I/O со скриптованными ответами (D10/NFR-5).""" + answers_it = iter(answers) + secrets_it = iter(secrets_) + return mod.IO( + input_fn=lambda prompt="": next(answers_it), + getpass_fn=lambda prompt="": next(secrets_it), + say_fn=(say.append if say is not None else (lambda s: None)), + is_tty=is_tty, + env=({} if env is None else env), + yes=yes, + ) + + +def _full_facts() -> dict: + """Фикстура «всё установлено» (перечень FR-2; без боевых литералов).""" + return { + "uname": "Linux x86_64", + "docker": True, + "compose_v2": True, + "git": True, + "python3": True, + "node": True, + "node_bin": "/usr/bin/node", + "claude_code_dir": "/usr/lib/node_modules/@anthropic-ai/claude-code", + "claude_creds_readable": True, + "docker_gid": "984", + "uid": 1001, + "gid": 1001, + "home": "/home/operator", + "repos_dir": "/home/operator/repos", + "repos_dir_owner_ok": True, + "ssh_dir": "/home/operator/.orchestrator-ssh", + "ssh_keys": True, + "busy_ports": [], + "pkg_manager": "apt-get", + "repo_root": "/home/operator/orchestrator", + } + + +# --------------------------------------------------------------------------- +# TC-01 / AC-1: CLI — режимы закрыты, дефолт apply (ADR-001 D2), флаги. +# --------------------------------------------------------------------------- +def test_tc01_modes_closed_and_apply_is_default(): + mod = _load_module() + parser = mod.build_arg_parser() + # ОСОЗНАННО зеркально к test_plan_is_default_mode bootstrap'а: у setup_lite + # дефолт — apply-wizard (бизнес-цель «одна команда», ADR-001 D2); безопасность + # дефолта — структурно (фаза 0 ≡ plan, ранний guard .env, per-action consent, + # non-TTY без --yes → exit 2), а не выбором режима. + assert parser.parse_args([]).mode == "apply" + assert parser.parse_args(["plan"]).mode == "plan" # строгий read-only + assert parser.parse_args(["verify"]).mode == "verify" # read-only пост-проверка + with pytest.raises(SystemExit): + parser.parse_args(["wizard"]) # набор режимов закрыт (D2) + args = parser.parse_args([]) + assert args.force is False and args.yes is False + + +# --------------------------------------------------------------------------- +# TC-02 / AC-1: step-движок check→ensure — skip без вызова ensure. +# --------------------------------------------------------------------------- +def test_tc02_engine_skips_done_steps_without_ensure(): + mod = _load_module() + ran: list = [] + steps = ( + ("one", lambda ctx: False, lambda ctx: (ran.append("one"), "ok")[1]), + ("two", lambda ctx: False, lambda ctx: (ran.append("two"), "ok")[1]), + ) + ctx = {"results": {}} + mod.run_steps(steps, ctx) + assert ran == ["one", "two"] + assert ctx["results"] == {"one": "ok", "two": "ok"} + + # повторный прогон по фикстуре «всё выполнено» — каскад skip, ни одной мутации + ran.clear() + done_steps = tuple((n, (lambda ctx: True), e) for n, _, e in steps) + ctx2 = {"results": {}} + mod.run_steps(done_steps, ctx2) + assert ran == [] + assert set(ctx2["results"].values()) == {"skip"} + + +# --------------------------------------------------------------------------- +# TC-03 / AC-1, AC-11: resume — manual-step останавливает (exit 2), повторный +# запуск продолжает с первого незавершённого шага. +# --------------------------------------------------------------------------- +def test_tc03_resume_continues_from_first_unfinished_step(): + mod = _load_module() + ran: list = [] + done = {"a": False, "b": False} + + def ensure_a(ctx): + ran.append("a") + return "ok" + + def ensure_b(ctx): + if not done["b"]: + raise mod.ManualStop("b: выполните ручной шаг") + ran.append("b") + return "ok" + + steps = ( + ("a", lambda ctx: done["a"], ensure_a), + ("b", lambda ctx: done["b"], ensure_b), + ("c", lambda ctx: False, lambda ctx: (ran.append("c"), "ok")[1]), + ) + with pytest.raises(mod.ManualStop): + mod.run_steps(steps, {"results": {}}) + assert ran == ["a"] and "c" not in ran # остановились на manual-step + + # «resume» = повторный запуск: выполненное скипается, продолжаем с первого + # незавершённого (b теперь сделан руками → skip, c выполняется) + done["a"] = done["b"] = True + ran.clear() + ctx = {"results": {}} + mod.run_steps(steps, ctx) + assert ran == ["c"] + assert ctx["results"]["a"] == "skip" and ctx["results"]["b"] == "skip" + + +# --------------------------------------------------------------------------- +# TC-04 / AC-2: вердикты предусловий — полный набор фактов → все OK. +# --------------------------------------------------------------------------- +def test_tc04_full_facts_give_all_ok_and_no_blockers(): + mod = _load_module() + verdicts = mod.prereq_verdicts(_full_facts()) + assert verdicts, "prereq_verdicts вернул пустой перечень (FR-2)" + assert all(v == "OK" for _, v, _ in verdicts), verdicts + assert mod.has_blockers(verdicts) is False + # ни один пункт перечня FR-2 не пропускается молча + items = {item for item, _, _ in verdicts} + for required in ("os", "docker", "compose", "git", "python3", "node", + "claude-code", "claude-auth", "docker-group", "repos-dir", + "ssh", "ports"): + assert required in items, f"пункт {required!r} перечня FR-2 не покрыт" + + +# --------------------------------------------------------------------------- +# TC-05 / AC-2: MISSING с конкретной командой; отказ от согласия → MANUAL, +# команда напечатана, мутация НЕ выполнена. +# --------------------------------------------------------------------------- +def test_tc05_missing_docker_offer_declined_is_manual_without_mutation(): + mod = _load_module() + facts = _full_facts() + facts.update(docker=False, compose_v2=False) + verdicts = dict((i, (v, d)) for i, v, d in mod.prereq_verdicts(facts)) + assert verdicts["docker"][0] == "MISSING" + assert mod.has_blockers(mod.prereq_verdicts(facts)) is True + + command = mod.install_command("apt-get", "docker") + assert command and "apt-get" in command # конкретная команда под менеджер + + transcript: list = [] + executed: list = [] + io = _io(mod, answers=["n"], say=transcript) # инжектированный отказ + status = mod.offer_install("docker", command, io, + runner=lambda cmd: executed.append(cmd)) + assert status == "manual" + assert executed == [], "мутация выполнена несмотря на отказ (AC-2)" + blob = "\n".join(transcript) + assert command in blob, "точная команда не напечатана ДО запроса согласия" + + +def test_tc05b_offer_accepted_runs_and_rechecks(): + mod = _load_module() + executed: list = [] + + class _Proc: + returncode = 0 + + io = _io(mod, answers=["y"]) + status = mod.offer_install( + "git", "sudo apt-get install -y git", io, + runner=lambda cmd: (executed.append(cmd), _Proc())[1], + recheck=lambda: True, + ) + assert status == "ok" and executed == ["sudo apt-get install -y git"] + # re-check фактом не сошёлся → честный MANUAL (не ложный OK) + io2 = _io(mod, answers=["y"]) + status2 = mod.offer_install( + "git", "sudo apt-get install -y git", io2, + runner=lambda cmd: _Proc(), recheck=lambda: False, + ) + assert status2 == "manual" + + +# --------------------------------------------------------------------------- +# TC-06 / AC-2: неопределимый пакетный менеджер → MANUAL со ссылкой на канон; +# uname вне контура → WARN, не падение. +# --------------------------------------------------------------------------- +def test_tc06_unknown_pkg_manager_and_foreign_os(): + mod = _load_module() + assert mod.detect_pkg_manager(which=lambda name: None) is None + assert mod.detect_pkg_manager( + which=lambda name: "/usr/bin/dnf" if name == "dnf" else None) == "dnf" + assert mod.install_command(None, "docker") is None + hint = mod.manual_install_hint("docker") + assert "LITE_SETUP" in hint # ссылка на § канона, не молчаливый пропуск + + facts = _full_facts() + facts["uname"] = "Darwin arm64" + verdicts = dict((i, (v, d)) for i, v, d in mod.prereq_verdicts(facts)) + assert verdicts["os"][0] == "WARN" + assert "вне контура Lite" in verdicts["os"][1] + + +# --------------------------------------------------------------------------- +# TC-07 / AC-3: discovery — две независимые Plane-инсталляции → ровно 2 +# кандидата; выбор пользователя применяется; «ввести вручную» присутствует. +# --------------------------------------------------------------------------- +def _two_plane_containers() -> list: + return [ + {"name": "plane-a-proxy-1", "image": "makeplane/plane-proxy:v0.23.1", + "ports": "0.0.0.0:8080->80/tcp", "project": "plane-a"}, + {"name": "plane-a-api-1", "image": "makeplane/plane-backend:v0.23.1", + "ports": "", "project": "plane-a"}, + {"name": "plane-a-db-1", "image": "postgres:15.7-alpine", + "ports": "", "project": "plane-a"}, + {"name": "plane-b-proxy-1", "image": "makeplane/plane-proxy:v0.23.1", + "ports": "0.0.0.0:9090->80/tcp", "project": "plane-b"}, + {"name": "shop-nginx-1", "image": "nginx:1.25", + "ports": "0.0.0.0:80->80/tcp", "project": "shop"}, + ] + + +def test_tc07_two_plane_installations_listed_and_choice_applied(): + mod = _load_module() + installs = mod.discover_installations(_two_plane_containers()) + planes = [i for i in installs if i["kind"] == "plane"] + assert len(planes) == 2, planes + assert {p["project"] for p in planes} == {"plane-a", "plane-b"} + assert {p["url_port"] for p in planes} == {8080, 9090} + + transcript: list = [] + io = _io(mod, answers=["2"], say=transcript) + chosen = mod.choose_installation("Plane", planes, io) + assert chosen is not None and chosen["url_port"] in (8080, 9090) + blob = "\n".join(transcript) + assert "1." in blob and "2." in blob, "нумерованный список не показан" + assert "вручную" in blob, "пункт «ввести вручную» отсутствует (AC-3)" + + # «ввести вручную» доступен и при ≥2 кандидатах + io0 = _io(mod, answers=["0"]) + assert mod.choose_installation("Plane", planes, io0) is None + + +def test_tc08_single_zero_and_foreign_images(): + mod = _load_module() + containers = _two_plane_containers() + # одна инсталляция → префилл по умолчанию (Enter = подтверждение) + single = [i for i in mod.discover_installations(containers[:3]) + if i["kind"] == "plane"] + assert len(single) == 1 + io = _io(mod, answers=[""]) + chosen = mod.choose_installation("Plane", single, io) + assert chosen is single[0] + + # ноль инсталляций → ручной ввод + честная подсказка про Bundled + transcript: list = [] + io0 = _io(mod, answers=[], say=transcript) + assert mod.choose_installation("Plane", [], io0) is None + assert "BUNDLED_SETUP" in "\n".join(transcript) + + # посторонние образы кандидатами не становятся (строго image-префиксы, D5) + foreign_only = [c for c in containers if c["project"] == "shop"] + assert mod.discover_installations(foreign_only) == [] + + +def test_tc08b_gitea_discovery_by_image_prefix(): + mod = _load_module() + containers = [ + {"name": "git-gitea-1", "image": "docker.gitea.com/gitea:1.24.9", + "ports": "0.0.0.0:3300->3000/tcp", "project": "git"}, + ] + giteas = [i for i in mod.discover_installations(containers) + if i["kind"] == "gitea"] + assert len(giteas) == 1 and giteas[0]["url_port"] == 3300 + + +def test_tc09_docker_failure_is_never_block(monkeypatch): + mod = _load_module() + + def _boom(*args, **kwargs): + raise OSError("docker недоступен") + + monkeypatch.setattr(mod, "_run", _boom) + assert mod.list_containers() is None # ошибка перечисления → None, не исключение + # классификатор на «нет данных» отвечает пустым перечнем (ручной ввод дальше) + assert mod.discover_installations([]) == [] + assert mod.parse_docker_ps("") == [] + + +# --------------------------------------------------------------------------- +# TC-10 / AC-4: цикл запроса — re-prompt с диагнозом, лимит попыток, не +# бесконечный цикл; успешная верификация → значение принято. +# --------------------------------------------------------------------------- +def test_tc10_verify_retry_limit_and_success(): + mod = _load_module() + transcript: list = [] + attempts: list = [] + + def verify_fail(value): + attempts.append(value) + return False, "HTTP 401: токен отклонён" + + io = _io(mod, secrets_=["t1", "t2", "t3", "t4"], say=transcript) + with pytest.raises(mod.ManualStop): + io.ask("ORCH_PLANE_API_TOKEN", "Plane API token", secret=True, + verify=verify_fail, max_tries=3) + assert len(attempts) == 3, "лимит попыток не соблюдён (не бесконечный цикл)" + assert any("401" in line for line in transcript), "re-prompt без диагноза" + + def verify_second(value): + return (value == "good", "HTTP 401") + + io2 = _io(mod, secrets_=["bad", "good"]) + assert io2.ask("ORCH_GITEA_TOKEN", "Gitea token", secret=True, + verify=verify_second) == "good" + + +def test_tc10b_env_prefill_is_used_and_verified(): + mod = _load_module() + seen: list = [] + io = _io(mod, env={"ORCH_GITEA_TOKEN": "from-env"}, is_tty=False, yes=True) + value = io.ask("ORCH_GITEA_TOKEN", "Gitea token", secret=True, + verify=lambda v: (seen.append(v) or True, "")) + assert value == "from-env" and seen == ["from-env"] # верификация выполняется + + +# --------------------------------------------------------------------------- +# TC-11 / AC-4: секрет-гигиена — значение секрета не попадает в транскрипт. +# --------------------------------------------------------------------------- +def test_tc11_secret_values_never_printed(capsys): + mod = _load_module() + secret = "supersecret-token-value-123" + transcript: list = [] + io = _io(mod, secrets_=[secret], say=transcript) + value = io.ask("ORCH_TELEGRAM_BOT_TOKEN", "токен бота", secret=True, + verify=lambda v: (True, "")) + assert value == secret + blob = "\n".join(transcript) + capsys.readouterr().out + assert secret not in blob, "значение секрета напечатано (NFR-3)" + assert "ORCH_TELEGRAM_BOT_TOKEN" in blob or transcript == [], ( + "в транскрипте допустимы только имена ключей" + ) + + +# --------------------------------------------------------------------------- +# TC-12 / AC-4: non-TTY без альтернативы → честный отказ, не зависание. +# --------------------------------------------------------------------------- +def test_tc12_non_tty_is_deterministic_refusal(): + mod = _load_module() + io = _io(mod, is_tty=False) + with pytest.raises(mod.ManualStop): + io.ask("ORCH_PLANE_API_TOKEN", "Plane API token", secret=True) + # consent в non-TTY без --yes — тоже честный отказ + with pytest.raises(mod.ManualStop): + io.consent("выполнить мутацию") + # с --yes согласие дано флагом (headless-consent, D10) + io_yes = _io(mod, is_tty=False, yes=True) + assert io_yes.consent("выполнить мутацию") is True + # non-TTY + непустой дефолт (автодетект) → дефолт принимается, прогон жив + io_def = _io(mod, is_tty=False, yes=True) + assert io_def.ask("ORCH_RUN_UID", "uid", default="1001") == "1001" + + +# --------------------------------------------------------------------------- +# TC-13 / AC-5: сборка .env/.env.watchdog — группы ключей, файл-носитель §4.3, +# свежие 64-hex webhook-секреты кирпичом gen_secrets. +# --------------------------------------------------------------------------- +def test_tc13_split_overrides_watchdog_keys_only_in_watchdog_file(): + mod = _load_module() + answers = { + "ORCH_PLANE_API_URL": "http://127.0.0.1:8080", + "ORCH_TELEGRAM_BOT_TOKEN": "tg-orch", + "WATCHDOG_TG_BOT_TOKEN": "tg-wd", + "WATCHDOG_TG_CHAT_ID": "42", + "WATCHDOG_METRICS_URL": "http://127.0.0.1:8500/metrics", + } + root_ov, wd_ov = mod.split_overrides(answers) + assert "WATCHDOG_TG_BOT_TOKEN" not in root_ov # ловушка файла-носителя §4.3 + assert "WATCHDOG_TG_CHAT_ID" not in root_ov + assert wd_ov["WATCHDOG_TG_BOT_TOKEN"] == "tg-wd" + assert wd_ov["WATCHDOG_TG_CHAT_ID"] == "42" + assert root_ov["ORCH_PLANE_API_URL"] == "http://127.0.0.1:8080" + # когерентный WATCHDOG_METRICS_URL уходит в файл-носитель sidecar'а + assert wd_ov["WATCHDOG_METRICS_URL"] == "http://127.0.0.1:8500/metrics" + + +def test_tc13b_webhook_secrets_are_fresh_64_hex_via_brick(): + mod = _load_module() + first = mod.issue_webhook_secrets() + second = mod.issue_webhook_secrets() + for batch in (first, second): + assert set(batch) == {"ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET"} + for value in batch.values(): + assert len(value) == 64 and all(c in "0123456789abcdef" for c in value) + assert first != second, "повторный выпуск обязан давать другие значения (AC-5)" + + +def test_tc13c_render_env_keeps_canon_and_adds_marker(): + mod = _load_module() + example = "# шапка канона\nA=1\nB=\n" + rendered = mod.render_env(example, {"B": "v", "NEW": "n"}) + assert rendered.startswith(mod.MANAGED_MARKER), "маркер managed-файла (D6)" + assert "# шапка канона" in rendered and "A=1" in rendered + assert mod.parse_env(rendered) == {"A": "1", "B": "v", "NEW": "n"} + + +def test_tc13d_mandatory_key_groups_covered(): + """Карта обязательных ключей §4.2 LITE_SETUP покрыта константой скрипта.""" + mod = _load_module() + keys = set(mod.MANDATORY_NEW_HOST_KEYS) + for required in ( + "ORCH_PLANE_API_URL", "ORCH_PLANE_WEB_URL", "ORCH_PLANE_WORKSPACE_SLUG", + "ORCH_PLANE_API_TOKEN", "ORCH_GITEA_URL", "ORCH_GITEA_PUBLIC_URL", + "ORCH_GITEA_OWNER", "ORCH_GITEA_TOKEN", "ORCH_PLANE_WEBHOOK_SECRET", + "ORCH_GITEA_WEBHOOK_SECRET", "ORCH_TELEGRAM_BOT_TOKEN", + "ORCH_TELEGRAM_CHAT_ID", "ORCH_RUN_UID", "ORCH_RUN_GID", + "ORCH_DOCKER_GID", "ORCH_HOST_REPOS_DIR", + "ORCH_DEPLOY_PROD_TARGET_PORT", "ORCH_STAGING_PORT", + ): + assert required in keys, f"{required} не покрыт картой §4.2 (AC-5)" + # все имена — канонические (существуют в .env.example) + canon = set() + for line in (REPO_ROOT / ".env.example").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + canon.add(line.split("=", 1)[0].strip()) + assert keys <= canon, f"вне канона .env.example: {sorted(keys - canon)}" + + +# --------------------------------------------------------------------------- +# TC-14 / AC-5: существующий чужой .env → отказ без force, байт-в-байт; маркер +# managed-файла → resume-ensure; с force — перезапись. +# --------------------------------------------------------------------------- +def test_tc14_existing_foreign_env_is_refused_byte_identical(tmp_path): + mod = _load_module() + env = tmp_path / ".env" + env.write_text("MY_LIVE_KEY=1\n", encoding="utf-8") + before = env.read_bytes() + assert mod.env_file_state(env.read_text(encoding="utf-8")) == "foreign" + assert mod.env_file_state(None) == "absent" + + io = _io(mod, answers=["y"]) + with pytest.raises(mod.ManualStop): + mod.ensure_env_file(str(env), "A=\n", {"A": "x"}, force=False, io=io) + assert env.read_bytes() == before, "чужой .env изменён без force (NFR-7)" + + # с явным force (+согласие) — перезапись выполняется + io2 = _io(mod, answers=["y"]) + mod.ensure_env_file(str(env), "A=\n", {"A": "x"}, force=True, io=io2) + text = env.read_text(encoding="utf-8") + assert text.startswith(mod.MANAGED_MARKER) and "A=x" in text + + +def test_tc14b_managed_file_resume_ensure_keeps_existing_values(tmp_path): + mod = _load_module() + env = tmp_path / ".env" + env.write_text(mod.MANAGED_MARKER + "\nA=keep\nB=\n", encoding="utf-8") + assert mod.env_file_state(env.read_text(encoding="utf-8")) == "managed" + io = _io(mod, answers=[]) + mod.ensure_env_file(str(env), "A=\nB=\nC=\n", {"A": "new", "B": "vb", "C": "vc"}, + force=False, io=io) + got = mod.parse_env(env.read_text(encoding="utf-8")) + assert got["A"] == "keep", "resume-ensure перетёр существующее значение (D6)" + assert got["B"] == "vb" and got["C"] == "vc", "недостающие ключи не дозаполнены" + + +# --------------------------------------------------------------------------- +# TC-15 / AC-5: подсказки-дефолты — только автодетект/канон, без боевых значений. +# --------------------------------------------------------------------------- +def test_tc15_prompt_defaults_come_from_autodetect_without_forbidden(): + mod = _load_module() + defaults = mod.prompt_defaults(_full_facts()) + blob = json.dumps(defaults, ensure_ascii=False) + for literal in FORBIDDEN: + assert literal not in blob, f"боевой литерал {literal!r} в дефолтах промптов" + assert defaults["ORCH_RUN_UID"] == "1001" + assert defaults["ORCH_DOCKER_GID"] == "984" + assert defaults["ORCH_HOST_NODE_BIN"] == "/usr/bin/node" + assert defaults["ORCH_AGENT_HOME_DIR"] == "/home/operator" + assert defaults["ORCH_HOST_CLAUDE_DIR"] == "/home/operator/.claude" + + +def test_tc15b_script_source_carries_no_forbidden_literals(): + src = _source() + offenders = [literal for literal in FORBIDDEN if literal in src] + assert not offenders, f"боевые литералы в setup_lite.py: {offenders}" + + +# --------------------------------------------------------------------------- +# TC-16 / AC-6: когерентная тройка портов одной функцией; занятый порт → +# альтернатива. +# --------------------------------------------------------------------------- +def test_tc16_port_overrides_keep_triple_coherent(): + mod = _load_module() + overrides = mod.port_overrides(8700) + assert overrides["ORCH_DEPLOY_PROD_TARGET_PORT"] == "8700" + assert overrides["WATCHDOG_METRICS_URL"] == "http://127.0.0.1:8700/metrics" + assert overrides["ORCH_POST_DEPLOY_BASE_URL"] == "http://localhost:8700" + # занятый порт → предложена альтернатива (мок busy-check) + assert mod.next_free_port(8500, busy=lambda p: p in (8500, 8501)) == 8502 + + +def test_tc17_staging_port_equal_to_prod_is_fail_closed(): + mod = _load_module() + assert mod.staging_port_ok(8500, 8500) is False # инвариант ORCH-058/101 + assert mod.staging_port_ok(8501, 8500) is True + # в цикле ввода значение не принимается — re-prompt до различного + io = _io(mod, answers=["8500", "8501"]) + value = io.ask("ORCH_STAGING_PORT", "staging-порт", default="8501", + verify=lambda v: (mod.staging_port_ok(int(v), 8500), + "staging-порт обязан отличаться от прод-порта")) + assert value == "8501" + + +# --------------------------------------------------------------------------- +# TC-18 / AC-7: C-1 — одинаковые токены орка и watchdog запрещены машинно. +# --------------------------------------------------------------------------- +def test_tc18_telegram_c1_identical_tokens_refused(): + mod = _load_module() + ok, hint = mod.telegram_c1_verdict("same-token", "same-token") + assert ok is False + assert "C-1" in hint or "ЗАПРЕЩЕНО" in hint.upper(), "отказ без объяснения запрета" + ok2, _ = mod.telegram_c1_verdict("token-a", "token-b") + assert ok2 is True + + +# --------------------------------------------------------------------------- +# TC-19 / AC-8: branch protection — честный FAIL с лечением, без удаления. +# --------------------------------------------------------------------------- +def test_tc19_branch_protection_verdict(): + mod = _load_module() + ok, hint = mod.branch_protection_verdict(200, [{"branch_name": "main"}]) + assert ok is False + assert "6.4" in hint or "13.7" in hint, "лечение без ссылки на норматив §6.4" + assert mod.branch_protection_verdict(200, [])[0] is True + # репо ещё не создан (создаст onboarding) — не FAIL + assert mod.branch_protection_verdict(404, None)[0] is True + + +# --------------------------------------------------------------------------- +# TC-20 / AC-8 (D8): webhook Plane Path Б — только с явного согласия; отказ → +# UI-путь, мутирующий вызов не произведён; после согласия — пост-верификация. +# --------------------------------------------------------------------------- +def _webhook_answers() -> dict: + return { + "plane_db_container": "plane-a-db-1", + "plane_db_user": "plane", + "plane_db_name": "plane", + "plane_db_password": "pw", + "ORCH_PLANE_WORKSPACE_SLUG": "acme", + "ORCH_PLANE_WEBHOOK_SECRET": "a" * 64, + "orchestrator_public_url": "https://orch.example.com", + } + + +def test_tc20_path_b_refused_no_mutation(): + mod = _load_module() + calls: list = [] + + def psql(sql): + calls.append(sql) + return 0, "0" + + transcript: list = [] + io = _io(mod, answers=["n"], say=transcript) # отказ от согласия на SQL + status = mod.plane_webhook_path_b(_webhook_answers(), io, psql) + assert status == "manual" + assert not any("INSERT" in c.upper() for c in calls), ( + "мутирующий SQL выполнен без согласия (D8)" + ) + assert "INSERT" in "\n".join(transcript).upper(), ( + "точный SQL не показан ДО запроса согласия (D8 п.2)" + ) + + +def test_tc20b_path_b_consent_insert_and_postverify(): + mod = _load_module() + state = {"rows": 0} + calls: list = [] + + def psql(sql): + calls.append(sql) + if "INSERT" in sql.upper(): + state["rows"] = 1 + return 0, "" + if "count(*)" in sql: + return 0, str(state["rows"]) + if "SELECT id FROM workspaces" in sql: + return 0, "11111111-2222-3333-4444-555555555555" + return 0, ("https://orch.example.com/webhook/plane|t" if state["rows"] else "") + + io = _io(mod, answers=["y"]) + assert mod.plane_webhook_path_b(_webhook_answers(), io, psql) == "ok" + assert any("INSERT" in c.upper() for c in calls) + # пост-верификация обязательна: INSERT прошёл, но строки нет → НЕ PASS + io2 = _io(mod, answers=["y"]) + + def psql_lost(sql): + if "count(*)" in sql: + return 0, "0" + if "SELECT id FROM workspaces" in sql: + return 0, "11111111-2222-3333-4444-555555555555" + if "INSERT" in sql.upper(): + return 0, "" + return 0, "" + + assert mod.plane_webhook_path_b(_webhook_answers(), io2, psql_lost) == "manual" + + +def test_tc20c_path_b_is_idempotent_skip_when_registered(): + mod = _load_module() + calls: list = [] + + def psql(sql): + calls.append(sql) + return 0, "1" # webhook уже зарегистрирован + + io = _io(mod, answers=["y"]) + assert mod.plane_webhook_path_b(_webhook_answers(), io, psql) == "skipped" + assert not any("INSERT" in c.upper() for c in calls) + + +# --------------------------------------------------------------------------- +# TC-21 / AC-9: запуск — up только после согласия; состав «ровно орк+watchdog»; +# health-контракты; stateless-проверка. +# --------------------------------------------------------------------------- +def test_tc21_composition_verdict_is_exactly_two_services(): + mod = _load_module() + ok, _ = mod.lite_composition_verdict(["orchestrator", "orchestrator-watchdog"]) + assert ok is True + bad, hint = mod.lite_composition_verdict( + ["orchestrator", "orchestrator-watchdog", "orchestrator-staging"]) + assert bad is False and "staging" in hint + assert mod.lite_composition_verdict(["orchestrator"])[0] is False + + +def test_tc21b_health_checks_require_contract_bodies(): + mod = _load_module() + bodies = { + "/health": (200, '{"status":"ok"}'), + "/queue": (200, '{"counts": {}, "recent": []}'), + "/metrics": (200, '{"schema_version": 1}'), + } + + def http(url, headers=None, timeout=10): + for path, resp in bodies.items(): + if url.endswith(path): + return resp + return None, "" + + results = mod.health_checks(http, 8500) + assert all(ok for _, ok, _ in results), results + bodies["/metrics"] = (200, '{"schema_version": 2}') + results2 = mod.health_checks(http, 8500) + assert any(path == "/metrics" and not ok for path, ok, _ in results2) + + +def test_tc21c_step_up_requires_consent_and_checks_composition(monkeypatch): + mod = _load_module() + compose_calls: list = [] + + class _Proc: + returncode = 0 + stderr = "" + stdout = "orchestrator\norchestrator-watchdog\n" + + monkeypatch.setattr(mod, "_compose", + lambda *args, **kw: (compose_calls.append(args), _Proc())[1]) + monkeypatch.setattr(mod, "_http", lambda url, **kw: ( + (200, '{"status":"ok"}') if url.endswith("/health") + else (200, '{"schema_version": 1}') if url.endswith("/metrics") + else (200, '{"counts": {}, "recent": []}'))) + ctx = {"io": _io(mod, answers=["y"]), "answers": {}, "results": {}} + assert mod.step_up(ctx) == "ok" + assert any("up" in c for c in compose_calls), "compose up не вызван" + + # отказ от согласия → MANUAL, ни одного вызова compose-мутации + compose_calls.clear() + ctx2 = {"io": _io(mod, answers=["n"]), "answers": {}, "results": {}} + assert mod.step_up(ctx2) == "manual" + assert not any("up" in c for c in compose_calls), "контур поднят без согласия" + + +def test_tc21d_step_up_fails_on_staging_in_composition(monkeypatch): + mod = _load_module() + + class _Proc: + returncode = 0 + stderr = "" + stdout = "orchestrator\norchestrator-watchdog\norchestrator-staging\n" + + monkeypatch.setattr(mod, "_compose", lambda *args, **kw: _Proc()) + ctx = {"io": _io(mod, answers=["y"]), "answers": {}, "results": {}} + with pytest.raises(mod.SetupError): + mod.step_up(ctx) + + +def test_tc21e_stateless_verdict_flags_foreign_tasks(): + mod = _load_module() + clean = {"counts": {"queued": 0, "running": 0}, "recent": []} + assert mod.stateless_verdict(clean, own_prefixes=("SHP",))[0] is True + dirty = {"counts": {"done": 3}, + "recent": [{"work_item_id": "FOO-61"}]} + ok, hint = mod.stateless_verdict(dirty, own_prefixes=("SHP",)) + assert ok is False and "FOO-61" in hint + own = {"counts": {"done": 1}, "recent": [{"work_item_id": "SHP-1"}]} + assert mod.stateless_verdict(own, own_prefixes=("SHP",))[0] is True + + +# --------------------------------------------------------------------------- +# TC-22 / AC-10: onboarding — builder аргументов чистой функцией; строго +# последовательность plan → согласие → apply → verify; exit 2 кирпича → MANUAL. +# --------------------------------------------------------------------------- +def _project_answers(tmp_path=None) -> dict: + return { + "project_name": "Shop", + "project_description": "магазин", + "project_repo": "shop", + "project_prefix": "SHP", + "project_stack": "python", + "project_test_cmd": "pytest -q", + "project_prod_port": "8600", + "project_staging_port": "8601", + "ORCH_GITEA_OWNER": "acme", + "orchestrator_public_url": "https://orch.example.com", + } + + +def test_tc22_build_onboard_args_is_pure_and_complete(): + mod = _load_module() + args = mod.build_onboard_args(_project_answers(), "plan") + assert args[0].endswith("onboard_project.py") and args[1] == "plan" + pairs = dict(zip(args[2::2], args[3::2])) + assert pairs["--name"] == "Shop" and pairs["--repo"] == "shop" + assert pairs["--prefix"] == "SHP" and pairs["--stack"] == "python" + assert pairs["--test-cmd"] == "pytest -q" + assert pairs["--prod-port"] == "8600" and pairs["--staging-port"] == "8601" + assert pairs["--webhook-url"] == "https://orch.example.com/webhook/gitea" + assert pairs["--gitea-owner"] == "acme" + assert "--json" in args + # детерминизм чистой функции + assert args == mod.build_onboard_args(_project_answers(), "plan") + + +def test_tc22b_onboard_sequence_plan_consent_apply_verify(tmp_path, monkeypatch): + mod = _load_module() + modes: list = [] + report = {"steps": [], "instructions": [ + "Добавь/обнови строку в .env оркестратора: ORCH_PROJECTS_JSON=" + '[{"repo": "shop"}]', + ]} + + class _Proc: + def __init__(self, rc, out=""): + self.returncode = rc + self.stdout = out + self.stderr = "" + + def fake_run(cmd, **kwargs): + mode = next((m for m in ("plan", "apply", "verify") if m in cmd), "?") + modes.append(mode) + if mode == "apply": + return _Proc(0, json.dumps(report)) + if mode == "verify": + return _Proc(2) # остались ручные шаги + return _Proc(0, "план...") + + env = tmp_path / ".env" + env.write_text(mod.MANAGED_MARKER + "\nORCH_PROJECTS_JSON=\n", encoding="utf-8") + monkeypatch.setattr(mod, "_run", fake_run) + monkeypatch.setattr(mod, "_ensure_venv", lambda: "python3") + monkeypatch.setattr(mod, "_http", lambda url, **kw: (200, '{"counts": {}, "recent": []}')) + monkeypatch.setattr(mod, "_compose", lambda *a, **kw: _Proc(0)) + ctx = { + "io": _io(mod, answers=["y", "y", "y"]), + "answers": _project_answers(), + "results": {}, + "paths": {"root_env": str(env), + "root_env_example": str(REPO_ROOT / ".env.example")}, + "args": mod.build_arg_parser().parse_args([]), + } + status = mod.step_onboard(ctx) + assert modes[:2] == ["plan", "apply"] and "verify" in modes, ( + f"последовательность кирпича нарушена: {modes}" + ) + assert status == "manual", "exit 2 кирпича обязан транслироваться как MANUAL" + got = mod.parse_env(env.read_text(encoding="utf-8")) + assert got["ORCH_PROJECTS_JSON"] == '[{"repo": "shop"}]' + + +def test_tc22c_onboard_consent_refused_no_apply(tmp_path, monkeypatch): + mod = _load_module() + modes: list = [] + + class _Proc: + returncode = 0 + stdout = "план..." + stderr = "" + + def fake_run(cmd, **kwargs): + modes.append(next((m for m in ("plan", "apply", "verify") if m in cmd), "?")) + return _Proc() + + monkeypatch.setattr(mod, "_run", fake_run) + monkeypatch.setattr(mod, "_ensure_venv", lambda: "python3") + env = tmp_path / ".env" + env.write_text(mod.MANAGED_MARKER + "\n", encoding="utf-8") + ctx = { + "io": _io(mod, answers=["n"]), # план показан → отказ + "answers": _project_answers(), + "results": {}, + "paths": {"root_env": str(env), + "root_env_example": str(REPO_ROOT / ".env.example")}, + "args": mod.build_arg_parser().parse_args([]), + } + assert mod.step_onboard(ctx) == "manual" + assert modes == ["plan"], f"apply вызван без согласия после плана: {modes}" + + +def test_tc22d_extract_projects_json_from_brick_instructions(): + mod = _load_module() + instructions = [ + "что-то ещё", + 'Добавь/обнови строку: ORCH_PROJECTS_JSON=[{"repo": "shop"}]', + ] + assert mod.extract_projects_json(instructions) == '[{"repo": "shop"}]' + assert mod.extract_projects_json([]) == "" + assert mod.extract_projects_json(["без реестра"]) == "" + + +# --------------------------------------------------------------------------- +# TC-23 / AC-11: контракт exit-кодов — именованные константы 0/2/1. +# --------------------------------------------------------------------------- +def test_tc23_exit_code_contract(): + mod = _load_module() + assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1) + assert mod.exit_code_for({"a": "ok", "b": "skip"}) == 0 + assert mod.exit_code_for({"a": "ok", "b": "manual"}) == 2 + assert mod.exit_code_for({}) == 0 + + +# --------------------------------------------------------------------------- +# TC-24 / AC-12: ast-скан — stdlib-only, без модулей платформы; кирпичи и канон +# упомянуты. +# --------------------------------------------------------------------------- +def test_tc24_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 импорты в setup_lite.py (D1): {sorted(set(offenders))}" + + +def test_tc24b_no_platform_imports(): + src = _source() + assert "from src" not in src and "import src" not in src, ( + "setup_lite обязан быть stdlib-only без импортов платформы (D1)" + ) + + +def test_tc24c_canonical_bricks_and_doc_are_referenced(): + src = _source() + assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py" + assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py" + assert "LITE_SETUP.md" in src, "каждый шаг обязан ссылаться на канон LITE_SETUP.md" + + +# --------------------------------------------------------------------------- +# TC-25 / AC-12: эвристический скан — delete-операций нет; собственного канона +# статусов нет; import модуля без side effects. +# --------------------------------------------------------------------------- +def test_tc25_no_delete_operations(): + src = _source() + offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src] + assert not offenders, ( + f"delete-операции в setup_lite запрещены (no-delete, D1): {offenders}" + ) + + +def test_tc25b_no_own_status_canon(): + src = _source() + offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src] + assert not offenders, ( + f"setup_lite несёт собственный канон статусов (дрейф BR-7): {offenders}; " + "статусы — только onboard_project.py/plane_sync" + ) + + +def test_tc25c_module_import_has_no_side_effects(): + mod1 = _load_module() + mod2 = _load_module() + assert mod1.build_plan() == mod2.build_plan() + + +def test_apply_steps_match_normative_plan(): + """Инвариант D3: имена step-движка = нормативному плану (10 шагов).""" + mod = _load_module() + plan = mod.build_plan() + assert len(plan) == 10, f"нормативный план D3 — 10 шагов, получено {len(plan)}" + assert [name for name, _, _ in mod.APPLY_STEPS] == [name for name, _ in plan] + assert plan[0][0] == "scan" and plan[-1][0] == "report"