diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1a50dfb..6ebce17 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -210,6 +210,29 @@ sidecar читает только `.env.watchdog`; C-1 ORCH-100 — отдель (docs+tests). Подробнее: [adr-0037](adr/adr-0037-lite-replication-canon.md), детально — `docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`. +**Установщик Lite (ORCH-104 — design).** Поверх ручного канона ORCH-102 вводится интерактивный +**установщик `scripts/install_lite.py`** — «connect-only»-сородич `bootstrap_bundle.py` (тот +*поднимает* Plane/Gitea, этот *подключается* к уже существующим заказчика), автоматизирующий +happy-path `LITE_SETUP.md` §2–§11. **Самодостаточный stdlib-only один файл** (примитивы эталона +реплицированы, не вынесены — `bootstrap_bundle.py` байт-в-байт; вынос общего модуля — по +rule-of-three): step-движок `check→ensure`, режимы `plan`/`apply`/`verify`, exit `0/2/1`, honest +`manual_checkpoint`. Скан предусловий хоста + управляемая установка зависимостей (точная команда +под `apt`/`dnf`, выполнение только с явным согласием при TTY; тихого root-инсталла нет, D-1); +best-effort детект существующих Plane/Gitea (`docker ps` + порт-пробы, ранжир unauth-liveness) с +ручным фолбэком; интерактивный сбор токенов/URL с **живой верификацией ДО записи** (`getpass`, +Plane `/projects/`, Gitea `/user`, Telegram `getMe`). **Каноны не форкаются:** webhook-секреты — +`gen_secrets.py`, регистрация проекта/22 статуса — `onboard_project.py`, env — рендер из +`.env.example`/`.env.watchdog.example`, стек — `docker-compose.yml`. **Граница connect-only +(нормативно):** webhook Plane — верифицируемый **manual-step** (печать инструкции пути A/Б, без +исполнения raw-SQL в чужую БД — INV-5; истинный гейт — smoke §11); логин claude CLI — manual-step +с верификацией. Гигиена секретов: `getpass`, права 600, без молчаливой перезаписи (NFR-2). Не-интерактив/CI +— fail-closed `exit 2` с именем недостающего ключа; секреты из env, не из argv. Анти-дрейф — +новый `tests/test_install_lite_script.py` (структурный + юнит + фейки) + ассерт ссылки на +установщик в `test_lite_setup_doc.py` (13 разделов сохранены). Рантайм/конвейер — байт-в-байт +(scripts+docs+tests); kill-switch не нужен (активация — явный запуск оператором). Подробнее: +[adr-0040](adr/adr-0040-lite-installer-canon.md), детально — +`docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md`. + **Type B — Bundled (ORCH-103).** Закрывает эпик ORCH-10: весь стек одним комплектом (орк + watchdog + Gitea + Plane CE ≈13–14 контейнеров) для заказчика без собственной инфраструктуры. Состав Plane — зеркало официального selfhost-référence v0.23.1 diff --git a/docs/architecture/adr/adr-0040-lite-installer-canon.md b/docs/architecture/adr/adr-0040-lite-installer-canon.md new file mode 100644 index 0000000..e54ea6d --- /dev/null +++ b/docs/architecture/adr/adr-0040-lite-installer-canon.md @@ -0,0 +1,106 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# adr-0040: Установщик Lite-тиража — `scripts/install_lite.py` (ORCH-104, 10a) + +## Статус +Proposed + +## Контекст + +Эпик ORCH-10 (D5 «Масштаб»), тип **A — Lite**. adr-0037 (ORCH-102) дал golden-source инструкцию +`docs/deployment/LITE_SETUP.md` — надёжный, но **ручной** маршрут из 13 разделов («голый хост → +работающий конвейер»): ~6 предусловий, ~20 ключей `.env` по 4 группам, webhook'и Plane/Gitea +(иногда raw-SQL), два Telegram-бота, claude CLI, compose, онбординг, smoke. Долго; ошибки +(опечатка секрета → 401 HMAC; неверный uid; занятый порт) всплывают поздно — на `docker compose +up`, не в момент ввода. adr-0038 (ORCH-103) для **Bundled** уже доказал каркас установщика +`scripts/bootstrap_bundle.py`. + +ORCH-104 автоматизирует happy-path §2–§11 одним интерактивным установщиком и добавляет раннюю +валидацию. Сквозной характер: вводится новый **операторский артефакт семьи тиража** и нормативная +**граница connect-only** (Lite *подключается* к чужой инфре, в отличие от Bundled, который её +*поднимает*) — это правило обязательно для будущих задач эпика ORCH-10. Детальный пакет решений +(D1…D15, исходы вопросов ТЗ OQ-1…OQ-7) — work-item ADR: +`docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md`. + +## Решение + +1. **Новый установщик `scripts/install_lite.py`** — «connect-only»-сородич `bootstrap_bundle.py`. + Имя `install_*` (не `bootstrap_*`) понятнее оператору и сигналит семантику «подключение к + готовой инфре» против «bring-up» bundle (OQ-1). +2. **Самодостаточный один файл, stdlib-only** (OQ-4): проверенные примитивы эталона + (`parse_env`/`render_env`/`manual_checkpoint`/`_write_private` 600/never-raise `_http`/ + `preflight_verdict`-стиль/exit-контракт `0/2/1`) **реплицированы**, а не вынесены в общий + модуль. Так держится «один установочный файл» (BR-1) и `bootstrap_bundle.py` остаётся + байт-в-байт (нулевой риск регресса Bundled). Вынос общего `scripts/_replication_lib.py` — + позже, по rule-of-three (третий тиражный скрипт). +3. **Каноны не форкаются** (как adr-0037/0038): webhook-секреты — субпроцесс `gen_secrets.py` + (никакого своего `token_hex`); регистрация проекта и 22 статуса — субпроцесс + `onboard_project.py apply/verify` (`plane_sync._PLANE_NAME_TO_KEY`); `.env`/`.env.watchdog` — + рендер из `.env.example`/`.env.watchdog.example`; стек — `docker-compose.yml` + (ровно орк+watchdog, staging за профилем не поднимается). +4. **Граница connect-only — нормативно (OQ-5):** в Lite Plane/Gitea/Telegram — **чужие** + инсталляции; установщик говорит с ними только по их API и **не мутирует** их. Webhook Plane = + верифицируемый **manual-step** (печать инструкции пути A/Б, без исполнения raw-SQL в чужую БД — + INV-5); истинный гейт webhook'а — smoke (§11). Контраст с bundle, который владеет Plane-БД и + пробует INSERT. +5. **Управляемая установка зависимостей без тихого root (OQ-3):** точная команда под дистрибутив + (`apt`/`dnf`), выполнение только при TTY + явном согласии + команда из allowlist; неизвестный + дистрибутив/нет TTY/отказ → инструкция + `exit 2`. Логин claude CLI — manual-step с верификацией + (OQ-6). Не-интерактив/CI — fail-closed `exit 2` с именем недостающего ключа; секреты из env, не + из argv (OQ-7). +6. **Анти-дрейф — постоянная CI-гарантия:** новый `tests/test_install_lite_script.py` (структурный + + юнит чистых функций + фейки HTTP/процессов; без сети/docker/LLM): ссылки на кирпичи, запрет + собственной генерации секретов и захардкоженных 22 статусов, stdlib-only (AST), 0 + delete/force-операций, реюз `FORBIDDEN`/`find_violations` из `tests/test_no_host_hardcodes.py`, + exit-контракт `{0,1,2}`. `LITE_SETUP.md` получает callout-указатель на установщик + (рекомендованный путь; ручной маршрут — фолбэк), **13 `## N.`-разделов сохранены** → + `test_lite_setup_doc.py` остаётся зелёным + ассертит ссылку. Норматив сопровождения (NFR-5): + меняешь шаги установки → синхронизируй `LITE_SETUP.md` в том же PR. + +### Что НЕ меняется +`src/**`, `docker-compose.yml`, `Dockerfile`, существующие `scripts/**` (включая +`bootstrap_bundle.py`/`gen_secrets.py`/`onboard_project.py`); `STAGE_TRANSITIONS`, состав +`QG_CHECKS`, семантика `check_*`, machine-verdict ключи, схема БД — байт-в-байт. Новый QG не +вводится (структурные тесты — в существующих гейтах). Kill-switch не нужен: активация — только явный +запуск оператором на его хосте (паттерн ORCH-009/101/102/103). Прод-контейнер в рамках задачи не +рестартуется. `07-infra-requirements.md`/`08-data-requirements.md` — N/A (топология нашего орка и +схема БД не меняются; предусловия хоста — golden source LITE_SETUP §2, не форкаются). + +## Альтернативы +- **Общий модуль `_replication_lib.py` (DRY сейчас)** — отвергнуто: правка замороженного + `bootstrap_bundle.py` = риск регресса Bundled + противоречит «одному файлу»; вынос по rule-of-three. +- **Имя `bootstrap_lite.py`** — отвергнуто: `install_*` понятнее оператору и верно сигналит + connect-only. +- **Авто-драйв webhook Plane raw-SQL (как bundle)** — отвергнуто: в Lite Plane чужой; raw-SQL в + чужую БД нарушает INV-5 и часто недостижим. +- **Тихий sudo-install / сторонний TUI (rich/click)** — отвергнуто: D-1 (только consent) и NFR-6 + (stdlib-only до первого `docker compose up`). +- **Замена `LITE_SETUP.md` установщиком** — отвергнуто: runbook остаётся golden source и фолбэком. + +## Последствия +- Type A эпика ORCH-10 получает автоматизированный happy-path поверх ручного канона; ошибки + секретов/URL ловятся в момент ввода (живая верификация), не на `up`. Полнота/анти-форк/отсутствие + delete защищены CI. +- Цена: ~150 строк осознанного дублирования примитивов с `bootstrap_bundle.py` (rule-of-three + триггер задокументирован); webhook Plane остаётся ручным (граница connect-only), верифицируется + на smoke. +- Откат: удалить `scripts/install_lite.py` + `tests/test_install_lite_script.py`, вернуть + callout-врезку `LITE_SETUP.md` и doc-правки — состояние 1:1 (scripts+docs+tests, без миграций и + рантайм-изменений). + +## Связи +adr-0037 (ORCH-102 — Lite-канон/`LITE_SETUP.md`; этот установщик автоматизирует его §2–§11), +adr-0038 (ORCH-103 — `bootstrap_bundle.py`, эталон каркаса; connect-only vs bring-up), +adr-0036 (ORCH-101 — фундамент 10-common: `gen_secrets.py`, `.env.example`, расхардкод хоста), +adr-0035 (ORCH-009 — `onboard_project.py`/22 статуса, переиспользуются субпроцессом; D10 — запрет +branch protection), adr-0033 (ORCH-100 — watchdog, C-1 отдельный бот в `.env.watchdog`), +adr-0008/INV-4 (ORCH-058 — staging-порт guard; merge-актор — основание connect-only-границы). +Детально — `docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md`, +`docs/work-items/ORCH-104/10-tech-risks.md`. diff --git a/docs/work-items/ORCH-104/00-business-request.md b/docs/work-items/ORCH-104/00-business-request.md new file mode 100644 index 0000000..80fa7f6 --- /dev/null +++ b/docs/work-items/ORCH-104/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Установочный скрипт для lite + +Work Item ID: ORCH-104 + +## Description + +TBD diff --git a/docs/work-items/ORCH-104/01-brd.md b/docs/work-items/ORCH-104/01-brd.md new file mode 100644 index 0000000..a9d755a --- /dev/null +++ b/docs/work-items/ORCH-104/01-brd.md @@ -0,0 +1,187 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-104 — Установочный скрипт для Lite + +Work Item: **ORCH-104** · Repo: **orchestrator** (self-hosting) · Стадия: analysis +Тип: FEATURE — UX/онбординг тиража (Type A эпика ORCH-10), поверх ORCH-102 (LITE_SETUP.md) + +> ⚠️ **Объём заморожен (2026-06-12).** Один интерактивный установщик, автоматизирующий +> ручной маршрут `docs/deployment/LITE_SETUP.md`. Это **scripts + docs + tests**-изменение: +> рантайм/конвейер (`src/**`, `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД) — **не трогаются** +> (см. §2 «Вне объёма», NFR-1). Установщик НЕ форкает каноны: секреты — кирпичом +> `gen_secrets.py`, онбординг — кирпичом `onboard_project.py`, env — из `.env.example`. + +--- + +## 1. Бизнес-контекст и проблема + +### 1.1. Цель +Максимально упростить установку оркестратора в Lite-режиме на чужой хост: свести +тринадцатишаговую ручную инструкцию к **одному запускаемому файлу**, который сам сканирует +систему, подсказывает чего не хватает, запрашивает у оператора только действительно +неизвестные данные (токены/URL) и доводит инсталляцию до работающего конвейера. + +### 1.2. Корневая боль (установленный факт) +`docs/deployment/LITE_SETUP.md` (ORCH-102) — золотой источник Lite-тиража, но это **ручной +runbook из 13 разделов**. Оператор вручную: проверяет предусловия хоста (§2), клонирует код (§3), +копирует `.env.example`, гоняет `gen_secrets.py`, **заполняет ~20 ключей `.env`** по четырём +группам (Plane/Gitea/Telegram/хост-порты, §4), настраивает webhook Plane (иногда **прямым SQL в +Postgres**, §5.4 путь Б), выпускает токен и webhook Gitea (§6), ставит claude CLI (§7), заводит +**два** Telegram-бота (§8), поднимает compose (§9), регистрирует проект `onboard_project.py` (§10), +гоняет smoke (§11). Это долго, легко ошибиться (опечатка в секрете → 401 HMAC; неверный uid → +worktree не пишется; занятый порт; пропущенный fail-closed статус `Confirm Deploy`/`STOP`), и +ошибка часто всплывает поздно — на `docker compose up`, а не в момент ввода. + +### 1.3. Почему именно установщик (а не «ещё инструкция») +Ручной маршрут уже задокументирован и стабилен (ORCH-102). Ценность ORCH-104 — **автоматизация +happy-path и ранняя валидация**: авто-детект всего детектируемого (uid/gid/docker-gid, свободные +порты, node/claude, **существующие инсталляции Plane/Gitea**), живая проверка каждого введённого +секрета ДО записи в `.env`, идемпотентный безопасный повтор. LITE_SETUP.md остаётся справочником +и фолбэком для траблшутинга — установщик его не заменяет, а исполняет. + +### 1.4. Установленные факты (переиспользуемые кирпичи — НЕ изобретать) +- **`scripts/bootstrap_bundle.py` (ORCH-103)** — ближайший прецедент: step-движок `check→ensure`, + режимы `plan`/`apply`/`verify`, exit-коды `0/2/1`, честные `manual_checkpoint` с + `input()`/`getpass()`, stdlib-only, секреты не печатаются, **delete-операций нет вообще**, + каноны переиспользуются субпроцессами. Установщик Lite — его «connect-only»-сородич: bundle + *поднимает* Plane/Gitea, Lite *подключается* к уже существующим. +- **`scripts/gen_secrets.py` (ORCH-101)** — выпуск webhook-секретов (`secrets.token_hex(32)`), + отказ перезаписи без `--force`. Единственный легитимный источник секретов. +- **`scripts/onboard_project.py` (ORCH-009)** — регистрация проекта (22 статуса с точными + именами, лейблы `autoApprove`/`autoDeploy`/`Bug`, репо+webhook), `plan`/`apply`/`verify`, + идемпотентно, exit `0/2/1`. +- **`.env.example` / `.env.watchdog.example`** — канон 100% ключей старта (дефолт каждого ключа = + боевому значению, ORCH-101); установщик рендерит `.env` из них, не выдумывая ключи. +- **`docker-compose.yml`** — сам по себе является Lite-подмножеством (дефолтный `up -d` поднимает + ровно `orchestrator` + `orchestrator-watchdog`; `orchestrator-staging` за профилем `staging`). + +### 1.5. Решения владельца (Owner decisions) +Предложены Владельцу как рекомендованные дефолты 2026-06-12; приняты для старта анализа, +пересматриваемы на review. + +| ID | Решение | Дата | +|----|---------|------| +| D-1 | **Установка зависимостей = детект + управляемая установка.** Установщик сканирует, показывает чего не хватает, печатает **точную команду** под обнаруженный дистрибутив и предлагает выполнить безопасные с явного согласия (`y/N`). Системные пакеты под root — только с подтверждением, **никогда молча**. (Совпадает с self-hosting-этосом и каноном `bootstrap_bundle.py`.) | 2026-06-12 | +| D-2 | **Plane/Gitea = только подключение (Lite = Type A).** Установщик **детектит существующие** инсталляции; при нескольких — показывает нумерованный список и даёт выбрать; ручной ввод URL — всегда фолбэк. Если их нет вовсе → инструктирует поставить самостоятельно либо указывает на Bundled-тираж (ORCH-103). Установщик их **не поднимает**. | 2026-06-12 | +| D-3 | **Режимы = интерактивный мастер + `plan`/`apply`/`verify`.** Канон `bootstrap_bundle.py`: `plan` (дефолт, ноль мутаций) / `apply` / `verify`; exit `0/2/1`; без TTY → fail-closed exit 2 с инструкцией; идемпотентный повтор. Скрипт пригоден и для повторного/CI-прогона. | 2026-06-12 | + +--- + +## 2. Объём (scope) + +### В объёме +- **FR-1** — Единый entry-point `scripts/install_lite.py` с режимами `plan`/`apply`/`verify`. +- **FR-2** — Скан-предусловий хоста (детект docker/compose/git/python3/node/claude CLI/портов/ + uid/gid/docker-gid/каталогов) с понятным списком «чего не хватает». +- **FR-3** — Управляемая установка недостающих зависимостей (детект дистрибутива → точная команда → + выполнение безопасных с согласия; D-1). +- **FR-4** — Детект существующих инсталляций Plane/Gitea на хосте + выбор при нескольких + ручной + фолбэк (D-2). +- **FR-5** — Интерактивный сбор данных оператора (Plane/Gitea/Telegram токены/URL) с **живой + верификацией каждого ДО записи** и скрытым вводом секретов. +- **FR-6** — Выпуск webhook-секретов строго кирпичом `gen_secrets.py`. +- **FR-7** — Сборка `.env` / `.env.watchdog` из канона `.env.example` / `.env.watchdog.example` + (идемпотентный рендер, права 600, без молчаливой перезаписи). +- **FR-8** — Подъём `orchestrator` + `orchestrator-watchdog` (`docker compose up`) + ожидание + готовности. +- **FR-9** — Регистрация проекта строго кирпичом `onboard_project.py apply`/`verify` + запись + `ORCH_PROJECTS_JSON`. +- **FR-10** — Health-верификация (`/health` / `/queue` / `/metrics`) + итоговая сводка PASS/FAIL. +- **FR-11** — Синхронизация документации (указатель LITE_SETUP.md на установщик; CLAUDE/README/ + overview/CHANGELOG). + +### Вне объёма +- ❌ **Любые изменения рантайма/конвейера** — `src/**`, `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, + machine-verdict ключи, схема БД (NFR-1; установщик вне процесса орка и вне конвейера QG). +- ❌ **Подъём самих Plane/Gitea** — это Bundled-тираж (ORCH-103); Lite только подключается (D-2). +- ❌ **Полный безусловный авто-install системных пакетов под root без согласия** (D-1 отвергает). +- ❌ **Teardown / удаление** — установщик не несёт delete-операций (NFR-4); снос — документированная + процедура. +- ❌ **Замена LITE_SETUP.md** — runbook остаётся золотым источником и фолбэком (дополняем, не + выкидываем). +- ❌ **Форк канонов** — секреты/онбординг/env/compose не реимплементируются (NFR-7). +- ❌ **Автоматизация интерактивного логина claude CLI** — это поток Anthropic; остаётся + верифицируемым manual-step (OQ-6). + +--- + +## 3. Заинтересованные стороны +- **Заказчик/инициатор:** Владелец (Слава) — цель «максимально упростить установку для пользователей». +- **Прямой пользователь:** внешний оператор/заказчик платформы, разворачивающий Lite на своём хосте. +- **Затрагиваемые:** сопровождающие LITE_SETUP.md (норматив синхронизации, BR-9); агенты + пайплайна (reviewer — проверка doc-sync; tester — прогон новых тестов). +- **Принимает результат:** reviewer (стадия review) + tester (стадия testing) по критериям §03. + +--- + +## 4. Бизнес-требования (BR) + +| ID | Требование | Связь | +|----|------------|-------| +| **BR-1** | Один запускаемый файл проводит оператора от «код склонирован» до «работающий Lite-конвейер», автоматизируя LITE_SETUP.md §2–§11. | FR-1, AC-1 | +| **BR-2** | Установщик сканирует хост и выдаёт чёткий список отсутствующих предусловий (что есть / чего не хватает). | FR-2, AC-2 | +| **BR-3** | Для каждого недостающего предусловия — точная команда установки под обнаруженный дистрибутив + предложение выполнить безопасные с явного согласия (никогда молча, D-1). | FR-3, AC-3 | +| **BR-4** | Данные, которые знает только оператор (токены/URL Plane, Gitea, Telegram), запрашиваются интерактивно в момент установки, **каждое — с живой проверкой ДО записи**; секреты вводятся скрыто и не логируются. | FR-5, AC-5, AC-6 | +| **BR-5** | Установщик детектит существующие инсталляции Plane и Gitea; при нескольких — показывает список и даёт выбрать; ручной ввод URL — всегда доступный фолбэк (D-2). | FR-4, AC-4 | +| **BR-6** | Каноны не форкаются: webhook-секреты — `gen_secrets.py`, регистрация проекта — `onboard_project.py`, env — из `.env.example`/`.env.watchdog.example`, стек — из `docker-compose.yml`. | FR-6, FR-7, FR-9, AC-7 | +| **BR-7** | Идемпотентность и наблюдаемость: режимы `plan`(дефолт)/`apply`/`verify`; повтор пропускает завершённые шаги; exit `0/2/1`; без TTY → fail-closed exit 2 с инструкцией; финальная сводка PASS/FAIL. | FR-1, FR-10, AC-8, AC-11 | +| **BR-8** | Self-hosting-безопасность: установщик — только scripts/docs/tests; никогда не правит `src/**`/конвейер/схему; исполняется на хосте заказчика; говорит только с локальным хостом и собственными Plane/Gitea/Telegram заказчика; delete-операций нет. | NFR-1, NFR-4, AC-9, AC-10 | +| **BR-9** | Норматив сопровождения: установщик автоматизирует LITE_SETUP.md → оба держатся в синхроне (меняешь шаги установки → обнови LITE_SETUP.md в том же PR; держит анти-дрейф тест). | FR-11, AC-12 | + +--- + +## 5. Нефункциональные требования (NFR) + +| ID | Требование | +|----|------------| +| **NFR-1** | **Рантайм байт-в-байт.** `src/**`, `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, machine-verdict ключи, схема БД — не трогаются. Kill-switch не нужен: активация = только явный запуск оператором (паттерн ORCH-009/102/103). | +| **NFR-2** | **Гигиена секретов.** Значения секретов никогда не печатаются в stdout/логи; `.env`/`.env.watchdog` пишутся правами 600; существующие секреты не перетираются без явного согласия/`--force`. | +| **NFR-3** | **Never-raise в детекте.** Сбой любой эвристики обнаружения/пробы деградирует на ручной ввод и не роняет установщик и не блокирует прогон (best-effort, fail-safe). | +| **NFR-4** | **Нет delete-операций** нигде в скрипте (teardown — только документированная процедура; зеркало ORCH-103 D9). | +| **NFR-5** | **Идемпотентный ensure.** Каждый шаг `check→ensure`; повтор безопасен; валидный существующий конфиг пропускается; дублей проекта/webhook не создаётся. | +| **NFR-6** | **stdlib-only.** Никаких новых зависимостей платформы — работает на голом `python3` целевого хоста ДО первого `docker compose up` (как `gen_secrets.py`/`bootstrap_bundle.py`). Каноны-знания — только субпроцессами кирпичей. | +| **NFR-7** | **Детерминированные анти-дрейф тесты.** В unit-тестах — без сети/docker/subprocess/LLM; чистые функции изолированы; HTTP/процессы — через инъекцию фейков. | +| **NFR-8** | **Кросс-дистрибутив.** Детект и команды установки работают на распространённых Linux (Debian/Ubuntu `apt`, RHEL/Fedora `dnf`); неизвестный дистрибутив → деградация на «инструктировать». | + +--- + +## 6. Допущения и ограничения +- **Контур Lite** (LITE_SETUP §1–§2): Linux x86_64, у оператора есть root/sudo для установки + системных пакетов (или он ставит их сам). Вне контура — вне гарантии. +- **Plane и Gitea — собственные инсталляции заказчика** (Type A). Установщик к ним подключается, + не управляет их жизненным циклом. +- **Plane CE не даёт API первичной инициализации/иногда webhook** (LITE_SETUP §5.4): такие шаги + остаются честными manual-step с верификацией результата (контракт `manual_checkpoint`). +- **Интерактивный логин claude CLI** не автоматизируется (поток Anthropic) — manual-step (OQ-6). +- **Платформенные конвенции (не менять):** репо обязан называться `orchestrator` + (`SELF_HOSTING_REPO`); имена compose-сервисов/профиля — константы; `ORCH_STAGING_PORT` ≠ + прод-порт (fail-closed guard ORCH-058). + +--- + +## 7. Критерии успеха (резюме) +Оператор на свежем хосте запускает один файл, отвечает на минимум вопросов (только то, что +система не может определить сама), и получает поднятый Lite-контур с зарегистрированным проектом и +зелёными `/health` `/queue` `/metrics`. Повторный запуск безопасен и пропускает сделанное. Рантайм +не изменён. Детальные PASS/FAIL — в `03-acceptance-criteria.md`. + +--- + +## 8. Риски (кратко; детали — 10-tech-risks.md, заполняет архитектор) +- **R-1** — Эвристика детекта Plane/Gitea ложно-положительна/отрицательна (нестандартные имена + контейнеров/порты) → митигируется ручным фолбэком (NFR-3) и живой верификацией URL (BR-4). +- **R-2** — Авто-install системных пакетов небезопасен/дистрибутиво-зависим → митигируется D-1 + (только с согласия, точная команда, неизвестный дистрибутив → инструктировать). +- **R-3** — Дрейф установщик ↔ LITE_SETUP.md при будущих правках → митигируется BR-9 + анти-дрейф + тестом. +- **R-4** — Случайная утечка секрета в лог/перетирание `.env` → митигируется NFR-2 (маскирование, + 600, без silent overwrite), покрыто AC-6. +- **R-5** — Дублирование логики `bootstrap_bundle.py` (parse_env/render_env/manual_checkpoint) → + архитектурный вопрос «общий модуль vs самодостаточный файл» (OQ-4), не блокирует анализ. diff --git a/docs/work-items/ORCH-104/02-trz.md b/docs/work-items/ORCH-104/02-trz.md new file mode 100644 index 0000000..85a0e1b --- /dev/null +++ b/docs/work-items/ORCH-104/02-trz.md @@ -0,0 +1,193 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-104 — Установочный скрипт для Lite + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные требования к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование (структура скрипта, точные эвристики детекта, общий код с +> `bootstrap_bundle.py`, финальное имя файла) — задача архитектора (`06-adr/`). Открытые вопросы — +> §12. + +## 1. Сводка изменения +Добавляется **один интерактивный установщик** `scripts/install_lite.py`, автоматизирующий ручной +маршрут `docs/deployment/LITE_SETUP.md` §2–§11 для Lite-тиража (Type A). Скрипт сканирует хост, +детектит/предлагает доустановить зависимости, обнаруживает существующие Plane/Gitea (выбор при +нескольких), интерактивно собирает и **живо верифицирует** токены/URL, выпускает секреты кирпичом +`gen_secrets.py`, собирает `.env`/`.env.watchdog` из канон-`.example`, поднимает `orchestrator` + +`orchestrator-watchdog`, регистрирует проект кирпичом `onboard_project.py` и проверяет health. +Изменение **аддитивно** и **вне рантайма**: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не +трогаются; kill-switch не нужен (активация — только явный запуск). Каноны не форкаются. + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `scripts/install_lite.py` | **создать** — интерактивный установщик Lite (entry-point; имя — OQ-1) | +| `scripts/gen_secrets.py` | **переиспользовать** (subprocess-кирпич), без изменений | +| `scripts/onboard_project.py` | **переиспользовать** (subprocess-кирпич, `apply`/`verify`), без изменений | +| `.env.example`, `.env.watchdog.example` | **переиспользовать** как канон-источник рендера, без изменений | +| `docker-compose.yml` | **переиспользовать** (`docker compose up -d --build`), без изменений | +| `scripts/bootstrap_bundle.py` | **эталон-паттерн** (parse_env/render_env/preflight_verdict/manual_checkpoint/exit-коды) — не изменяется; общий код — OQ-4 | +| `docs/deployment/LITE_SETUP.md` | **обновить** — указатель на установщик как рекомендованный путь + синхронизация шагов | +| `tests/test_install_lite_script.py` | **создать** — unit (чистые функции) + структурные анти-дрейф тесты | +| `tests/test_lite_setup_doc.py` | **обновить** — ассерт ссылки на установщик; существующие ассерты зелёные | +| `CLAUDE.md`, `CHANGELOG.md`, `docs/architecture/README.md`, `docs/overview/` | **обновить** (docs golden-source, правило агентов №2) | + +## 3. Функциональные требования + +### FR-1 — Entry-point и режимы +Скрипт `scripts/install_lite.py`, запускаемый из корня чекаута: `argparse`, позиционный `mode ∈ +{plan, apply, verify}` (дефолт `plan`). Exit-коды (контракт): **0** — успех; **2** — остановка на +manual-step / незавершённое предусловие / нет TTY; **1** — ошибка. `plan` — ноль мутаций (печать +плана + read-only preflight-диагностика). `apply` — полный прогон step-движком `check→ensure` +(повтор = каскад skip; «resume» = повторный запуск). `verify` — read-only пост-проверка +(health-контракты + `onboard_project.py verify`). Привязка: BR-1, BR-7. + +### FR-2 — Скан предусловий хоста (preflight) +Read-only снимок хоста (по образцу `bootstrap_bundle.collect_facts`/`preflight_verdict`): наличие +`docker`, `docker compose` v2, `git`, `python3`, `node`, `claude` CLI + читаемость кред; свободность +портов (прод-порт `ORCH_DEPLOY_PROD_TARGET_PORT` дефолт 8500, при self-hosting-вилке staging 8501); +`uid`/`gid`/`docker-gid` и владелец каталога репозиториев; наличие ssh-каталога. Чистая функция- +вердикт возвращает `(blockers, warnings)`; человекочитаемый список «есть/нет». Привязка: BR-2. + +### FR-3 — Управляемая установка зависимостей (D-1) +Для каждого блокера-зависимости: детект пакетного менеджера (`apt`/`dnf` по наличию бинаря / +`/etc/os-release`) → **точная команда** установки (чистая функция «дистрибутив+пакет → команда»); +печать команды; для безопасных — предложение выполнить с согласия (`y/N`, `input()`); отказ/нет +TTY/неизвестный дистрибутив → печать инструкции и `exit 2` (никакой молчаливой root-мутации). +Привязка: BR-3, AC-3. + +### FR-4 — Детект существующих Plane/Gitea + выбор (D-2) +Best-effort обнаружение (never-raise, NFR-3): кандидаты из `docker ps` (имена/образы, похожие на +Plane: `plane-*`/`makeplane`/`proxy`; Gitea: `gitea/gitea`/`gitea-*`) и из слушающих портов +(типовые Plane 80/8080/443, Gitea 3000). По кандидату — проба живости (Plane: `GET /api/instances/`; +Gitea: `GET /api/v1/version`). Чистая функция формирует ранжированный список кандидатов. Поведение: +0 кандидатов → запрос ручного URL; ≥2 → нумерованный список + выбор (`input()` индекс), вне +диапазона → ручной ввод; 1 → предложить с подтверждением. Выбор наполняет `ORCH_PLANE_*` / +`ORCH_GITEA_*`. Привязка: BR-5, AC-4. + +### FR-5 — Интерактивный сбор данных + живая верификация +Honest-checkpoint контракт (как `bootstrap_bundle.manual_checkpoint`): для каждого требуемого +секрета/параметра — печать откуда взять (ссылка на LITE_SETUP §5–§8), скрытый ввод секрета +(`getpass`), **верификация ДО записи**: Plane — `GET /api/v1/workspaces//projects/` с +`X-API-Key`; Gitea — `GET /api/v1/user` с `Authorization: token`; Telegram — `GET /bot/getMe`. +Провал → повтор (до N) или `exit 2` с подсказкой, значение **не пишется**. Авто-детект и +пред-заполнение всего детектируемого (uid/gid/docker-gid/порты/пути/node/claude/выбранные URL) — +оператор только подтверждает. Привязка: BR-4, AC-5. + +### FR-6 — Выпуск webhook-секретов кирпичом `gen_secrets.py` +`ORCH_PLANE_WEBHOOK_SECRET` / `ORCH_GITEA_WEBHOOK_SECRET` выпускаются **строго** субпроцессом +`gen_secrets.py` (никакого собственного `secrets.token_hex` в установщике — анти-форк, AC-7); если +уже присутствуют в `.env` и валидны — пропуск (не перетирать без `--force`). Привязка: BR-6, NFR-2. + +### FR-7 — Сборка `.env` / `.env.watchdog` +Идемпотентный рендер из канона (`render_env`-паттерн): существующий файл — обновить ключи- +override, отсутствующий — отрендерить из `.env.example` / `.env.watchdog.example`; комментарии +канона сохранены; неизвестные ключи — управляемым блоком в конец; запись правами **600**; значения +секретов в stdout/лог **не попадают**; молчаливой перезаписи нет. Watchdog-ключи (`WATCHDOG_TG_*`) +кладутся **только** в `.env.watchdog` (файл-носитель, LITE_SETUP §4.3). Привязка: BR-6, NFR-2. + +### FR-8 — Подъём стека + готовность +`docker compose up -d --build` ровно `orchestrator` + `orchestrator-watchdog` (staging НЕ +поднимается — за профилем). Ожидание готовности поллингом `GET /health` (таймаут). Перед записью +`ORCH_PROJECTS_JSON` стек уже жив. Привязка: BR-1. + +### FR-9 — Регистрация проекта кирпичом `onboard_project.py` +Сбор параметров проекта (имя/repo/prefix/стек/тест-команда/порты/webhook-URL — флаги или интерактивно), +вызов `onboard_project.py apply` затем `verify` субпроцессом; парс merged-`ORCH_PROJECTS_JSON` из +отчёта и запись в `.env`; ручные пункты отчёта (manual-step) пробрасываются оператору. **Никакого +собственного создания статусов/лейблов/репо** (анти-форк, 22 статуса — только онбординг-кирпич). +Привязка: BR-6, AC-7. + +### FR-10 — Health-верификация + сводка +После `apply` (и в `verify`): `GET /health` → 200, `GET /queue` / `GET /metrics` → валидный JSON. +Итоговая сводка по шагам (`ok`/`skipped`/`manual-step`) + общий вердикт; любой FAIL → `exit 1` с +диагностикой (хвост `docker logs` / снапшот). Привязка: BR-7, AC-11. + +### FR-11 — Синхронизация документации +`LITE_SETUP.md` дополняется указателем «рекомендованный путь — `install_lite.py`; ручной маршрut +ниже как фолбэк/референс». Обновляются `CLAUDE.md` (раздел тиража), `docs/architecture/README.md` +(Type A), `docs/overview/` (если затронута витрина), `CHANGELOG.md`. Привязка: BR-9, AC-12. + +## 4. Изменения API +**Нет.** Установщик — вне процесса орка; обращается только к существующим read-only эндпоинтам +(`/health`, `/queue`, `/metrics`) как HTTP-клиент и к собственным Plane/Gitea/Telegram заказчика. +Новых/изменённых эндпоинтов оркестратора не вводится. + +## 5. Изменения схемы БД +**Нет.** Установщик не касается БД оркестратора (её создаёт сам орк пустой при первом старте, +stateless-инвариант LITE_SETUP §12). + +## 6. Требования к новым/изменённым QG checks +**Нет.** `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` / machine-verdict ключи — байт-в-байт не +трогаются (INV-1). Установщик не участвует в решении ни одного гейта. + +## 7. Конфигурация +Новых **рантайм**-ключей `config.py` / kill-switch — **нет** (NFR-1; активация = явный запуск). +Установщик читает/пишет только `.env` / `.env.watchdog` (канон ключей — `.env.example` / +`.env.watchdog.example`, ORCH-101). CLI-флаги установщика (имена — OQ-7): режим + параметры проекта +для `onboard_project.py` (`--repo`/`--prefix`/`--stack`/…), возможный `--force` (перевыпуск +секретов), возможный `--non-interactive`/значения из env для CI. + +## 8. Наблюдаемость +- Прогресс-лог по шагам (`ok`/`skipped`/`manual-step`/`error`) — **без значений секретов** (только + имена ключей/пути файлов, NFR-2). +- Итоговая сводка PASS/FAIL + код выхода `0/2/1`. +- `manual_checkpoint` печатает точную инструкцию и верифицирует результат (молчаливый пропуск + запрещён); без TTY → `exit 2` с той же инструкцией. + +## 9. Артефакты pipeline (создаются/обновляются) +- `scripts/install_lite.py` (новый исполняемый артефакт). +- `tests/test_install_lite_script.py` (новый), `tests/test_lite_setup_doc.py` (обновление). +- `docs/work-items/ORCH-104/06-adr/ADR-001-.md` (архитектор) + опц. сквозной + `docs/architecture/adr/adr-NNNN-*.md`. +- `docs/deployment/LITE_SETUP.md`, `CLAUDE.md`, `docs/architecture/README.md`, `docs/overview/`, + `CHANGELOG.md` — обновления (BR-9). + +## 10. Совместимость / регресс +Аддитивно: новый файл-скрипт + новый тест + правки docs. Существующие кирпичи (`gen_secrets.py`, +`onboard_project.py`) и compose — байт-в-байт. Полный регресс `pytest tests/ -q` остаётся зелёным. +Обратимость — тривиальная (удаление нового файла/теста). Область раската — только хосты заказчиков +Lite; **наш прод не затронут** (установщик исполняется на чужом хосте, говорит только с локальным +хостом и инфраструктурой заказчика). + +## 11. Инварианты (не нарушать) +- **INV-1** — `src/**` / `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / + схема БД — байт-в-байт. +- **INV-2** — Каноны не форкаются: секреты — `gen_secrets.py`; статусы/лейблы/репо/webhook — + `onboard_project.py`; env — из `.env.example`/`.env.watchdog.example`; стек — из `docker-compose.yml`. +- **INV-3** — Нет delete-операций (никаких `docker … rm`, `rm -rf`, удаления веток, force-push). +- **INV-4** — Секреты не печатаются; `.env`/`.env.watchdog` — права 600; без молчаливой перезаписи. +- **INV-5** — Никогда не трогает наш прод / `main` / force-push; говорит только с локальным хостом и + собственными Plane/Gitea/Telegram заказчика. +- **INV-6** — Платформенные конвенции: репо `orchestrator`; имена compose-сервисов/профиля — + константы; staging за профилем; `ORCH_STAGING_PORT` ≠ прод-порт (guard ORCH-058) — установщик + уважает, не форкает. +- **INV-7** — stdlib-only (NFR-6). + +## 12. Открытые вопросы для архитектора (OQ — не блокируют анализ) +- **OQ-1** — Финальное имя/путь: `scripts/install_lite.py` (понятно конечному оператору) vs + `scripts/bootstrap_lite.py` (симметрия с `bootstrap_bundle.py`). Рекомендация анализа — + `install_lite.py`. +- **OQ-2** — Точные эвристики детекта Plane/Gitea (паттерны имён/образов контейнеров, набор + портов/URL-проб, ранжирование уверенности). +- **OQ-3** — Какие зависимости считать «безопасными для авто-выполнения с согласия» (напр. + `pip install -r requirements.txt` в venv — да; `apt install docker` под sudo — только consent; + claude CLI через npm); владелец distro-команд-карты. +- **OQ-4** — Общий код с `bootstrap_bundle.py` (вынести `parse_env`/`render_env`/`manual_checkpoint` + в общий stdlib-модуль) vs самодостаточный один файл (ради «1 установочный файл» и stdlib-only). + Trade-off DRY ↔ простота/одно-файловость. +- **OQ-5** — Драйвить ли путь Б Plane-webhook (raw-SQL, LITE_SETUP §5.4) автоматически (как + `bootstrap_bundle.step_plane_webhook`) или всегда оставлять верифицируемым manual-step. +- **OQ-6** — Подтвердить: интерактивный логин claude CLI остаётся manual-step с верификацией + (`claude --version` + читаемость кред), не автоматизируется. +- **OQ-7** — Набор CLI-флагов/env для не-интерактивного (CI) прогона: какие входы принимают + флаги/env vs только prompt. diff --git a/docs/work-items/ORCH-104/03-acceptance-criteria.md b/docs/work-items/ORCH-104/03-acceptance-criteria.md new file mode 100644 index 0000000..ccf8ca1 --- /dev/null +++ b/docs/work-items/ORCH-104/03-acceptance-criteria.md @@ -0,0 +1,171 @@ +--- +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-104 — Установочный скрипт для Lite + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/tester проверяют их буквально по файлам репозитория и поведению +скрипта (фейки HTTP/процессов — в тестах; сеть/docker в unit не нужны). + +> Область: автоматизация LITE_SETUP.md §2–§11 одним установщиком (scripts+docs+tests). Подъём самих +> Plane/Gitea (Bundled), teardown, правки рантайма — вне области (см. 01-brd §2). + +--- + +## AC-1 — Единый entry-point и режимы + +**Условие:** `python3 scripts/install_lite.py` (дефолт-режим `plan`) запущен из корня чекаута. +- **PASS:** печатается нормативный план шагов + read-only preflight-диагностика; **ноль мутаций** + (никаких записей в `.env`/`.env.watchdog`, никаких `docker compose up`); exit `0` (предусловия + чисты) либо `2` (есть блокеры). Поддержаны режимы `plan`/`apply`/`verify` (argparse). +- **FAIL:** режим `plan` что-то пишет/поднимает; нет одного из режимов; неизвестный код выхода + (не из `{0,1,2}`). + +--- + +## AC-2 — Скан предусловий выявляет нехватку + +**Условие:** на хосте отсутствует одно из предусловий (напр. `node` или свободный прод-порт). +- **PASS:** `plan`/preflight перечисляет отсутствующее как блокер с понятной строкой и подсказкой + (что доустановить / какой порт занят); при всех присутствующих — блокеров нет, разрешён `apply`. +- **FAIL:** отсутствующая зависимость не отмечена; ложный блокер при фактически выполненном + предусловии; preflight роняет скрипт исключением. + +--- + +## AC-3 — Управляемая установка зависимостей (D-1) + +**Условие:** недостающая зависимость, для которой определён пакетный менеджер хоста (`apt`/`dnf`). +- **PASS:** установщик печатает **точную команду** установки под обнаруженный дистрибутив; авто- + выполнение — только после явного согласия (`y/N`); при отказе / отсутствии TTY / неизвестном + дистрибутиве — печатает инструкцию и завершает `exit 2`, **не выполнив ни одной root-мутации + молча**. +- **FAIL:** скрипт ставит системный пакет без подтверждения; команда не соответствует дистрибутиву; + неизвестный дистрибутив приводит к падению вместо инструкции. + +--- + +## AC-4 — Детект существующих Plane/Gitea и выбор (D-2) + +**Условие:** на хосте присутствует ≥2 кандидата Plane (или Gitea); отдельный прогон — 0 кандидатов. +- **PASS:** при ≥2 — установщик показывает **нумерованный список** обнаруженных инсталляций и + принимает выбор оператора; выбранный URL наполняет `ORCH_PLANE_*`/`ORCH_GITEA_*`. При 0 — + запрашивает ручной ввод URL (фолбэк). Выбор/ввод проходит живую верификацию (AC-5) до записи. +- **FAIL:** при нескольких инсталляциях выбор не предложен (молча берётся первая/случайная); нет + ручного фолбэка при 0 кандидатов; сбой детекта роняет установщик (нарушение never-raise NFR-3). + +--- + +## AC-5 — Живая верификация введённых данных ДО записи + +**Условие:** оператор вводит токен/URL Plane, Gitea или Telegram. +- **PASS:** введённое значение проверяется онлайн перед записью (Plane `GET …/projects/`; Gitea + `GET /api/v1/user`; Telegram `getMe`); неверное → отклоняется (повтор до N либо `exit 2` с + подсказкой) и **в `.env` не пишется**; верное → принимается и пишется. Секреты вводятся скрыто + (`getpass`). +- **FAIL:** неверный секрет записан в `.env`; верификация отсутствует/после записи; секрет виден на + экране при вводе. + +--- + +## AC-6 — Гигиена секретов + +**Условие:** прогон `apply`, затем повторный `apply`. +- **PASS:** ни одно значение секрета не появляется в stdout/логах (только имена ключей/пути); + `.env` и `.env.watchdog` создаются правами **600**; повторный прогон **не перетирает** уже + присутствующие значения секретов без `--force`/явного согласия. +- **FAIL:** секрет утёк в вывод/лог; права файла шире 600; повтор молча перезаписал существующий + секрет. + +--- + +## AC-7 — Каноны переиспользуются, не форкаются + +**Условие:** ревизия `scripts/install_lite.py` и его прогон. +- **PASS:** webhook-секреты выпускаются вызовом `gen_secrets.py`; проект регистрируется вызовом + `onboard_project.py apply`+`verify`; `.env` рендерится из `.env.example`. Структурный тест + подтверждает ссылки на оба кирпича и **отсутствие форка** канона статусов Plane (нет + захардкоженных 22 имён статусов) и собственной генерации секретов. +- **FAIL:** установщик сам генерирует webhook-секреты / сам создаёт статусы-лейблы-репо / сам + выдумывает ключи env вместо рендера из канона. + +--- + +## AC-8 — Идемпотентный apply + +**Условие:** два последовательных `apply` (фейки Plane/Gitea/процессов в тесте). +- **PASS:** второй прогон помечает завершённые шаги как `skipped`; дублей проекта/webhook не + создаётся; значения `.env` стабильны; exit-код консистентен (`0`, либо `2` если остался + manual-step). +- **FAIL:** повтор создаёт дубли / перевыпускает секреты / падает на уже сделанном шаге. + +--- + +## AC-9 — Рантайм не затронут + +**Условие:** диф PR ORCH-104. +- **PASS:** изменены только `scripts/**`, `docs/**`, `tests/**` (+ `CLAUDE.md`/`CHANGELOG.md`); + `src/**`, `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, machine-verdict ключи, схема БД — + **байт-в-байт** (снапшот-тест инвариантов зелёный, не изменён этим PR). +- **FAIL:** PR трогает `src/**` / реестр стадий / гейты / схему БД; снапшот-тест инвариантов + изменён или падает. + +--- + +## AC-10 — stdlib-only, без delete-операций, без импорта платформы + +**Условие:** структурный анализ `scripts/install_lite.py` (AST/токенайз/grep). +- **PASS:** только stdlib-импорты (из разрешённого набора); нет `from src …`/`import src`; нет + деструктивных операций (`docker … rm`, `rm -rf`, удаление веток, `git push --force`); каноны- + знания — только субпроцессами кирпичей. +- **FAIL:** найден сторонний/платформенный импорт; найдена delete/force-операция; найден захардкод + хост-литералов (переиспользуется `FORBIDDEN`-набор `tests/test_no_host_hardcodes.py`). + +--- + +## AC-11 — Health-гейт и итоговый вердикт + +**Условие:** успешный `apply`, затем `verify`. +- **PASS:** `GET /health` → 200, `GET /queue` и `GET /metrics` → валидный JSON; итоговая сводка + печатает PASS; любой FAIL шага даёт `exit 1` с диагностикой. `verify` (read-only) повторяет + health-контракты + `onboard_project.py verify`. +- **FAIL:** скрипт рапортует успех при неотвечающем `/health`; нет финальной сводки; FAIL не + даёт `exit 1`. + +--- + +## AC-12 — Синхронизация документации + +**Условие:** ревизия `docs/deployment/LITE_SETUP.md` и анти-дрейф теста. +- **PASS:** LITE_SETUP.md содержит указатель на установщик как рекомендованный путь (ручной + маршрут сохранён фолбэком); `tests/test_lite_setup_doc.py` ассертит наличие ссылки и **остаётся + зелёным** во всех существующих проверках (13 разделов, кирпичи, key-sync); обновлены + `CLAUDE.md`/`CHANGELOG.md` (и витрина `docs/overview/`, если затронута). +- **FAIL:** установщик добавлен, но LITE_SETUP.md/`CLAUDE.md` о нём молчат; анти-дрейф тест не + обновлён/красный (reviewer-ось обзорных доков, ORCH-079/011 → finding ≥P1). + +--- + +## Сводная матрица AC ↔ BR/FR/NFR +| AC | Покрывает | Тип проверки | +|----|-----------|--------------| +| AC-1 | BR-1 / FR-1 | integration (plan zero-mutation) | +| AC-2 | BR-2 / FR-2 | unit (preflight verdict) | +| AC-3 | BR-3 / FR-3 / D-1 | unit + integration (consent / no-TTY) | +| AC-4 | BR-5 / FR-4 / D-2 | unit (discovery/select) | +| AC-5 | BR-4 / FR-5 | integration (verify-before-write) | +| AC-6 | BR-8 / NFR-2 | unit + integration (mask / 600 / no-overwrite) | +| AC-7 | BR-6 / FR-6 / FR-9 / INV-2 | structural (reuse bricks, anti-fork) | +| AC-8 | BR-7 / NFR-5 | integration (idempotency) | +| AC-9 | BR-8 / NFR-1 / INV-1 | structural (invariant snapshot) | +| AC-10 | BR-8 / NFR-4 / NFR-6 / INV-3 / INV-7 | structural (AST/grep) | +| AC-11 | BR-7 / FR-10 | integration (health gate) | +| AC-12 | BR-9 / FR-11 | structural (doc anti-drift) | diff --git a/docs/work-items/ORCH-104/04-test-plan.yaml b/docs/work-items/ORCH-104/04-test-plan.yaml new file mode 100644 index 0000000..904009a --- /dev/null +++ b/docs/work-items/ORCH-104/04-test-plan.yaml @@ -0,0 +1,150 @@ +work_item: ORCH-104 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-12 +model_used: claude-opus-4-8 +title: "Установочный скрипт для Lite — интерактивный установщик scripts/install_lite.py" +framework: pytest +scope: > + FR-1..FR-11 / AC-1..AC-12. Покрывается поведение установщика (чистые функции + step-движок + с инъекцией фейков HTTP/процессов) и анти-дрейф (stdlib-only, no-delete, reuse-bricks, doc-sync, + invariant snapshot). ВНЕ объёма тестов: реальная установка пакетов, реальный docker compose up, + реальные Plane/Gitea/Telegram (unit/integration детерминированы, без сети/docker/LLM — NFR-7). +notes: > + Зеркало паттернов tests/test_bootstrap_script.py и tests/test_lite_setup_doc.py: модуль грузится + через importlib; side-effects (subprocess/HTTP/getpass/input) инъектируются фейками; чистые + функции (parse_env/render_env/preflight_verdict/distro-команда/discovery/select) тестируются + изолированно. Полный регресс `pytest tests/ -q` обязан оставаться зелёным. Имена функций ниже — + ориентир по образцу bootstrap_bundle.py; финальные сигнатуры уточняет архитектор/разработчик. + +tests: + # ---- FR-1 / AC-1: entry-point и режимы ---- + - id: TC-01 + type: integration + description: "plan-режим (дефолт) печатает план + preflight и НЕ делает мутаций (нет записи .env, нет compose up); exit 0 при чистых предусловиях, 2 при блокерах." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-02 + type: unit + description: "build_plan() возвращает нормативный список шагов Lite в правильном порядке (preflight→deps→discovery→inputs→secrets→env→up→onboard→health)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-2 / AC-2: скан предусловий ---- + - id: TC-03 + type: unit + description: "preflight-вердикт: отсутствие docker/compose/node, занятый прод-порт, mismatch uid:gid владельца repos-dir → блокеры; полностью укомплектованный хост → блокеров нет." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-3 / AC-3 / D-1: управляемая установка зависимостей ---- + - id: TC-04 + type: unit + description: "distro→команда: apt-хост даёт apt-команду установки, dnf-хост — dnf-команду; неизвестный дистрибутив → деградация на текстовую инструкцию (без команды-мутации)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-05 + type: integration + description: "remediation: при отказе оператора (ответ 'N') и при отсутствии TTY установщик НЕ выполняет установку, печатает инструкцию и возвращает exit 2 (никакой молчаливой root-мутации)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-4 / AC-4 / D-2: детект Plane/Gitea + выбор ---- + - id: TC-06 + type: unit + description: "discovery: по фейковым фактам docker ps/портов с 2 кандидатами Plane возвращается ранжированный список из 2; 0 кандидатов → пустой список (триггерит ручной фолбэк)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-07 + type: unit + description: "select_candidate: валидный индекс выбирает кандидата; индекс вне диапазона/пустой ввод → режим ручного ввода URL (never-raise)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-08 + type: unit + description: "detect never-raise: исключение в пробе docker/порта/URL деградирует в 'кандидатов нет' и не роняет установщик (NFR-3)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-5 / AC-5: живая верификация ввода ---- + - id: TC-09 + type: integration + description: "verify-before-write: фейк Plane/Gitea, отвечающий 401, отклоняет токен — значение НЕ пишется в .env; ответ 200 принимает и пишет. Telegram getMe ok:false → отклонение." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-6 / FR-7 / AC-6 / AC-7: секреты и env ---- + - id: TC-10 + type: unit + description: "parse_env round-trip: KEY=value строки → словарь, комментарии/пустые игнорируются." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-11 + type: unit + description: "render_env: ключи-override обновляются в каноне, комментарии сохранены, неизвестные ключи дописываются управляемым блоком; идемпотентно (повторный рендер стабилен)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-12 + type: integration + description: "секрет-гигиена: после прогона ни одно секрет-значение не встречается в собранном stdout/логе (маскирование); записанные .env/.env.watchdog имеют права 600." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-13 + type: integration + description: "no-silent-overwrite: при уже заполненных секретах повторный apply без --force их не перетирает (значения стабильны); webhook-секреты выпускаются вызовом gen_secrets.py, а не собственным кодом." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-9 / AC-7: онбординг кирпичом ---- + - id: TC-14 + type: integration + description: "onboard reuse: установщик вызывает onboard_project.py apply+verify субпроцессом (фейк), парсит merged ORCH_PROJECTS_JSON из отчёта и пишет его в .env; ручные пункты отчёта пробрасываются (exit 2)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- FR-1 / FR-10 / AC-8 / AC-11: идемпотентность, health, no-TTY ---- + - id: TC-15 + type: integration + description: "идемпотентность: два apply подряд (фейки) → второй помечает шаги skipped, без дублей проекта/webhook, значения .env стабильны." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-16 + type: integration + description: "no-TTY fail-closed: stdin не tty → manual_checkpoint печатает инструкцию и поднимает остановку → exit 2 (никакого зависания на input)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-17 + type: integration + description: "health-гейт: фейк отвечает 200 на /health и валидным JSON на /queue,/metrics → сводка PASS, exit 0; /health не 200 → exit 1 с диагностикой (нет ложного success)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- AC-9 / AC-10 / INV: структурные анти-дрейф ---- + - id: TC-18 + type: unit + description: "stdlib-only + no-src-import: AST-скан install_lite.py — импорты только из разрешённого stdlib-набора; нет 'from src'/'import src' (зеркало test_bootstrap_script)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-19 + type: unit + description: "no-delete-ops: AST/regex-скан не находит деструктивных операций (docker … rm/down -v, rm -rf, git push --force, удаление веток) и хост-хардкодов (FORBIDDEN-набор test_no_host_hardcodes)." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-20 + type: unit + description: "reuse-bricks / anti-fork: скрипт ссылается на gen_secrets.py и onboard_project.py; не несёт захардкоженного канона имён статусов Plane (FORBIDDEN_STATUS_NEEDLES) и собственной генерации секретов." + module: tests/test_install_lite_script.py + expected: PASS + - id: TC-21 + type: unit + description: "invariant snapshot: STAGE_TRANSITIONS/QG_CHECKS не изменены этим PR (существующий снапшот-тест зелёный; ORCH-104 их не трогает)." + module: tests/test_install_lite_script.py + expected: PASS + + # ---- AC-12: синхронизация документации ---- + - id: TC-22 + type: unit + description: "doc-sync: tests/test_lite_setup_doc.py ассертит, что LITE_SETUP.md ссылается на install_lite.py как рекомендованный путь; все существующие проверки дока (13 разделов, кирпичи, env key-sync, секрет-гигиена) остаются зелёными." + module: tests/test_lite_setup_doc.py + expected: PASS diff --git a/docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md b/docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md new file mode 100644 index 0000000..d0af123 --- /dev/null +++ b/docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md @@ -0,0 +1,354 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# ADR-001: Установщик Lite-тиража `scripts/install_lite.py` — структура, границы, переиспользование канонов + +Work Item: **ORCH-104** — Установочный скрипт для Lite (Type A эпика ORCH-10, поверх ORCH-102) +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0040-lite-installer-canon.md`** (решение +кросс-каттинговое — вводит новый операторский артефакт в семью тиража и нормативную границу +«connect-only vs bring-up»; продолжает серию adr-0036 → 0037 → 0038). + +## Статус +Proposed + +## Контекст + +ORCH-102 дал золотой источник Lite-тиража — ручной runbook `docs/deployment/LITE_SETUP.md` из 13 +разделов («голый хост → работающий конвейер»). Это надёжный, но **ручной** маршрут: оператор сам +проверяет ~6 предусловий хоста, копирует `.env.example`, гоняет `gen_secrets.py`, заполняет ~20 +ключей `.env` по 4 группам, настраивает webhook Plane (иногда прямым SQL в Postgres, §5.4), +выпускает токен/webhook Gitea, ставит claude CLI, заводит **два** Telegram-бота, поднимает compose, +регистрирует проект `onboard_project.py`, гоняет smoke. Долго; легко ошибиться; ошибка всплывает +поздно (на `docker compose up`, а не в момент ввода). + +ORCH-104 автоматизирует **happy-path §2–§11** одним интерактивным установщиком и добавляет +**раннюю валидацию** (живая проверка каждого секрета/URL ДО записи). Это **scripts + docs + tests** +изменение: рантайм/конвейер (`src/**`, `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, machine-verdict +ключи, схема БД) — **не трогаются** (NFR-1/INV-1). Каноны не форкаются: секреты — +`gen_secrets.py`, регистрация проекта — `onboard_project.py`, env — из `.env.example` / +`.env.watchdog.example`, стек — из `docker-compose.yml`. + +**Сверено по коду/репо (факты, на которых стоит решение):** +- `scripts/bootstrap_bundle.py` (ORCH-103, ~970 строк) — ближайший прецедент: step-движок + `check→ensure`, режимы `plan`/`apply`/`verify`, exit `0/2/1`, чистые `parse_env`/`render_env`/ + `preflight_verdict`/`collect_facts`, honest `manual_checkpoint` (TTY-aware, verify-loop), + `_write_private` (chmod 600, маскировка), `_http` (never-raise GET), stdlib-only, **0 + delete-операций**. Subprocess-кирпичи `gen_secrets.py --write ` и `onboard_project.py + apply/verify --json` (парс `instructions[]` на префикс `ORCH_PROJECTS_JSON=`). +- `scripts/gen_secrets.py` (ORCH-101) — единственный легитимный источник webhook-секретов + (`secrets.token_hex(32)`); `--write [PATH]` отказывает на существующем файле (exit 2), перезапись + только `--force`; stdlib-only. +- `scripts/onboard_project.py` (ORCH-009) — `plan`/`apply`/`verify` (`choices`), exit `0/1/2` + (`Report.exit_code`), `--json` → отчёт с `instructions[]`, обязательные + `--name/--repo/--prefix/--stack/--test-cmd/--prod-port/--staging-port/--webhook-url`. **22 статуса + с точными именами** — только этот кирпич (`plane_sync._PLANE_NAME_TO_KEY`). +- `tests/test_no_host_hardcodes.py` экспортирует `FORBIDDEN` + `find_violations(source, forbidden)` + (tokenize-исключение комментариев/докстрингов) — переиспользуемы по импорту (single source of + truth; так уже делает `test_lite_setup_doc.py`). +- `tests/test_lite_setup_doc.py` — структурный анти-дрейф: `SECTIONS` (13 `## N.`-заголовков **в + порядке**), список обязательных кирпичей, key-sync `.env.watchdog.example`, сверка «22 статуса» + импортом `plane_sync._PLANE_NAME_TO_KEY`, fenced-скан `FORBIDDEN` + секрет-эвристика. +- `LITE_SETUP.md` §5.4 — webhook Plane CE **не в `/api/v1`**: путь A (UI) / путь Б (raw SQL + `INSERT INTO webhooks … docker exec plane-db psql`). §7.2 — claude CLI: интерактивный логин + Anthropic. §6.4 — branch protection на `main` **не включать** (INV-4, ложные HOLD merge-актора). + +ТЗ (`02-trz.md` §12) оставило архитектору 7 открытых вопросов (OQ-1…OQ-7). Они разрешены ниже как +решения D1…D11; D12…D15 — структурные решения (анти-дрейф, doc-sync, границы scope). + +## Решение + +### Сводка + +Новый **самодостаточный** stdlib-only установщик `scripts/install_lite.py` — «connect-only»-сородич +`bootstrap_bundle.py`: тот *поднимает* Plane/Gitea, этот *подключается* к уже существующим. Тот же +проверенный каркас (step-движок `check→ensure`, режимы `plan`/`apply`/`verify`, exit `0/2/1`, +honest `manual_checkpoint`, `_write_private` 600, never-raise `_http`), реплицированный в один файл +ради «одного установочного файла» (BR-1) и нулевого риска регресса Bundled-пути. Каноны +(`gen_secrets.py`, `onboard_project.py`, `.env.example`, `docker-compose.yml`) переиспользуются +субпроцессами/рендером, не форкаются. Ключевая граница Lite: установщик говорит **только** с +локальным хостом и собственными Plane/Gitea/Telegram заказчика по их API — он **не** мутирует +чужую инфраструктуру (webhook Plane = верифицируемый manual-step, не raw-SQL). + +### D1 — Имя и идентичность: `scripts/install_lite.py` (OQ-1) + +Файл — `scripts/install_lite.py` (рекомендация анализа принята). `install_*` понятнее конечному +**оператору**, чем `bootstrap_*`, и точнее отражает суть: Lite **устанавливает/подключает** орк к +готовой инфре, тогда как `bootstrap_bundle.py` **бутстрапит** весь стек (включая Plane/Gitea). +Несимметрия имён — намеренный сигнал разной семантики (D9). Привязка: BR-1, AC-1. + +### D2 — Самодостаточный один файл, не общий модуль (OQ-4) + +`install_lite.py` **реплицирует** небольшие проверенные примитивы `bootstrap_bundle.py` +(`parse_env`/`render_env`/`manual_checkpoint`/`_http`/`_write_private`/`collect_facts`-стиль/ +`preflight_verdict`-стиль/exit-контракт) **внутри себя**, а не импортирует общий модуль. + +**Почему так, а не DRY-модуль:** +- **«Один установочный файл» (BR-1)** — оператор копирует/запускает один артефакт; скрытый граф + импортов противоречит UX-цели. +- **`bootstrap_bundle.py` остаётся байт-в-байт (TRZ §2).** Вынос общего кода потребовал бы + *править* существующий кирпич → перегон его анти-дрейф-тестов (`test_bootstrap_script.py`) и + риск регресса **Bundled**-пути. Это scope-creep и риск ради чужой задачи. +- **stdlib-only (NFR-6/INV-7)** соблюдается тривиально в обоих вариантах — DRY его не решает. +- Цена — ~150 строк дублирования примитивов; они малы, генеричны и стабильны (низкий риск дрейфа, + т.к. два скрипта автоматизируют **разные** runbook'и). +- **Триггер будущего выноса (rule of three):** появится **третий** тиражный скрипт с теми же + примитивами → вот тогда выносить `scripts/_replication_lib.py` отдельной задачей (с правкой обоих + существующих под её анти-дрейф). До тех пор — дублирование осознанное и документированное. + +Привязка: BR-6, NFR-6, INV-2/INV-7, AC-7/AC-10. + +### D3 — Step-движок и режимы: канон `bootstrap_bundle.py` (FR-1) + +Каркас 1:1 с эталоном: +- **Режимы** (argparse, позиционный `mode`, `choices=("plan","apply","verify")`, дефолт `plan`): + `plan` — ноль мутаций (печать плана + read-only preflight); `apply` — полный прогон step-движком; + `verify` — read-only пост-проверка (health-контракты + `onboard_project.py verify`). +- **Exit-контракт:** `0` успех; `2` — manual-step / незавершённое предусловие / нет TTY; `1` — + ошибка. Никаких иных кодов (AC-1 FAIL). +- **Step-движок `check→ensure`:** упорядоченный `APPLY_STEPS = ((name, fn), …)`, каждая + `fn(ctx) -> str` возвращает `"ok"`/`"skipped"`/`"manual-step"` либо бросает `ManualStop`(→2)/ + `InstallError`(→1) (ловятся в `main`). Идемпотентность — встроенная inline-проверка «уже + сделано» в каждом шаге (повтор = каскад `skipped`, «resume» = повторный запуск). Результаты — + `ctx["results"]`, печатаются в итоговой сводке. +- **Шаги Lite (порядок):** `preflight` (D4) → `detect_infra` (D5) → `collect_verify` (D6) → + `secrets` (D7) → `env` (D7) → `up` (D8) → `onboard` (D8) → `plane_webhook` (D9, manual) → + `health` (D12). + +Привязка: BR-1, BR-7, FR-1, AC-1, AC-8. + +### D4 — Preflight-скан и управляемая установка зависимостей (FR-2/FR-3, OQ-3) + +**Скан (FR-2):** read-only `collect_facts` (образец эталона) — наличие `docker` / `docker compose` +v2 / `git` / `python3` / `node` / `claude` (+ читаемость кред); свободность прод-порта +`ORCH_DEPLOY_PROD_TARGET_PORT` (дефолт 8500; при self-hosting-вилке staging +`ORCH_STAGING_PORT`=8501); `uid`/`gid`/`docker-gid` и владелец каталога репозиториев; наличие +ssh-каталога. Чистая `preflight_verdict(facts) -> (blockers, warnings)` + человекочитаемый список +«есть/нет». `plan`/preflight печатает его и при блокерах отдаёт `exit 2`. + +**Управляемая установка (FR-3, D-1 владельца):** в Lite реалистично **все** недостающие +зависимости (docker, node, `@anthropic-ai/claude-code` через npm, git) — системного/root-уровня. +Поэтому **тира «тихого авто-инсталла» нет вообще** — это самый безопасный разрез D-1: +- чистая `install_command(distro, dep) -> str | None` (карта: debian/ubuntu→`apt`, rhel/fedora/ + centos→`dnf`; дистрибутив — из `/etc/os-release` `ID`/`ID_LIKE` + `shutil.which`) печатает + **точную команду**; +- выполнение — **только** при (a) наличии TTY, (b) явном `y/N`=yes, (c) команда из фиксированного + allowlist (никакого произвольного exec); +- отказ / нет TTY / неизвестный дистрибутив (`install_command` → `None`) → печать инструкции + + `exit 2`. **Ни одной молчаливой root-мутации** (AC-3 FAIL). + +Привязка: BR-2/BR-3, FR-2/FR-3, AC-2/AC-3, NFR-8. + +### D5 — Детект существующих Plane/Gitea + выбор (FR-4, OQ-2) + +Best-effort обнаружение (never-raise, NFR-3); его роль — лишь **пред-заполнить догадку**, +авторитет — живая верификация D6 перед записью (митигейшн R-1). +- **Кандидаты из двух источников:** (1) `docker ps` (имена/образы) — Plane: образ/имя + `makeplane/plane-*`, `plane-{web,space,admin,api,proxy}`, `proxy`; Gitea: образ `gitea/gitea`, + имя `gitea*`. (2) Слушающие localhost-порты (stdlib socket): Plane — 80/443/8080; Gitea — 3000. +- **Проба живости (ранжир уверенности, без токена):** Plane — `GET /api/instances/` + (unauth-liveness Plane CE); Gitea — `GET /api/v1/version`. Кандидат с успешной пробой + ранжируется выше; дедуп по нормализованному base-URL. +- **Чистая `rank_candidates(docker_facts, port_probes) -> [Candidate(url, source, confidence)]`** + (юнит-тестируема без сети). +- **Поведение по числу (FR-4):** 0 → запрос ручного URL (фолбэк); 1 → предложить с подтверждением; + ≥2 → **нумерованный список** + выбор (`input()` индекс), вне диапазона → ручной ввод. Выбор + наполняет `ORCH_PLANE_*` / `ORCH_GITEA_*` и проходит D6. +- **never-raise:** сбой любой пробы → кандидат отброшен, детект продолжается; тотальный сбой → + пустой список → ручной ввод (AC-4 не должен ронять скрипт). + +Привязка: BR-5, FR-4, AC-4, NFR-3. + +### D6 — Интерактивный сбор + живая верификация ДО записи (FR-5) + +Honest-checkpoint контракт (как `manual_checkpoint` эталона): для каждого требуемого секрета/URL — +печать «откуда взять» (ссылка на LITE_SETUP §5–§8), скрытый ввод секрета (`getpass`), +**верификация онлайн ДО записи**: +- Plane: `GET /api/v1/workspaces//projects/` с `X-API-Key:` → 200; +- Gitea: `GET /api/v1/user` с `Authorization: token ` → 200; +- Telegram: `GET https://api.telegram.org/bot/getMe` → `{"ok":true}`. + +Провал → повтор (до N) либо `exit 2` с подсказкой; значение в `.env` **не пишется** (AC-5 FAIL — +неверный секрет записан / верификация после записи / секрет виден на экране). Авто-детектируемое +(uid/gid/docker-gid/порты/пути/node/claude/выбранные URL) **пред-заполняется** — оператор только +подтверждает. Привязка: BR-4, FR-5, AC-5, NFR-2. + +### D7 — Секрет-гигиена и рендер `.env`/`.env.watchdog` (FR-6/FR-7, NFR-2) + +- **Webhook-секреты — строго кирпич** `gen_secrets.py` субпроцессом во временный файл, парс + `parse_env`. **Никакого собственного `secrets.token_hex`/`token_urlsafe` в установщике** + (анти-форк, проверяется AST-сканом теста, AC-7). Уже присутствуют и валидны → skip (не + перетирать без `--force`). +- **Рендер** — идемпотентный `render_env(example_text, overrides)` из `.env.example` / + `.env.watchdog.example`: существующие ключи-override обновляются на месте (комментарии канона + сохранены), неизвестные — управляемым блоком в конец. Watchdog-ключи (`WATCHDOG_TG_*`) — + **только** в `.env.watchdog` (файл-носитель, LITE_SETUP §4.3; в `.env` они инертны). +- **Гигиена:** запись через `_write_private` (chmod **600**); значения секретов в stdout/лог + **не попадают** (лог печатает только имена ключей/пути); молчаливой перезаписи нет. + +Привязка: BR-6, FR-6/FR-7, AC-6, NFR-2, INV-4. + +### D8 — Подъём стека + регистрация проекта кирпичом (FR-8/FR-9) + +- **Up:** `docker compose up -d --build` ровно `orchestrator` + `orchestrator-watchdog` (staging НЕ + поднимается — за профилем); ожидание готовности поллингом `GET /health` (таймаут). +- **Onboarding — строго кирпич** `onboard_project.py`: сбор параметров проекта (флаги или + интерактивно), `apply` затем `verify` субпроцессом (`--json`); парс `instructions[]` на + `ORCH_PROJECTS_JSON=` → запись в `.env` через `render_env`; manual-пункты отчёта (exit 2) + пробрасываются оператору. **Никакого собственного создания статусов/лейблов/репо/webhook** + (22 статуса — только кирпич, AC-7). После записи реестра — управляемый рестарт + `up -d --force-recreate orchestrator` в тихом окне (как LITE_SETUP §10). + +Привязка: BR-1/BR-6, FR-8/FR-9, AC-7/AC-8. + +### D9 — Webhook Plane = всегда верифицируемый manual-step (OQ-5) — граница connect-only + +**Ключевое отличие Lite от Bundled.** `bootstrap_bundle.py::step_plane_webhook` *владеет* +Plane-Postgres (сам его поднял) и пробует прямой `INSERT` с manual-фолбэком. В Lite Plane — **чужая +инсталляция заказчика** (D-2): установщик может быть не на одном хосте с Plane, не иметь +docker-доступа к её БД, и raw-SQL в чужую БД — ровно та кросс-граничная мутация, что запрещает +INV-5. Поэтому webhook Plane в Lite — **всегда honest manual-step, никогда авто-SQL**: +- инструкция пути A (UI Plane: URL `…/webhook/plane`, secret `ORCH_PLANE_WEBHOOK_SECRET`, события + Issue + Issue Comment); +- путь Б (raw-SQL из LITE_SETUP §5.4) — **печатается как инструкция** для Plane CE без UI, но + установщиком **не исполняется**; +- **верификация** — фактическую запись webhook в Plane CE через `/api/v1` прочитать нельзя → + истинный гейт webhook'а = **smoke** (D12 / §11): реальная задача, переведённая в «To Analyse», + даёт analyst-job в `/queue`. `manual_checkpoint` фиксирует факт «оператор настроил» и сообщает, + что финальная проверка — на smoke. + +Привязка: BR-8, OQ-5, AC-5/AC-11, INV-3/INV-5. + +### D10 — Логин claude CLI = manual-step с верификацией (OQ-6) + +Интерактивный логин — поток Anthropic, **не автоматизируется** (подтверждено). Установщик: +детект `claude` (`shutil.which`/`claude --version`); отсутствует → consent-gated install (D4, +`npm install -g @anthropic-ai/claude-code`); логин → `manual_checkpoint`: инструкция «выполнить +`claude` на хосте под deploy-user», **верификация** = `claude --version` OK **и** читаемость +кред-файла под run-uid (`~/.claude/.credentials.json`, как LITE_SETUP §7.2). Привязка: OQ-6, AC-3, +NFR-3. + +### D11 — Не-интерактивный / CI-контур (OQ-7) + +- **Флаги:** `mode` (plan|apply|verify); `--force` (перевыпуск секретов → проброс `gen_secrets.py + --force`); проброс параметров проекта в `onboard_project.py` + (`--name/--repo/--prefix/--stack/--test-cmd/--prod-port/--staging-port/--description/ + --webhook-url`); `--non-interactive`. +- **Секреты — из окружения, не из argv** (анти-ps-leak, NFR-2): в не-интерактиве токены берутся из + одноимённых `ORCH_*` env-переменных / существующего `.env`, не из флагов. +- **Fail-closed:** `--non-interactive` **или** `not sys.stdin.isatty()` → любой обязательный, + но отсутствующий/непрошедший верификацию вход → `exit 2` с **точным именем** недостающего ключа, + без prompt'а (зеркало `manual_checkpoint` эталона). Скрипт пригоден для повторного/CI-прогона + (идемпотентный `apply`). + +Привязка: BR-7, FR-1, AC-8, NFR-2. + +### D12 — Health-гейт и итоговая сводка (FR-10) + +После `apply` (и в `verify`): `GET /health` → 200; `GET /queue` / `GET /metrics` → валидный JSON. +Итоговая сводка по шагам (`ok`/`skipped`/`manual-step`) + общий вердикт PASS/FAIL; любой FAIL шага → +`exit 1` с диагностикой (хвост `docker logs orchestrator --tail` / снапшот `/queue`). `verify` +(read-only) повторяет health-контракты + `onboard_project.py verify`. Привязка: BR-7, FR-10, AC-11. + +### D13 — Анти-дрейф тесты (NFR-7) + +Новый **`tests/test_install_lite_script.py`** — детерминированный (без сети/docker/subprocess/LLM; +HTTP/процессы — через инъекцию фейков): +- **Структурные:** ссылки на кирпичи (`gen_secrets.py`, `onboard_project.py`) присутствуют; **нет + собственной генерации секретов** (запрет `secrets.token_hex`/`token_urlsafe`/`token_bytes` в + файле — делегирование кирпичу, AC-7); **нет захардкоженных 22 имён статусов** (AC-7); + **stdlib-only** (AST-скан import'ов из разрешённого набора; нет `from src …`/`import src …`, + AC-10/INV-7); **нет delete/force-операций** (`rm -rf`, `docker … rm`, `down -v`, `git push + --force`, удаление веток, AC-10/INV-3); **FORBIDDEN host-литералы** — импорт `FORBIDDEN` + + `find_violations` из `test_no_host_hardcodes.py` (single source of truth, не копия, AC-10); + exit-контракт `{0,1,2}`. +- **Юнит (чистые функции):** `preflight_verdict` (блокеры/варнинги), `install_command(distro,dep)`, + `rank_candidates` (ранжир/дедуп), `parse_env`/`render_env` (round-trip, сохранение комментариев, + watchdog-ключи в свой файл), маскирование-хелпер. +- **С фейками:** verify-before-write (битый токен отклонён и не записан), идемпотентный `apply` + (второй прогон → `skipped`, без дублей), `plan` zero-mutation. + +Обновление **`tests/test_lite_setup_doc.py`**: добавить ассерт «LITE_SETUP.md ссылается на +`install_lite.py` как рекомендованный путь»; **все существующие проверки (13 разделов, кирпичи, +key-sync, 22 статуса, FORBIDDEN) остаются зелёными** и не ослабляются. Привязка: BR-9, AC-9/AC-10/ +AC-12, NFR-7. + +### D14 — Doc-sync и норматив сопровождения (FR-11, BR-9) + +- **`LITE_SETUP.md`:** указатель на установщик — **callout-врезка** (`>`-блок) в §1 (или сразу + перед §2), НЕ новый нумерованный раздел: «Рекомендованный путь — `python3 scripts/install_lite.py` + (автоматизирует §2–§11); ручной маршрут ниже — референс/фолбэк для траблшутинга». **13 `## N.` + заголовков сохранены байт-в-байт** (на них ключуется `test_lite_setup_doc.py::SECTIONS`). +- **Обновить в том же PR (golden source, правило агентов №2):** `CLAUDE.md` (раздел тиража), + `docs/architecture/README.md` (Type A — Lite ← делается этой стадией, D15), `docs/overview/` + (витрина Lite-install/использования — затронута, см. ORCH-105; финальные правки — на стадии + development), `CHANGELOG.md`. +- **NFR-5 норматив:** меняешь шаги установки → синхронизируй `LITE_SETUP.md` в том же PR (держит + анти-дрейф `test_lite_setup_doc.py`). Зеркало норматива ORCH-102/103. + +Привязка: BR-9, FR-11, AC-12. + +### D15 — Границы scope, сквозная регистрация, отсутствие kill-switch + +- **`07-infra-requirements.md` — N/A:** топология **нашего** оркестратора не меняется (установщик + вне рантайма, исполняется на хосте заказчика). Предусловия хост-контура — золотой источник + LITE_SETUP §2; форкать их в 07 запрещено каноном «link-first, не дублировать». +- **`08-data-requirements.md` — N/A:** изменений схемы БД нет (TRZ §5; БД создаёт сам орк пустой + при первом старте, stateless §12). +- **Сквозной ADR `adr-0040`** регистрирует Lite-установщик как операторский артефакт семьи тиража и + нормативную границу connect-only (D9). Детальный пакет (D1…D15) — в этом work-item ADR. +- **Kill-switch не нужен:** активация — только явный запуск оператором на его хосте (паттерн + ORCH-009/101/102/103). Рантайм байт-в-байт (INV-1). + +## Альтернативы + +- **Общий модуль `scripts/_replication_lib.py` (DRY сейчас)** — отвергнуто: потребовал бы править + замороженный `bootstrap_bundle.py` (риск регресса Bundled, TRZ §2) и противоречит «одному + установочному файлу» (BR-1). Вынос — позже, по rule-of-three (D2). +- **Имя `bootstrap_lite.py` (симметрия с bundle)** — отвергнуто: `install_*` понятнее оператору и + верно сигналит «connect-only» против «bring-up» bundle (D1). +- **Авто-драйв webhook Plane raw-SQL (как bundle `step_plane_webhook`)** — отвергнуто: в Lite Plane + чужой; raw-SQL в чужую БД нарушает INV-5 и может быть недостижим (нет docker-доступа). Только + manual-step + smoke-верификация (D9). +- **Тихий авто-install системных пакетов под sudo** — отвергнуто D-1: только consent + точная + команда из allowlist; неизвестный дистрибутив/нет TTY → инструктировать + exit 2 (D4). +- **Полноценный TUI-мастер / сторонние зависимости (rich/click)** — отвергнуто: NFR-6 stdlib-only, + работа на голом `python3` до первого `docker compose up`; `argparse`+`input`/`getpass` достаточно. +- **Замена `LITE_SETUP.md` установщиком** — отвергнуто: runbook остаётся золотым источником и + фолбэком (дополняем, не выкидываем; D14). + +## Последствия + +- **+** Оператор проходит §2–§11 одним файлом; ошибки секретов/URL ловятся **в момент ввода** + (живая верификация), а не на `docker compose up`. +- **+** Идемпотентность/повтор/CI: `plan`/`apply`/`verify`, exit `0/2/1`, fail-closed без TTY. +- **+** Каноны не форкаются → нулевой дрейф статусов/секретов/env; `bootstrap_bundle.py` и + Bundled-путь не тронуты. +- **+** Self-hosting-безопасно: только scripts/docs/tests; говорит лишь с локальным хостом и + инфрой заказчика; 0 delete-операций; наш прод не затронут (исполняется на чужом хосте). +- **−** ~150 строк дублирования примитивов с `bootstrap_bundle.py` (осознанно; rule-of-three + триггер задокументирован — D2). +- **−** Эвристика детекта Plane/Gitea ложно-(положительна/отрицательна) на нестандартных + именах/портах → митигируется ручным фолбэком и живой верификацией (D5/D6, R-1). +- **−** Webhook Plane остаётся ручным шагом (граница connect-only) → его корректность + подтверждается только на smoke (D9); это честно сообщается оператору. +- **Откат:** удаление `scripts/install_lite.py` + `tests/test_install_lite_script.py` + revert + callout-врезки `LITE_SETUP.md` и doc-правок. Состояние 1:1, ни миграций, ни рантайм-изменений. + +## Ссылки +- BRD: `docs/work-items/ORCH-104/01-brd.md` +- TRZ: `docs/work-items/ORCH-104/02-trz.md` (OQ-1…OQ-7 → D1…D11) +- Acceptance: `docs/work-items/ORCH-104/03-acceptance-criteria.md` (AC-1…AC-12) +- Tech-risks: `docs/work-items/ORCH-104/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0040-lite-installer-canon.md` +- Предшественники: adr-0036 (фундамент 10-common), adr-0037 (Lite-канон/`LITE_SETUP.md`), + adr-0038 (Bundled/`bootstrap_bundle.py`), adr-0035 (онбординг/`onboard_project.py`) +- Сверено по коду: `scripts/bootstrap_bundle.py`, `scripts/gen_secrets.py`, + `scripts/onboard_project.py`, `tests/test_no_host_hardcodes.py`, `tests/test_lite_setup_doc.py`, + `docs/deployment/LITE_SETUP.md`, `.env.example`, `.env.watchdog.example`, `docker-compose.yml` diff --git a/docs/work-items/ORCH-104/10-tech-risks.md b/docs/work-items/ORCH-104/10-tech-risks.md new file mode 100644 index 0000000..90e187f --- /dev/null +++ b/docs/work-items/ORCH-104/10-tech-risks.md @@ -0,0 +1,43 @@ +--- +work_item: ORCH-104 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-12 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-104 — Установочный скрипт для Lite + +Work Item: **ORCH-104** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации `scripts/install_lite.py` и их +> митигейшн. Опирается на BRD §8 (R-1…R-5) и архитектурные решения ADR-001 (D1…D15). + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Эвристика детекта Plane/Gitea ложно-положительна/отрицательна (нестандартные имена контейнеров/порты, reverse-proxy) → подставлен не тот URL. | Сред. | Сред. | D5: детект лишь **пред-заполняет догадку**; авторитет — живая верификация D6 (Plane `/projects/`, Gitea `/user`) ДО записи; ручной ввод URL — всегда доступный фолбэк; never-raise (NFR-3). Неверный URL не пройдёт верификацию → не запишется. | +| TR-2 | Утечка секрета в stdout/лог или перезапись существующего `.env` без согласия. | Низ. | **Выс.** | D7/NFR-2/INV-4: ввод секретов `getpass`; лог печатает только имена ключей/пути; запись `_write_private` (chmod 600); webhook-секреты — кирпич `gen_secrets.py` (отказ перезаписи без `--force`); idem-skip валидных значений. Покрыто AC-6 + AST-скан теста. | +| TR-3 | Управляемый авто-install выполняет небезопасную/неверную для дистрибутива root-команду. | Низ. | **Выс.** | D4/D-1: тихого авто-инсталла нет; выполнение только при TTY + явном `y/N` + команда из фиксированного allowlist; неизвестный дистрибутив (`install_command→None`)/нет TTY/отказ → инструкция + `exit 2`. Покрыто AC-3. | +| TR-4 | Установщик мутирует **чужую** инфраструктуру (raw-SQL в Plane-БД заказчика, правка `main`, force-push). | Низ. | **Выс.** | D9/INV-3/INV-5: webhook Plane = manual-step (печать инструкции, не исполнение SQL); 0 delete/force-операций (AST-скан AC-10); говорит только с локальным хостом и API заказчика; никогда не трогает `main`/прод. | +| TR-5 | Дрейф `install_lite.py` ↔ `LITE_SETUP.md` при будущих правках (шаги разойдутся). | Сред. | Сред. | D14/BR-9/NFR-5: норматив «меняешь шаги → обнови LITE_SETUP.md в том же PR»; анти-дрейф `test_lite_setup_doc.py` (ссылка на установщик + 13 разделов/кирпичи/key-sync зелёные). Reviewer-ось обзорных доков (ORCH-079/011). | +| TR-6 | Дублирование примитивов с `bootstrap_bundle.py` (D2) расходится со временем (поведенческий дрейф `parse_env`/`render_env`/`manual_checkpoint`). | Низ. | Низ. | D2: примитивы малы, генеричны, стабильны; скрипты автоматизируют разные runbook'и; юнит-тесты round-trip `parse_env`/`render_env` в `test_install_lite_script.py`; rule-of-three триггер выноса в общий модуль задокументирован. | +| TR-7 | Установщик случайно импортирует платформу (`src/**`) или сторонний пакет → ломает stdlib-only/работу до `docker compose up`. | Низ. | Сред. | D2/D13/NFR-6/INV-7: AST-скан import'ов из разрешённого stdlib-набора, запрет `from src …`/`import src …`; работа на голом `python3` подтверждается тестом без сети/docker. | +| TR-8 | Webhook Plane настроен оператором неверно (опечатка секрета/URL/событий) — установщик не может прочитать его в Plane CE → рапортует успех при неработающем webhook. | Сред. | Сред. | D9: истинный гейт webhook'а — **smoke** (D12/§11): задача в «To Analyse» → analyst-job в `/queue`; `manual_checkpoint` честно сообщает, что финальная проверка — на smoke; траблшутинг 401/HMAC — LITE_SETUP §13.1. | +| TR-9 | Не-интерактивный/CI-прогон без TTY молча зависает на `input()`/`getpass` или берёт неполные данные. | Низ. | Сред. | D11: `--non-interactive`/`not isatty()` → fail-closed `exit 2` с точным именем недостающего ключа; секреты из env, не из argv (анти-ps-leak); зеркало контракта `manual_checkpoint` эталона. | +| TR-10 | `apply` не идемпотентен: повтор создаёт дубль проекта/webhook, перевыпускает секреты или падает на сделанном шаге. | Низ. | Сред. | D3/D8: step-движок `check→ensure` (inline «уже сделано» → `skipped`); онбординг — идемпотентный кирпич `onboard_project.py`; секреты — idem-skip. Покрыто AC-8 (два последовательных `apply` с фейками). | + +## Сводный вывод + +Доминирующий класс — **гигиена секретов и кросс-граничные мутации** (TR-2/TR-3/TR-4): высокое +влияние при низкой вероятности, полностью снято архитектурой (getpass + 600 + кирпич-секреты; +consent-only allowlist-install; connect-only-граница без raw-SQL/delete/force). Остаточный риск для +**прод-конвейера self-hosting — нулевой**: установщик вне рантайма (`src/**`/`STAGE_TRANSITIONS`/ +`QG_CHECKS`/схема БД байт-в-байт, INV-1), исполняется на хосте заказчика и не касается ни нашего +прода, ни `main`, ни общей БД. Эскалация `arch:major-change` **не требуется** (аддитивный +scripts+docs+tests артефакт, обратимый удалением файла); возврат в анализ не нужен — все 7 OQ ТЗ +разрешимы без нарушения принципов. Ведущие реализационные риски — **дрейф док↔скрипт (TR-5)** и +**ложный детект (TR-1)**: оба со средней вероятностью и снижены анти-дрейф-тестом и обязательной +живой верификацией с ручным фолбэком. diff --git a/docs/work-items/ORCH-104/12-review.md b/docs/work-items/ORCH-104/12-review.md new file mode 100644 index 0000000..f165c7a --- /dev/null +++ b/docs/work-items/ORCH-104/12-review.md @@ -0,0 +1,134 @@ +--- +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 + +> Машинный вердикт читается ТОЛЬКО из `verdict:` во frontmatter. +> `REQUEST_CHANGES` → откат на `development`. + +## Summary + +PR в текущем виде **не содержит главного артефакта ТЗ**: `scripts/install_lite.py` существует +только как **untracked-файл** в worktree (`git status` → `?? scripts/install_lite.py`; `git +ls-files` его не знает; diff `origin/main...HEAD` — только docs + tests). При этом +`tests/test_install_lite_script.py` (735 строк, 50 тестов) **закоммичен** и жёстко ассертит +наличие скрипта → на свежем чекауте ветки CI (`.gitea/workflows/ci.yml` → `pytest tests/`) +красный. Дополнительно: даже при наличии скрипта локальный прогон даёт **47 passed / 2 failed / +1 вечно висящий**: tc19 и tc20 (needle-скан сырого исходника ловит докстринг и текст инструкции +оператору), tc17_health_fail (константный фейк `time.monotonic` → недостижимый дедлайн — +busy-loop, прогон не завершается). Doc-sync (FR-11/AC-12: LITE_SETUP.md, CLAUDE.md, CHANGELOG.md, +`test_lite_setup_doc.py`, витрина `docs/overview/`) не выполнен. + +Положительное: архитектурный пакет качественный — ADR-001 (D1–D15) разрешает все OQ-1…OQ-7 ТЗ, +сквозной `adr-0040` заведён; тест-набор содержательный (структурные анти-дрейф + юнит чистых +функций + фейки side-effects, зеркало `test_bootstrap_script.py`); вставка в +`docs/architecture/README.md` чисто аддитивна (чужие маркеры `ORCH-NNN` не тронуты — ось +трассировки чиста); сам скрипт статически соответствует ADR (кирпичи `gen_secrets.py`/ +`onboard_project.py` строго субпроцессом, `getpass`, `chmod 0o600`, `--force`-гейтинг, +webhook Plane — manual-step, никаких delete-операций). После фиксации скрипта в коммите и +исправления findings пакет близок к готовности. + +## Оси проверки + +| Ось | Статус | +|-----|--------| +| Соответствие ТЗ | ❌ FR-1…FR-10 не входят в PR (скрипт не закоммичен); FR-11 не выполнен | +| Соответствие ADR | ✅ содержимое скрипта/тестов следует ADR-001 D1–D15; ❌ D13/D14 (doc-sync) не исполнены | +| Качество кода | ❌ 2 падающих теста (tc19, tc20) + 1 вечно висящий (tc17) при локальном прогоне | +| Документация | ❌ LITE_SETUP.md / CLAUDE.md / CHANGELOG.md / `test_lite_setup_doc.py` / `docs/overview/` — ноль упоминаний ORCH-104/install_lite | +| Трассировка (TRACEABILITY) | ✅ diff аддитивен, чужие маркированные инварианты не правились | +| Инварианты (INV-1, AC-9) | ✅ `src/**` / `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — байт-в-байт | + +## Findings + +### P0 — Blocker + +- [ ] **Главный артефакт ТЗ не в PR: `scripts/install_lite.py` не закоммичен** (ТЗ §2/§3 + FR-1…FR-10; AC-1…AC-11). Файл есть в worktree (1064 строки, статически соответствует + ADR-001), но untracked — ни в одном коммите ветки, в diff `origin/main...HEAD` отсутствует. + С точки зрения PR установщик не реализован. **Прямое следствие — красный CI:** закоммиченный + `tests/test_install_lite_script.py` ассертит `SCRIPT.is_file()` (стр. 68: + `"scripts/install_lite.py отсутствует (FR-1)"`) и грузит модуль по файлу — на свежем чекауте + ветки все ~50 тестов упадут; `ci.yml` гоняет `pytest tests/`. **Фикс:** `git add + scripts/install_lite.py` + коммит в эту же ветку (после исправления P1-1/P1-2 ниже). + +- [ ] **FR-11 / AC-12 (doc-sync) не реализованы** — невыполненное требование ТЗ; буквально + срабатывает FAIL-условие AC-12 «установщик добавлен, но LITE_SETUP.md/CLAUDE.md о нём молчат» + (правило агентов №2/№6, ADR-001 D13/D14). Не обновлены в PR: + - `docs/deployment/LITE_SETUP.md` — нет callout-указателя «рекомендованный путь — + `install_lite.py`» (ADR D14; 13 `## N.`-заголовков при этом сохранить байт-в-байт); + - `tests/test_lite_setup_doc.py` — нет ассерта ссылки на установщик (ADR D13); + - `CLAUDE.md` — раздел тиража (Lite) без упоминания установщика; + - `CHANGELOG.md` — без записи ORCH-104. + +### P1 — Must fix + +- [ ] **2 структурных теста падают на «тексте о запретах», а не на нарушениях** (верифицировано + прогоном: 47 passed / 2 failed). Оба — один класс дефекта: needle-скан сырым `n in src` по + всему исходнику без tokenize-исключения комментариев/докстрингов/инструкций: + - `test_tc19_has_no_delete_or_force_operations` — `AssertionError: … ['down -v', + 'force-push']`: модульный докстринг `install_lite.py` (стр. 27–28) перечисляет запрещённые + операции текстом («…никаких rm/down -v/…/force-push…»). Реальных операций в коде нет. + - `test_tc20_carries_no_own_status_canon` — needle `To Analyse`: стр. 928 скрипта — текст + инструкции оператору в manual-step webhook («Финальная проверка webhook — на smoke §11 + (задача в „To Analyse“)»). Это не форк канона 22 статусов (статус не создаётся), а + человекочитаемая подсказка (зеркало LITE_SETUP §11). + + **Фикс (одно из):** (а) сканировать с tokenize-исключением комментариев/докстрингов — паттерн + `find_violations` из `test_no_host_hardcodes.py` уже импортирован тестом (single source of + truth); для tc20 — сузить критерий до признака форка канона (напр., ≥N имён статусов или + создание статусов API-вызовом); (б) перефразировать докстринг (стр. 27–28) и инструкцию + (стр. 928 — сослаться на LITE_SETUP §11 без буквального имени статуса). Тесты обязаны быть + зелёными в том виде, в котором закоммичены вместе со скриптом. Ссылка: AC-7, AC-10, INV-3, + ADR D13. + +- [ ] **`test_tc17_health_fail_raises_install_error` виснет навечно** (верифицировано verbose- + прогоном: последний стартовавший тест — ровно он, 48/51 пройдено, прогон убит по таймауту + 240 с; fail-fast прогон первых тестов — 0.6 с). Механизм: тест фейкует `mod.time` константой + `monotonic → 1e9`, `sleep → no-op`; + `step_health` (скрипт, стр. 941–946) крутит `while time.monotonic() < deadline` с + `deadline = monotonic() + 60` — дедлайн недостижим, `_http` → 503 вечно ⇒ бесконечный + busy-loop. После коммита скрипта CI будет висеть, а не падать. **Фикс:** тикающий фейк + monotonic (счётчик/генератор, +N на вызов) либо инжектируемый дедлайн/лимит попыток в + `step_health`/`_wait_health`. Заодно: `test_tc17_main_verify_reports_fail_as_exit_error` не + фейкует время вовсе → ~3 мин реального поллинга (3 пути × 60 с × `sleep(3)`) — ускорить тем + же способом. Ссылка: AC-11, NFR-7 («детерминированный, без сети»), ADR D13. + +- [ ] **Витрина `docs/overview/` не обновлена** (ORCH-011, расширение оси обзорных доков + ORCH-079 → finding ≥ P1). `docs/overview/presentation.md` слайд 17 «Lite-установка скриптами» + (стр. 159: «Маршрут — пошаговый runbook LITE_SETUP.md…») и `business.md` (стр. 92) описывают + именно меняемую функциональность — способ установки Lite. ADR-001 D14 сам фиксирует: «витрина + Lite-install — затронута…; финальные правки — на стадии development». Не сделано. **Фикс:** + обновить слайд 17/связанные упоминания (рекомендованный путь — установщик, runbook — фолбэк) + в том же PR; сверить с `tests/test_system_docs.py`. + +### P2 — Should fix + +- [ ] `docs/architecture/README.md`: добавленный блок озаглавлен «**Установщик Lite (ORCH-104 — + design)**» — пометка «design» была честной на стадии architecture, но в PR с реализацией + вводит в заблуждение (читается как «ещё не реализовано»). Снять/актуализировать пометку в том + же PR (ось документации, golden source). + +## Документация + +**Статус: НЕ обновлена — одна из причин `REQUEST_CHANGES`.** + +- Обновлено в PR: `docs/architecture/README.md` (блок Type A, аддитивно), сквозной + `docs/architecture/adr/adr-0040-lite-installer-canon.md`, work-item ADR + аналитический пакет + (01–04, 10) — корректные frontmatter-схемы 52c. +- Требуется обновить в том же PR (см. P0-2 / P1-3): `docs/deployment/LITE_SETUP.md` (callout на + установщик), `CLAUDE.md` (раздел Lite-тиража), `CHANGELOG.md` (запись ORCH-104), + `tests/test_lite_setup_doc.py` (ассерт ссылки), `docs/overview/presentation.md` (слайд 17) / + `business.md` (упоминание маршрута Lite), плюс снять пометку «design» в + `docs/architecture/README.md` (P2). +- `07-infra-requirements.md` / `08-data-requirements.md` — N/A обоснованно (ADR D15, ТЗ §5). diff --git a/tests/test_install_lite_script.py b/tests/test_install_lite_script.py new file mode 100644 index 0000000..66c8a26 --- /dev/null +++ b/tests/test_install_lite_script.py @@ -0,0 +1,735 @@ +"""ORCH-104 (TC-01…TC-21, AC-1…AC-11): структурные и unit-проверки +`scripts/install_lite.py` — интерактивного установщика Lite-тиража. + +Зеркало паттернов `tests/test_bootstrap_script.py`: модуль грузится по файлу +(import без side-effects, `main` только под `__main__`); side-effects +(subprocess/HTTP/getpass/input) инъектируются фейками через monkeypatch +module-level обёрток; чистые функции (`parse_env`/`render_env`/ +`preflight_verdict`/`install_command`/`rank_candidates`/`select_candidate`) +тестируются изолированно — без сети/docker/LLM (NFR-7). + +Структурные анти-дрейф (AC-9/AC-10/AC-7): stdlib-only AST-скан, нет `from src`, +нет delete/force-операций, нет собственного канона статусов Plane, нет +собственной генерации секретов (делегирование `gen_secrets.py`), нет +хост-литералов (импорт `FORBIDDEN` из `test_no_host_hardcodes.py` — один +источник истины), exit-контракт `{0,1,2}`, установщик не трогает рантайм +(`STAGE_TRANSITIONS`/`QG_CHECKS`). +""" + +import ast +import importlib.util +import os +import sys +from pathlib import Path + +# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7, не копия). +from tests.test_no_host_hardcodes import FORBIDDEN, find_violations + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts/install_lite.py" + +# Запрещённые delete/force-операции (INV-3: установщик ничего не удаляет/форсит). +FORBIDDEN_DELETE_NEEDLES = ( + "volume rm", + "rm -rf", + "down -v", + "compose down", + "rmtree", + "os.remove", + ".unlink", + "push --force", + "force-push", + "force-with-lease", + "branch -D", + "branch -d", +) + +# Маркеры собственного канона статусов Plane (запрещены: канон — onboard-кирпич). +FORBIDDEN_STATUS_NEEDLES = ( + "Backlog", + "To Analyse", + "Code-Review", + "Awaiting Deploy", + "Monitoring after Deploy", +) + +# Запрет собственной генерации секретов (делегирование gen_secrets.py, AC-7). +FORBIDDEN_SECRET_GEN_NEEDLES = ("token_hex", "token_urlsafe", "token_bytes") + +# stdlib-allowlist top-level импортов (INV-7: 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/install_lite.py отсутствует (FR-1)" + return SCRIPT.read_text(encoding="utf-8") + + +def _load_module(): + spec = importlib.util.spec_from_file_location("install_lite", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# =========================================================================== +# TC-18/TC-19/TC-20/TC-21 — структурные анти-дрейф (AC-7/AC-9/AC-10/INV). +# =========================================================================== +def test_tc18_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 импорты (INV-7): {sorted(set(offenders))}" + + +def test_tc18_does_not_import_platform_modules(): + src = _source() + assert "from src" not in src and "import src" not in src, ( + "установщик обязан быть stdlib-only без импортов платформы (INV-7/AC-10)" + ) + + +def test_tc19_has_no_delete_or_force_operations(): + src = _source() + offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src] + assert not offenders, ( + f"delete/force-операции запрещены (INV-3, AC-10): {offenders}" + ) + + +def test_tc19_carries_no_host_hardcodes(): + """FORBIDDEN host-литералы (импорт find_violations — один источник истины).""" + violations = find_violations(_source(), FORBIDDEN) + assert not violations, ( + f"хост-литералы в install_lite.py (AC-10): {violations}" + ) + + +def test_tc20_references_canonical_bricks(): + src = _source() + assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py (AC-7)" + assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py (AC-7)" + + +def test_tc20_carries_no_own_secret_generation(): + """AC-7: собственной генерации секретов нет — только делегирование кирпичу.""" + src = _source() + offenders = [n for n in FORBIDDEN_SECRET_GEN_NEEDLES if n in src] + assert not offenders, ( + f"собственная генерация секретов запрещена (анти-форк AC-7): {offenders}; " + "webhook-секреты — только gen_secrets.py" + ) + + +def test_tc20_carries_no_own_status_canon(): + src = _source() + offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src] + assert not offenders, ( + f"установщик несёт собственный канон статусов Plane (дрейф AC-7): " + f"{offenders}; 22 статуса — только onboard_project.py/plane_sync" + ) + + +def test_tc21_does_not_touch_runtime_registries(): + """AC-9: установщик вне рантайма — не ссылается на STAGE_TRANSITIONS/QG_CHECKS + и не правит src/** (он — scripts-артефакт).""" + src = _source() + for needle in ("STAGE_TRANSITIONS", "QG_CHECKS", "check_ci_green"): + assert needle not in src, ( + f"установщик не должен ссылаться на рантайм-реестр {needle} (AC-9)" + ) + + +def test_tc18_module_import_has_no_side_effects(): + before = dict(sys.modules) + mod1 = _load_module() + mod2 = _load_module() + assert mod1.build_plan() == mod2.build_plan() + assert before is not None # загрузка по файлу не должна падать + + +# =========================================================================== +# TC-01/TC-02 — entry-point, режимы, план. +# =========================================================================== +def test_tc01_exit_code_contract(): + mod = _load_module() + assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1) + + +def test_tc01_plan_is_default_mode_and_modes_are_closed(): + mod = _load_module() + parser = mod.build_arg_parser() + assert parser.parse_args([]).mode == "plan" # дефолт — ноль мутаций + assert parser.parse_args(["apply"]).mode == "apply" + assert parser.parse_args(["verify"]).mode == "verify" + assert parser.parse_args([]).force is False + assert parser.parse_args([]).non_interactive is False + + +def test_tc01_plan_makes_zero_mutations(tmp_path, monkeypatch, capsys): + """plan печатает план + preflight и НЕ пишет .env / не поднимает compose.""" + mod = _load_module() + env_path = tmp_path / ".env" + monkeypatch.setattr(mod, "ROOT_ENV", str(env_path)) + monkeypatch.setattr(mod, "WATCHDOG_ENV", str(tmp_path / ".env.watchdog")) + # Чистый хост: ни одной мутации, ни одного docker up. + monkeypatch.setattr(mod, "collect_facts", lambda env: { + "docker": True, "compose": True, "git": True, "node": True, + "claude": True, "prod_port": 8500, "prod_port_busy": False, + "orch_already_up": False, "repos_owner_ok": True, + }) + + def _boom_compose(*a, **k): + raise AssertionError("plan не имеет права запускать docker compose") + + monkeypatch.setattr(mod, "_compose", _boom_compose) + rc = mod.main(["plan"]) + out = capsys.readouterr().out + assert rc == mod.EXIT_OK + assert not env_path.exists(), "plan создал .env — это мутация (AC-1 FAIL)" + assert "план apply" in out and "preflight" in out + + +def test_tc01_plan_returns_manual_on_blockers(tmp_path, monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) + monkeypatch.setattr(mod, "collect_facts", lambda env: { + "docker": False, "compose": False, "git": True, "node": True, + "claude": True, "prod_port": 8500, "prod_port_busy": False, + "orch_already_up": False, "repos_owner_ok": True, + }) + assert mod.main(["plan"]) == mod.EXIT_MANUAL + + +def test_tc02_build_plan_is_ordered_and_complete(): + mod = _load_module() + names = [n for n, _ in mod.build_plan()] + order = ("preflight", "deps", "discovery", "inputs", "secrets", "env", + "up", "onboard", "health") + assert names[0] == "preflight", "preflight — строго ДО любых мутаций (FR-2)" + indexes = [names.index(n) for n in order] + assert indexes == sorted(indexes), f"порядок шагов нарушен: {names}" + assert len(names) >= 9 + + +def test_tc02_apply_steps_match_normative_plan(): + """Имена step-движка = нормативному плану (нет «теневых» шагов).""" + mod = _load_module() + assert [n for n, _ in mod.APPLY_STEPS] == [n for n, _ in mod.build_plan()] + + +# =========================================================================== +# TC-03 — preflight-вердикт (AC-2). +# =========================================================================== +def _clean_facts() -> dict: + return { + "docker": True, "compose": True, "git": True, "node": True, + "claude": True, "prod_port": 8500, "prod_port_busy": False, + "orch_already_up": False, "repos_owner_ok": True, + } + + +def test_tc03_preflight_clean_host_has_no_blockers(): + mod = _load_module() + blockers, warnings = mod.preflight_verdict(_clean_facts()) + assert blockers == [] and warnings == [] + + +def test_tc03_preflight_blocks_missing_deps_and_port_and_owner(): + mod = _load_module() + facts = _clean_facts() + facts.update(docker=False, compose=False, node=False, + prod_port_busy=True, orch_already_up=False, repos_owner_ok=False) + blockers, _ = mod.preflight_verdict(facts) + blob = "\n".join(blockers) + assert "docker" in blob + assert "node" in blob + assert "8500" in blob + assert "uid:gid" in blob + + +def test_tc03_busy_port_owned_by_orchestrator_is_not_a_blocker(): + mod = _load_module() + facts = _clean_facts() + facts.update(prod_port_busy=True, orch_already_up=True) + blockers, _ = mod.preflight_verdict(facts) + assert blockers == [], "уже поднятый орк на порту — не блокер (resume)" + + +def test_tc03_missing_claude_is_warning_not_blocker(): + mod = _load_module() + facts = _clean_facts() + facts.update(claude=False) + blockers, warnings = mod.preflight_verdict(facts) + assert blockers == [] + assert any("LLM" in w or "claude" in w for w in warnings) + + +# =========================================================================== +# TC-04 — distro → команда установки (AC-3 / D-1). +# =========================================================================== +def test_tc04_install_command_per_distro(): + mod = _load_module() + apt_cmd = mod.install_command("ubuntu", "docker") + dnf_cmd = mod.install_command("fedora", "docker") + assert apt_cmd and "apt" in apt_cmd + assert dnf_cmd and "dnf" in dnf_cmd + assert apt_cmd != dnf_cmd + # debian-семейство по ID_LIKE-подобному токену тоже резолвится: + assert mod.install_command("debian", "git").startswith("sudo apt") + + +def test_tc04_unknown_distro_degrades_to_none(): + mod = _load_module() + assert mod.install_command("arch", "docker") is None + assert mod.install_command("", "docker") is None + assert mod.install_command("ubuntu", "no-such-dep") is None + + +def test_tc04_install_commands_are_in_allowlist(): + """Команда, которую установщик может выполнить, ∈ фиксированному allowlist.""" + mod = _load_module() + allowed = mod.allowed_install_commands() + for distro in ("ubuntu", "fedora"): + for dep in mod.INSTALLABLE_DEPS: + cmd = mod.install_command(distro, dep) + if cmd is not None: + assert cmd in allowed + + +# =========================================================================== +# TC-05 — управляемая установка: отказ / нет TTY → exit 2, без мутации (AC-3). +# =========================================================================== +def test_tc05_deps_no_tty_does_not_install_and_stops(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_isatty", lambda: False) + + def _boom_run_shell(*a, **k): + raise AssertionError("без TTY установщик не имеет права что-либо ставить") + + monkeypatch.setattr(mod, "_run_shell", _boom_run_shell) + ctx = { + "args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}, + "facts": {"docker": False, "git": True, "node": True, "claude": True}, + "distro": "ubuntu", + } + import pytest + with pytest.raises(mod.ManualStop): + mod.step_deps(ctx) + + +def test_tc05_deps_consent_no_keeps_root_clean(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_isatty", lambda: True) + monkeypatch.setattr(mod, "_ask", lambda prompt: "N") # явный отказ + + def _boom_run_shell(*a, **k): + raise AssertionError("при отказе оператора установка не выполняется") + + monkeypatch.setattr(mod, "_run_shell", _boom_run_shell) + ctx = { + "args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}, + "facts": {"docker": False, "git": True, "node": True, "claude": True}, + "distro": "ubuntu", + } + import pytest + with pytest.raises(mod.ManualStop): + mod.step_deps(ctx) + + +def test_tc05_deps_all_present_is_skipped(monkeypatch): + mod = _load_module() + ctx = { + "args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}, + "facts": {"docker": True, "git": True, "node": True, "claude": True}, + "distro": "ubuntu", + } + assert mod.step_deps(ctx) == "skipped" + + +# =========================================================================== +# TC-06/TC-07/TC-08 — детект Plane/Gitea + выбор (AC-4 / D-2). +# =========================================================================== +def test_tc06_discovery_ranks_two_candidates(): + mod = _load_module() + # 2 слушающих plane-порта (80, 8080), оба живые → 2 ранжированных кандидата. + cands = mod.detect_existing( + "plane", + docker_fn=lambda: [], + ports_fn=lambda ports: [80, 8080], + prober=lambda url: True, + ) + assert len(cands) == 2 + assert all(c.confidence == 2 for c in cands) + # дедуп по URL: повторяющийся порт не плодит дублей + cands2 = mod.detect_existing( + "gitea", + docker_fn=lambda: ["gitea-server gitea/gitea:1.22"], + ports_fn=lambda ports: [3000], + prober=lambda url: True, + ) + assert len(cands2) == 1 + + +def test_tc06_zero_candidates_is_empty_list(): + mod = _load_module() + cands = mod.detect_existing( + "plane", docker_fn=lambda: [], ports_fn=lambda ports: [], + prober=lambda url: False, + ) + assert cands == [] + + +def test_tc06_rank_candidates_orders_alive_above_dead(): + mod = _load_module() + ranked = mod.rank_candidates([ + ("http://127.0.0.1:80", "port", False), + ("http://127.0.0.1:8080", "port", True), + ]) + assert [c.url for c in ranked] == [ + "http://127.0.0.1:8080", "http://127.0.0.1:80"] + + +def test_tc07_select_candidate_valid_and_out_of_range(): + mod = _load_module() + cands = mod.rank_candidates([("http://a", "port", True), + ("http://b", "port", True)]) + assert mod.select_candidate(cands, "1") in ("http://a", "http://b") + assert mod.select_candidate(cands, "99") is None # вне диапазона → ручной + assert mod.select_candidate(cands, "") is None # пусто → ручной + assert mod.select_candidate(cands, "abc") is None # мусор → ручной (never-raise) + + +def test_tc08_detect_never_raises_on_probe_failure(): + mod = _load_module() + + def _raising_docker(): + raise RuntimeError("docker недоступен") + + def _raising_ports(ports): + raise OSError("socket error") + + # исключение в docker-ps → [] + assert mod.detect_existing("plane", docker_fn=_raising_docker, + ports_fn=lambda p: [80]) == [] + # исключение в порт-скане → [] + assert mod.detect_existing("gitea", docker_fn=lambda: [], + ports_fn=_raising_ports) == [] + # исключение в пробе живости конкретного URL → кандидат «мёртв», не падение + cands = mod.detect_existing( + "gitea", docker_fn=lambda: [], ports_fn=lambda p: [3000], + prober=lambda url: (_ for _ in ()).throw(RuntimeError()), + ) + assert all(c.confidence == 1 for c in cands) + + +# =========================================================================== +# TC-09 — живая верификация ДО записи (AC-5). +# =========================================================================== +def test_tc09_verify_plane_rejects_401_accepts_200(): + mod = _load_module() + reject = mod.verify_plane_token( + "http://plane", "ws", "bad", http=lambda u, h, t: (401, "")) + accept = mod.verify_plane_token( + "http://plane", "ws", "good", http=lambda u, h, t: (200, "[]")) + assert reject[0] is False and accept[0] is True + + +def test_tc09_verify_gitea_rejects_401_accepts_200(): + mod = _load_module() + assert mod.verify_gitea_token( + "http://gitea", "bad", http=lambda u, h, t: (401, ""))[0] is False + assert mod.verify_gitea_token( + "http://gitea", "good", http=lambda u, h, t: (200, "{}"))[0] is True + + +def test_tc09_verify_telegram_ok_false_is_rejected(): + mod = _load_module() + assert mod.verify_telegram_token( + "bad", http=lambda u, h, t: (200, '{"ok": false}'))[0] is False + assert mod.verify_telegram_token( + "good", http=lambda u, h, t: (200, '{"ok": true}'))[0] is True + assert mod.verify_telegram_token( + "x", http=lambda u, h, t: (401, ""))[0] is False + + +def test_tc09_collect_secret_does_not_write_invalid(monkeypatch): + """Битый токен НЕ принимается; верный — принимается (verify-before-write).""" + mod = _load_module() + monkeypatch.setattr(mod, "_isatty", lambda: True) + seq = iter(["bad-token", "good-token"]) + monkeypatch.setattr(mod, "_getsecret", lambda prompt: next(seq)) + ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), "root_env": {}} + + def verify(value): + return (value == "good-token", "401") + + got = mod._collect_secret(ctx, "ORCH_PLANE_API_TOKEN", "Plane", + verify, "инструкция") + assert got == "good-token" # принят только прошедший верификацию + + +def test_tc09_collect_secret_no_tty_fails_closed(): + mod = _load_module() + ctx = {"args": mod.build_arg_parser().parse_args(["--non-interactive", "apply"]), + "root_env": {}} + import pytest + with pytest.raises(mod.ManualStop): + mod._collect_secret(ctx, "ORCH_GITEA_TOKEN", "Gitea", + lambda v: (False, "x"), "инструкция") + + +# =========================================================================== +# TC-10/TC-11 — parse_env / render_env (AC-6/AC-7). +# =========================================================================== +def test_tc10_parse_env_roundtrip(): + mod = _load_module() + text = "# шапка\nA=1\nB=\n\n# хвост\nC = three \n" + assert mod.parse_env(text) == {"A": "1", "B": "", "C": "three"} + + +def test_tc11_render_env_preserves_canon_and_appends_unknown(): + mod = _load_module() + example = "# шапка\nA=1\nB=\n\n# хвост\n" + rendered = mod.render_env(example, {"B": "v", "NEW": "n"}) + assert "# шапка" in rendered and "A=1" in rendered # канон сохранён + assert "B=v" in rendered # ключ канона получил значение + assert "NEW=n" in rendered # внеканонный — управляемым блоком + # идемпотентность: повторный рендер от результата стабилен по ключам + again = mod.render_env(rendered, {"B": "v", "NEW": "n"}) + assert mod.parse_env(again)["B"] == "v" + assert mod.parse_env(again)["NEW"] == "n" + + +def test_tc11_watchdog_keys_split_to_their_own_file(): + mod = _load_module() + root, watchdog = mod.split_watchdog_overrides( + {"ORCH_GITEA_TOKEN": "x", "WATCHDOG_TG_BOT_TOKEN": "y"}) + assert root == {"ORCH_GITEA_TOKEN": "x"} + assert watchdog == {"WATCHDOG_TG_BOT_TOKEN": "y"} + + +# =========================================================================== +# TC-12/TC-13 — секрет-гигиена + no-silent-overwrite (AC-6/AC-7). +# =========================================================================== +def test_tc12_write_private_sets_600(tmp_path, monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) + path = tmp_path / ".env" + mod._write_private(str(path), "ORCH_GITEA_TOKEN=secretvalue\n") + assert (path.stat().st_mode & 0o777) == 0o600 + + +def test_tc12_mask_secret_never_reveals_value(): + mod = _load_module() + masked = mod.mask_secret("super-secret-token-value") + assert "super-secret-token-value" not in masked + assert mod.mask_secret("") == "<пусто>" + + +def test_tc12_secrets_step_does_not_log_secret_value(tmp_path, monkeypatch, capsys): + mod = _load_module() + monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) + secret = "deadbeefcafefacefeed0123456789abcdef0123456789abcdef0123456789ab" + + def _fake_gen(cmd, env=None, timeout=600): + path = cmd[cmd.index("--write") + 1] + with open(path, "w", encoding="utf-8") as f: + f.write(f"ORCH_PLANE_WEBHOOK_SECRET={secret}\n" + f"ORCH_GITEA_WEBHOOK_SECRET={secret}\n") + return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(mod, "_run", _fake_gen) + ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}} + assert mod.step_secrets(ctx) == "ok" + out = capsys.readouterr().out + assert secret not in out, "значение секрета утекло в лог (NFR-2 FAIL)" + assert ctx["overrides"]["ORCH_PLANE_WEBHOOK_SECRET"] == secret + + +def test_tc13_secrets_no_overwrite_without_force(monkeypatch): + mod = _load_module() + + def _boom(*a, **k): + raise AssertionError("при наличии секретов gen_secrets.py не вызывается") + + monkeypatch.setattr(mod, "_run", _boom) + ctx = { + "args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {"ORCH_PLANE_WEBHOOK_SECRET": "a" * 64, + "ORCH_GITEA_WEBHOOK_SECRET": "b" * 64}, + "overrides": {}, + } + assert mod.step_secrets(ctx) == "skipped" + + +# =========================================================================== +# TC-14 — онбординг кирпичом (AC-7). +# =========================================================================== +def test_tc14_onboard_parses_registry_and_propagates_manual(tmp_path, monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) + monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) + monkeypatch.setattr(mod, "ROOT_ENV_EXAMPLE", str(REPO_ROOT / ".env.example")) + merged = '[{"repo":"acme","work_item_prefix":"ACME"}]' + calls = {"recreate": 0} + + def _fake_run(cmd, env=None, timeout=600): + joined = " ".join(cmd) + if "onboard_project.py" in joined and "apply" in cmd: + report = {"instructions": [f"ORCH_PROJECTS_JSON={merged}"], + "exit_code": 2, "steps": []} + return type("P", (), {"returncode": 2, "stdout": __import__("json").dumps(report), + "stderr": ""})() + if "onboard_project.py" in joined and "verify" in cmd: + return type("P", (), {"returncode": 2, "stdout": "{}", "stderr": ""})() + raise AssertionError(f"неожиданный вызов: {joined}") + + def _fake_compose(*a, **k): + calls["recreate"] += 1 + return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(mod, "_run", _fake_run) + monkeypatch.setattr(mod, "_compose", _fake_compose) + args = mod.build_arg_parser().parse_args([ + "apply", "--name", "Acme", "--repo", "acme", "--prefix", "ACME", + "--stack", "python", "--test-cmd", "pytest", "--prod-port", "8600", + "--staging-port", "8601", "--webhook-url", "http://x/webhook/gitea"]) + ctx = {"args": args, "root_env": {}, "overrides": {}} + status = mod.step_onboard(ctx) + written = (tmp_path / ".env").read_text(encoding="utf-8") + assert merged in written, "merged ORCH_PROJECTS_JSON не записан в .env" + assert ctx["onboard_manual"] is True + assert status == "manual-step" + assert calls["recreate"] == 1 + + +def test_tc14_onboard_skips_when_project_present(): + mod = _load_module() + args = mod.build_arg_parser().parse_args([ + "apply", "--name", "Acme", "--repo", "acme", "--prefix", "ACME"]) + ctx = {"args": args, + "root_env": {"ORCH_PROJECTS_JSON": '[{"repo":"acme"}]'}, + "overrides": {}} + assert mod.step_onboard(ctx) == "skipped" + + +def test_tc14_onboard_without_params_is_manual_step(): + mod = _load_module() + args = mod.build_arg_parser().parse_args(["apply"]) + ctx = {"args": args, "root_env": {}, "overrides": {}} + assert mod.step_onboard(ctx) == "manual-step" + assert ctx["onboard_manual"] is True + + +# =========================================================================== +# TC-15 — идемпотентность (AC-8). +# =========================================================================== +def test_tc15_secrets_then_env_are_idempotent(tmp_path, monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "REPO_ROOT", str(tmp_path)) + monkeypatch.setattr(mod, "ROOT_ENV", str(tmp_path / ".env")) + monkeypatch.setattr(mod, "ROOT_ENV_EXAMPLE", str(REPO_ROOT / ".env.example")) + monkeypatch.setattr(mod, "WATCHDOG_ENV", str(tmp_path / ".env.watchdog")) + monkeypatch.setattr(mod, "WATCHDOG_ENV_EXAMPLE", + str(REPO_ROOT / ".env.watchdog.example")) + secret = "a" * 64 + + def _fake_gen(cmd, env=None, timeout=600): + path = cmd[cmd.index("--write") + 1] + with open(path, "w", encoding="utf-8") as f: + f.write(f"ORCH_PLANE_WEBHOOK_SECRET={secret}\n" + f"ORCH_GITEA_WEBHOOK_SECRET={secret}\n") + return type("P", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(mod, "_run", _fake_gen) + args = mod.build_arg_parser().parse_args(["apply"]) + ctx = {"args": args, "root_env": {}, "overrides": {}} + # первый прогон: секреты выпущены, env собран + assert mod.step_secrets(ctx) == "ok" + assert mod.step_env(ctx) == "ok" + first = (tmp_path / ".env").read_text(encoding="utf-8") + # второй прогон тех же шагов поверх собранного .env + ctx2 = {"args": args, + "root_env": mod.parse_env(first), "overrides": {}} + assert mod.step_secrets(ctx2) == "skipped" # уже есть → не перевыпуск + assert mod.step_env(ctx2) == "ok" + second = (tmp_path / ".env").read_text(encoding="utf-8") + assert mod.parse_env(second)["ORCH_PLANE_WEBHOOK_SECRET"] == secret + assert mod.parse_env(first) == mod.parse_env(second) # значения стабильны + + +# =========================================================================== +# TC-16 — no-TTY manual_checkpoint fail-closed (AC-8/AC-11). +# =========================================================================== +def test_tc16_manual_checkpoint_no_tty_raises(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_isatty", lambda: False) + + def _boom_ask(prompt): + raise AssertionError("без TTY input() вызываться не должен") + + monkeypatch.setattr(mod, "_ask", _boom_ask) + import pytest + with pytest.raises(mod.ManualStop): + mod.manual_checkpoint("step", ["do x"], lambda: (True, "")) + + +def test_tc16_manual_checkpoint_verifies_after_confirm(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_isatty", lambda: True) + monkeypatch.setattr(mod, "_ask", lambda prompt: "") + # верификация проходит со второй попытки + seq = iter([(False, "ещё нет"), (True, "")]) + mod.manual_checkpoint("step", ["do x"], lambda: next(seq), max_tries=3) + + +# =========================================================================== +# TC-17 — health-гейт (AC-11). +# =========================================================================== +def test_tc17_health_pass_on_200(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_http", + lambda url, headers=None, timeout=10: (200, "{}")) + ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}} + assert mod.step_health(ctx) == "ok" + + +def test_tc17_health_fail_raises_install_error(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "_http", + lambda url, headers=None, timeout=10: (503, "")) + monkeypatch.setattr(mod, "_compose", + lambda *a, **k: type("P", (), {"stdout": "", "stderr": ""})()) + monkeypatch.setattr(mod, "time", + type("T", (), {"monotonic": staticmethod(lambda: 1e9), + "sleep": staticmethod(lambda s: None)})) + ctx = {"args": mod.build_arg_parser().parse_args(["apply"]), + "root_env": {}, "overrides": {}} + import pytest + with pytest.raises(mod.InstallError): + mod.step_health(ctx) + + +def test_tc17_main_verify_reports_fail_as_exit_error(monkeypatch): + mod = _load_module() + monkeypatch.setattr(mod, "ROOT_ENV", os.path.join(str(REPO_ROOT), "nonexistent.env")) + monkeypatch.setattr(mod, "_http", + lambda url, headers=None, timeout=10: (503, "")) + assert mod.main(["verify"]) == mod.EXIT_ERROR