"""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(" $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")