docs(deployment): ORCH-10a Lite-тираж — LITE_SETUP.md + канон watchdog-конфига + анти-дрейф контур
All checks were successful
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 57s

Закрывает Type A эпика ORCH-10 (поверх 10-common ORCH-101). Docs+tests
(паттерн ORCH-077/092): src/**, docker-compose.yml, Dockerfile, scripts/** —
ноль изменений; конвейер (STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/
схема БД) — байт-в-байт.

- docs/deployment/LITE_SETUP.md (D1/D2): golden source Lite-тиража — 13
  нормативных разделов в порядке маршрута оператора, каждый шаг =
  fenced-команда + явная «Проверка:»/PASS/FAIL, хост-специфика только
  плейсхолдерами; канон не форкается (статусы/env/вебхуки/smoke — ссылками
  на ONBOARDING §1 / REPLICATION §2–§4 / SETUP_WEBHOOKS; явно — только
  fail-closed Confirm Deploy/STOP и обязательные ключи нового хоста).
- .env.watchdog.example (D5, исход А-4): третий канонический env-example;
  key-set = блок WATCHDOG_* .env.example (19 ключей, токены — пустые
  плейсхолдеры); закрывает ловушку файла-носителя (sidecar читает ТОЛЬКО
  .env.watchdog); C-1 ORCH-100 + когерентность порта в шапке; .env.watchdog
  добавлен в .gitignore (секрет-гигиена, зеркало .env.staging).
- tests/test_lite_setup_doc.py (D8): 25 структурных тестов без
  сети/LLM/subprocess — 13 разделов в порядке D2, кирпичи FR-6.1, key-sync
  watchdog-канона, env-ключи ⊂ .env.example, compose-подмножество (ровно
  орк+watchdog по дефолту, staging за профилем, анти-появление
  plane*/gitea*), fenced-скан FORBIDDEN (импорт из test_no_host_hardcodes)
  + секрет-эвристика с негативным самочеком, «22 статуса» сверкой импорта
  plane_sync._PLANE_NAME_TO_KEY, перекрёстность.
- Перекрёстные доки (FR-7): REPLICATION.md §1 (Type A — Lite →  ORCH-102 +
  ссылка), README.md (способность Lite + docs/deployment/ в структуре),
  INFRA.md (.env.watchdog в секрет-нормативе + ссылка на deployment),
  CLAUDE.md (блок ORCH-102), CHANGELOG.md.

Нормативы разделов: Gitea — branch protection на main НЕ включать (D3 /
ADR D10 ORCH-009 / INV-4), pre-receive не вводится, ОДИН глобальный
webhook-секрет; staging-вилка опциональна (D6); источник кода —
параметризованный git clone <ORCHESTRATOR_GIT_URL> (D7); stateless —
данные/задачи/секреты боевого хоста НЕ переносятся (AC-3).

Тесты: pytest tests/ -q — 1789 passed (полный регресс зелёный).

Refs: ORCH-102

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:18:48 +03:00
parent b0eba43342
commit d03e29fd5d
9 changed files with 1111 additions and 2 deletions

View File

@@ -0,0 +1,428 @@
"""ORCH-102 (FR-6 / AC-1, AC-2, AC-3, AC-6): анти-дрейф контур Lite-тиража.
Структурные проверки golden source `docs/deployment/LITE_SETUP.md` (ADR-001 D8,
образец — `tests/test_replication_smoke.py`): док существует и несёт 13
нормативных разделов D2 в порядке маршрута оператора; обязательные кирпичи
(TC-02); key-sync `.env.watchdog.example` ⇄ блок `WATCHDOG_*` `.env.example`
(TC-02b, D5); каждый упомянутый в доке env-ключ существует в `.env.example`
(TC-03, анти-опечатка); compose-подмножество — ровно орк+watchdog по дефолту,
staging строго за профилем, никаких Plane/Gitea-сервисов (TC-04, D4);
stateless-норматив и секрет-гигиена fenced-блоков (TC-05, FORBIDDEN —
импорт из `tests/test_no_host_hardcodes.py`, не копия); канон не форкается —
статусы/env/smoke ссылками, fail-closed имена явно (TC-06, D3); инварианты
Gitea-раздела (TC-07); перекрёстность REPLICATION→LITE_SETUP + CHANGELOG
(TC-08). Детерминировано: без сети/LLM/subprocess (NFR/TR-7 — ассерты только
на стабильное: заголовки, подстроки-кирпичи, парсинг ключей, yaml.safe_load).
"""
import re
from pathlib import Path
import yaml
# Один источник истины запрещённых боевых литералов (ADR-001 D8 / ORCH-101 AC-7).
from tests.test_no_host_hardcodes import FORBIDDEN
REPO_ROOT = Path(__file__).resolve().parents[1]
LITE_SETUP = REPO_ROOT / "docs/deployment/LITE_SETUP.md"
REPLICATION = REPO_ROOT / "docs/operations/REPLICATION.md"
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
ENV_WATCHDOG_EXAMPLE = REPO_ROOT / ".env.watchdog.example"
COMPOSE = REPO_ROOT / "docker-compose.yml"
# Нормативная структура D2: фиксированная нумерация, порядок = маршрут оператора.
SECTIONS: tuple[str, ...] = (
"## 1. Рамка Lite",
"## 2. Предусловия хоста",
"## 3. Перенос кода",
"## 4. Конфигурация",
"## 5. Подключение Plane",
"## 6. Подключение Gitea",
"## 7. LLM (claude CLI)",
"## 8. Telegram",
"## 9. Запуск",
"## 10. Регистрация проекта",
"## 11. Smoke",
"## 12. Stateless-проверка",
"## 13. Траблшутинг",
)
# Обязательные кирпичи FR-6.1 (подстроки; TC-02).
BRICKS: tuple[str, ...] = (
"gen_secrets.py",
"onboard_project.py",
"docker compose",
"/health",
"/queue",
"/metrics",
"ORCH_PROJECTS_JSON",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
"X-Plane-Signature",
"X-Gitea-Signature",
"getent group docker",
"Confirm Deploy",
"STOP",
"ORCH_TELEGRAM_BOT_TOKEN", # канал трекера орка
"WATCHDOG_TG_BOT_TOKEN", # независимый канал sidecar-watchdog (C-1 ORCH-100)
"PASS",
"FAIL",
"Проверка",
)
# Обязательный набор ключей нового хоста, упоминаемый в доке ЯВНО (TC-03 / FR-1.4).
MANDATORY_NEW_HOST_KEYS: tuple[str, ...] = (
"ORCH_PROJECTS_JSON",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
"ORCH_PLANE_API_TOKEN",
"ORCH_GITEA_TOKEN",
"ORCH_TELEGRAM_BOT_TOKEN",
"ORCH_TELEGRAM_CHAT_ID",
"WATCHDOG_TG_BOT_TOKEN",
"WATCHDOG_TG_CHAT_ID",
)
# env-токен в доке: полное имя ключа, не заканчивающееся на «_» (glob-упоминания
# вида ORCH_FOO_* в доке запрещены формой D2 — пишутся полные имена).
_ENV_TOKEN_RE = re.compile(r"\b(?:ORCH|WATCHDOG)_[A-Z0-9_]*[A-Z0-9]\b")
# Секретоподобные значения в копируемых блоках (TC-05, эвристика D8):
# hex-run >= 32 симв. (token_hex) либо чистый alnum-run >= 40 симв. (токены ботов
# и т.п.); плейсхолдеры <...>/$ENV_VAR под эти классы не попадают.
_SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
_SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b")
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _doc_text() -> str:
assert LITE_SETUP.is_file(), "docs/deployment/LITE_SETUP.md отсутствует (AC-1)"
return LITE_SETUP.read_text(encoding="utf-8")
def _env_keys(path: Path) -> set[str]:
"""Множество ключей `KEY=` файла env-канона (комментарии/пустые — мимо)."""
keys: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
keys.add(line.split("=", 1)[0].strip())
return keys
def _env_values(path: Path) -> dict[str, str]:
"""Словарь `KEY -> value` файла env-канона."""
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
values[k.strip()] = v.strip()
return values
def _fenced_blocks(text: str) -> list[str]:
"""Содержимое fenced code blocks — единственная копируемая зона дока (D2/D8)."""
return re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
def _section_bodies() -> dict[str, str]:
"""Тело каждого нормативного раздела (от заголовка до следующего `## `)."""
text = _doc_text()
bodies: dict[str, str] = {}
for i, header in enumerate(SECTIONS):
start = text.find(header)
assert start != -1, f"раздел {header!r} отсутствует"
end = text.find(SECTIONS[i + 1]) if i + 1 < len(SECTIONS) else len(text)
bodies[header] = text[start:end]
return bodies
def _compose_services() -> dict:
return yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
# ---------------------------------------------------------------------------
# TC-01: док существует; 13 нормативных разделов D2 — в заданном порядке (AC-1).
# ---------------------------------------------------------------------------
def test_doc_exists_with_all_13_sections_in_order():
text = _doc_text()
positions: list[int] = []
for header in SECTIONS:
idx = text.find(header)
assert idx != -1, f"нормативный раздел {header!r} отсутствует (D2/FR-1)"
positions.append(idx)
assert positions == sorted(positions), (
"разделы LITE_SETUP.md идут не в порядке маршрута оператора (D2)"
)
# ---------------------------------------------------------------------------
# TC-02: обязательные кирпичи + форма «команда + проверка» (AC-1 / NFR-6).
# ---------------------------------------------------------------------------
def test_doc_carries_all_mandatory_bricks():
text = _doc_text()
missing = [b for b in BRICKS if b not in text]
assert not missing, f"обязательные кирпичи отсутствуют в LITE_SETUP.md (FR-6.1): {missing}"
def test_every_normative_section_carries_commands():
"""Каждый исполняемый раздел (§2§13) несёт минимум одну fenced-команду;
§1 (рамка/границы) — единственный раздел без команд по построению."""
bodies = _section_bodies()
for header in SECTIONS[1:]:
assert "```" in bodies[header], f"{header}: нет ни одной fenced-команды (NFR-6)"
def test_doc_carries_explicit_check_markers():
"""Маркеры явной проверки результата: не меньше одного на исполняемый раздел."""
text = _doc_text()
assert text.count("Проверка") >= 12, (
"маркеров «Проверка» меньше, чем исполняемых разделов (форма D2: "
"каждый шаг = команда + явная проверка)"
)
assert "PASS" in text and "FAIL" in text
# ---------------------------------------------------------------------------
# TC-02b: key-sync .env.watchdog.example ⇄ блок WATCHDOG_* .env.example (D5).
# ---------------------------------------------------------------------------
def test_watchdog_example_exists():
assert ENV_WATCHDOG_EXAMPLE.is_file(), ".env.watchdog.example отсутствует (ADR-001 D5)"
def test_watchdog_example_keys_sync_with_env_example_block():
"""Равенство МНОЖЕСТВ ключей (не строк): появление нового WATCHDOG_*-ключа в
каноне без обновления example (и наоборот) рвёт CI (D5/TR-8)."""
watchdog_keys = _env_keys(ENV_WATCHDOG_EXAMPLE)
canon_block = {k for k in _env_keys(ENV_EXAMPLE) if k.startswith("WATCHDOG_")}
assert canon_block, "блок WATCHDOG_* в .env.example пуст — канон сломан"
assert watchdog_keys == canon_block, (
f"key-set .env.watchdog.example разъехался с каноном .env.example: "
f"лишние={sorted(watchdog_keys - canon_block)}, "
f"недостающие={sorted(canon_block - watchdog_keys)}"
)
stray = {k for k in watchdog_keys if not k.startswith("WATCHDOG_")}
assert not stray, f"посторонние (не WATCHDOG_*) ключи в .env.watchdog.example: {sorted(stray)}"
def test_watchdog_example_secrets_are_placeholders_only():
"""Зеркало test_env_example_secrets_are_placeholders_only (ORCH-101):
токены sidecar-бота в гите — только пустые плейсхолдеры (NFR-3)."""
values = _env_values(ENV_WATCHDOG_EXAMPLE)
for key in ("WATCHDOG_TG_BOT_TOKEN", "WATCHDOG_TG_CHAT_ID"):
assert values.get(key, "") == "", (
f"{key} в .env.watchdog.example обязан быть пустым плейсхолдером, "
f"получено {values.get(key)!r}"
)
# ---------------------------------------------------------------------------
# TC-03: согласованность env-канона (AC-1, AC-6 / FR-6.2).
# ---------------------------------------------------------------------------
def test_every_env_token_in_doc_exists_in_env_example():
"""Каждый упомянутый в доке ключ ORCH_*/WATCHDOG_* существует в `.env.example`
(канон 100% ключей старта, ORCH-101) — анти-опечатка/анти-дрейф."""
canon = _env_keys(ENV_EXAMPLE)
mentioned = set(_ENV_TOKEN_RE.findall(_doc_text()))
assert mentioned, "в LITE_SETUP.md не упомянут ни один env-ключ — док не полон"
unknown = sorted(mentioned - canon)
assert not unknown, (
f"ключи из LITE_SETUP.md отсутствуют в .env.example (опечатка или дрейф "
f"канона, FR-6.2): {unknown}"
)
def test_mandatory_new_host_keys_are_explicit():
text = _doc_text()
missing = [k for k in MANDATORY_NEW_HOST_KEYS if k not in text]
assert not missing, f"обязательные ключи нового хоста не упомянуты явно (FR-1.4): {missing}"
# ---------------------------------------------------------------------------
# TC-04: compose-подмножество (AC-2 / D4) — yaml.safe_load, без subprocess.
# ---------------------------------------------------------------------------
def test_compose_services_are_exactly_the_lite_set():
services = _compose_services()
assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}, (
"множество сервисов docker-compose.yml разъехалось с Lite-подмножеством (AC-2)"
)
def test_compose_staging_is_strictly_behind_profile():
services = _compose_services()
assert services["orchestrator-staging"].get("profiles") == ["staging"], (
"orchestrator-staging обязан быть строго за profiles: [staging] (AC-2)"
)
default_up = {name for name, svc in services.items() if not svc.get("profiles")}
assert default_up == {"orchestrator", "orchestrator-watchdog"}, (
f"дефолтный `docker compose up -d` поднимает {sorted(default_up)}, "
"а обязан — ровно орк+watchdog (AC-2)"
)
def test_compose_has_no_plane_or_gitea_services():
"""Анти-появление молча: ни в имени сервиса, ни в image:/container_name:
нет подстрок plane/gitea (D4)."""
services = _compose_services()
for name, svc in services.items():
blob = " ".join(
[name, str(svc.get("image", "")), str(svc.get("container_name", ""))]
).lower()
for needle in ("plane", "gitea"):
assert needle not in blob, (
f"сервис {name}: в compose появился {needle}-компонент — "
"Lite-подмножество сломано (AC-2)"
)
def test_doc_documents_default_up_composition():
"""LITE_SETUP.md фиксирует состав дефолтного запуска и вилку staging (D6)."""
text = _doc_text()
assert "orchestrator-watchdog" in text
assert "orchestrator-staging" in text
assert "--profile staging" in text # staging поднимается только явным профилем
# ---------------------------------------------------------------------------
# TC-05: stateless-норматив + секрет-гигиена fenced-блоков (AC-3 / FR-3, NFR-3).
# ---------------------------------------------------------------------------
def test_doc_has_stateless_normative_line():
low = _doc_text().lower()
assert "не перенос" in low, (
"нормативная stateless-строка («данные/задачи/секреты боевого хоста "
"НЕ переносятся») отсутствует (AC-3)"
)
def test_doc_prescribes_clean_db_and_fresh_secrets():
text = _doc_text()
assert "gen_secrets.py" in text # секреты — только выпуск НОВЫХ
bodies = _section_bodies()
stateless = bodies["## 12. Stateless-проверка"]
assert "/queue" in stateless, (
"§12 обязан нести проверку чистоты инстанса через GET /queue (FR-1 п.12)"
)
def test_fenced_blocks_carry_no_forbidden_literals():
"""Копируемые блоки generic: боевые литералы (центральный список FORBIDDEN
из tests/test_no_host_hardcodes.py — один источник истины) запрещены."""
blocks = _fenced_blocks(_doc_text())
assert blocks, "в LITE_SETUP.md нет ни одного fenced-блока — форма D2 нарушена"
offenders = [
f"блок #{i}: {literal!r}"
for i, block in enumerate(blocks)
for literal in FORBIDDEN
if literal in block
]
assert not offenders, (
"боевые литералы в копируемых код-блоках LITE_SETUP.md (NFR-3):\n"
+ "\n".join(offenders)
)
def test_fenced_blocks_carry_no_secret_like_values():
"""Эвристика секретоподобных значений (D8): hex >= 32 / чистый alnum >= 40."""
offenders = []
for i, block in enumerate(_fenced_blocks(_doc_text())):
for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
m = rx.search(block)
if m is not None:
offenders.append(f"блок #{i}: {m.group(0)[:16]}")
assert not offenders, (
"секретоподобные значения в копируемых код-блоках (только плейсхолдеры "
"<...>/$ENV_VAR, NFR-3):\n" + "\n".join(offenders)
)
def test_secret_heuristic_is_not_evergreen():
"""Негативный самочек (паттерн ORCH-101 TC-02): эвристика реально ловит
подсаженный секрет — тест не может молча стать вечнозелёным."""
planted_hex = "export TOKEN=" + "a1b2c3d4" * 8 # 64 hex
planted_alnum = "bot" + "A9" * 25 # 50+ alnum
assert _SECRET_HEX_RE.search(planted_hex) is not None
assert _SECRET_ALNUM_RE.search(planted_alnum) is not None
assert _SECRET_HEX_RE.search("curl -fsS http://127.0.0.1:8500/health") is None
assert _SECRET_ALNUM_RE.search("<ORCHESTRATOR_GIT_URL> $ORCH_PLANE_API_TOKEN") is None
# ---------------------------------------------------------------------------
# TC-06: канон не форкается (AC-6 / FR-4) — ссылки на golden source.
# ---------------------------------------------------------------------------
def test_plane_canon_is_linked_not_forked():
"""Статусы — ссылкой на ONBOARDING.md (создаёт onboard_project.py apply);
в доке явно — только fail-closed имена Confirm Deploy / STOP."""
bodies = _section_bodies()
plane = bodies["## 5. Подключение Plane"]
assert "ONBOARDING.md" in plane, "раздел Plane не ссылается на golden source статусов"
assert "Confirm Deploy" in plane and "STOP" in plane, (
"fail-closed имена статусов не упомянуты явно (FR-4)"
)
def test_status_count_claim_matches_plane_sync():
"""Сверка импортом (не строковой копией): заявление дока «22 статуса»
держится фактическим маппингом src/plane_sync.py (нулевой дрейф, AC-6)."""
from src.plane_sync import _PLANE_NAME_TO_KEY
assert len(_PLANE_NAME_TO_KEY) == 22, (
f"в plane_sync {_PLANE_NAME_TO_KEY and len(_PLANE_NAME_TO_KEY)} статусов — "
"обнови число и раздел §5 LITE_SETUP.md (и ONBOARDING.md §1)"
)
assert "Confirm Deploy" in _PLANE_NAME_TO_KEY
assert "STOP" in _PLANE_NAME_TO_KEY
assert "22" in _doc_text(), "число статусов в LITE_SETUP.md разъехалось с plane_sync"
def test_env_map_and_smoke_are_linked_to_replication():
bodies = _section_bodies()
assert "REPLICATION.md" in bodies["## 4. Конфигурация"], (
"карта env обязана даваться ссылкой на REPLICATION.md §2 (FR-4)"
)
assert "REPLICATION.md" in bodies["## 11. Smoke"], (
"smoke обязан строиться на REPLICATION.md §4 ссылкой, без форка (FR-5)"
)
# ---------------------------------------------------------------------------
# TC-07: раздел Gitea соответствует инвариантам платформы (AC-7 / D3).
# ---------------------------------------------------------------------------
def test_gitea_section_fixes_platform_invariants():
bodies = _section_bodies()
gitea = bodies["## 6. Подключение Gitea"]
for event in ("push", "pull_request", "status"):
assert event in gitea, f"событие webhook {event!r} не зафиксировано в §6"
assert "ОДИН глобальный" in gitea or "один глобальный" in gitea.lower(), (
"§6 обязан фиксировать «ОДИН глобальный webhook-секрет на все репо»"
)
def test_gitea_section_forbids_branch_protection():
"""Исход А-1 (D3): branch protection на main НЕ включать (ADR D10 ORCH-009,
ломает PR-merge API merge-актора); pre-receive хуки не вводятся."""
bodies = _section_bodies()
gitea = bodies["## 6. Подключение Gitea"]
assert "branch protection" in gitea.lower(), "§6 не несёт норматив про branch protection"
assert "НЕ включа" in gitea, "§6 обязан нормативно запрещать branch protection на main"
assert "pre-receive" in gitea.lower(), "§6 обязан фиксировать: pre-receive хуки не вводятся"
# ---------------------------------------------------------------------------
# TC-08: перекрёстная документация (AC-5 / FR-7).
# ---------------------------------------------------------------------------
def test_replication_boundaries_reference_lite_setup():
text = REPLICATION.read_text(encoding="utf-8")
assert "LITE_SETUP.md" in text, (
"REPLICATION.md §1 обязан ссылаться на LITE_SETUP.md (Type A — Lite реализован)"
)
assert "ORCH-102" in text, "строка Type A — Lite в REPLICATION.md §1 не отмечена ✅ ORCH-102"
def test_changelog_has_orch_102_entry():
assert "ORCH-102" in CHANGELOG.read_text(encoding="utf-8")