feat: ORCH-104-lite #126

Closed
admin wants to merge 7 commits from feature/ORCH-104-lite into main
19 changed files with 4254 additions and 6 deletions

View File

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

View File

@@ -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()]` (нет «теневых» шагов).
- **Решающая логика — чистые функции (D4D11):** вердикты предусловий `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.

View File

@@ -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()]` (нет «теневых» шагов).
- **Решающая логика — чистые функции (D4D11):** `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/<slug>/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`

View File

@@ -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)
Единая точка входа «бизнес + тех» для трёх аудиторий (заказчик / менеджер / разработчик) —

View File

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

View File

@@ -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` (канон ключей).*

View File

@@ -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) (весь стек одним комплектом).
### Я менеджер проекта

View File

@@ -0,0 +1,7 @@
# Business Request: Установочный скрипт для lite
Work Item ID: ORCH-104
## Description
TBD

View File

@@ -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) → выбор всегда за
пользователем, ручной ввод всегда доступен, токен-верификация всё равно обязательна.

View File

@@ -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/<slug>/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» → артефакты `0104`) выдаётся как
завершающая 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-<slug>.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 печать готовой команды.

View File

@@ -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-тесты) |

View File

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

View File

@@ -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:<published-port>` достигает
сервисов хоста; 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:<port>`
(валидно: корневой compose — `network_mode: host`, контейнер орка видит published-порты хоста
через loopback) и публичный URL `http://<hostname>:<port>` (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 <tmpdir>/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/<slug>/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/<owner>/<repo>/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://<orchestrator-public-host>/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` (канон имён ключей)

View File

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

View File

@@ -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). Возврат в анализ не требуется:
ТЗ выполнимо без нарушения принципов архитектуры.

View File

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

1379
scripts/setup_lite.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)}"
)

View File

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