feat: ORCH-104-eae59ef5 #128
@@ -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
|
||||
|
||||
106
docs/architecture/adr/adr-0040-lite-installer-canon.md
Normal file
106
docs/architecture/adr/adr-0040-lite-installer-canon.md
Normal file
@@ -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`.
|
||||
7
docs/work-items/ORCH-104/00-business-request.md
Normal file
7
docs/work-items/ORCH-104/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Установочный скрипт для lite
|
||||
|
||||
Work Item ID: ORCH-104
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
187
docs/work-items/ORCH-104/01-brd.md
Normal file
187
docs/work-items/ORCH-104/01-brd.md
Normal file
@@ -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), не блокирует анализ.
|
||||
193
docs/work-items/ORCH-104/02-trz.md
Normal file
193
docs/work-items/ORCH-104/02-trz.md
Normal file
@@ -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/<slug>/projects/` с
|
||||
`X-API-Key`; Gitea — `GET /api/v1/user` с `Authorization: token`; Telegram — `GET /bot<token>/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-<slug>.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.
|
||||
171
docs/work-items/ORCH-104/03-acceptance-criteria.md
Normal file
171
docs/work-items/ORCH-104/03-acceptance-criteria.md
Normal file
@@ -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) |
|
||||
150
docs/work-items/ORCH-104/04-test-plan.yaml
Normal file
150
docs/work-items/ORCH-104/04-test-plan.yaml
Normal file
@@ -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
|
||||
354
docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md
Normal file
354
docs/work-items/ORCH-104/06-adr/ADR-001-lite-installer.md
Normal file
@@ -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 <tmp>` и `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 <base>/api/instances/`
|
||||
(unauth-liveness Plane CE); Gitea — `GET <base>/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>/api/v1/workspaces/<slug>/projects/` с `X-API-Key:<token>` → 200;
|
||||
- Gitea: `GET <url>/api/v1/user` с `Authorization: token <token>` → 200;
|
||||
- Telegram: `GET https://api.telegram.org/bot<token>/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=<merged>` → запись в `.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`
|
||||
43
docs/work-items/ORCH-104/10-tech-risks.md
Normal file
43
docs/work-items/ORCH-104/10-tech-risks.md
Normal file
@@ -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)**: оба со средней вероятностью и снижены анти-дрейф-тестом и обязательной
|
||||
живой верификацией с ручным фолбэком.
|
||||
134
docs/work-items/ORCH-104/12-review.md
Normal file
134
docs/work-items/ORCH-104/12-review.md
Normal file
@@ -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).
|
||||
735
tests/test_install_lite_script.py
Normal file
735
tests/test_install_lite_script.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user