292 lines
14 KiB
Python
292 lines
14 KiB
Python
"""ORCH-103 (TC-05/06/09/10/11, AC-4/AC-9): анти-дрейф golden source
|
||
`docs/deployment/BUNDLED_SETUP.md` + секрет-гигиена новых артефактов bundle.
|
||
|
||
Зеркало паттерна `tests/test_lite_setup_doc.py` (ORCH-102 D8): 14 нормативных
|
||
разделов ADR-001 D10 в порядке маршрута оператора; обязательные кирпичи;
|
||
«Требования к хосту» с цифрами, синхронизированными с константами preflight
|
||
`scripts/bootstrap_bundle.py`; каждый упомянутый env-ключ существует в канонах
|
||
(`.env.example` ∪ `deploy/bundled/.env.example`); гигиена FORBIDDEN (импорт из
|
||
`tests/test_no_host_hardcodes.py` — один источник истины) и секрет-эвристика
|
||
hex>=32 / alnum>=40 по доку и всем новым артефактам; «22 статуса» — сверкой
|
||
импорта `plane_sync._PLANE_NAME_TO_KEY`, не литералом; кросс-ссылки канона.
|
||
Детерминировано: без сети/LLM/subprocess/docker.
|
||
"""
|
||
|
||
import importlib.util
|
||
import re
|
||
from pathlib import Path
|
||
|
||
# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7).
|
||
from tests.test_no_host_hardcodes import FORBIDDEN
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
DOC = REPO_ROOT / "docs/deployment/BUNDLED_SETUP.md"
|
||
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"
|
||
BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example"
|
||
BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml"
|
||
BOOTSTRAP = REPO_ROOT / "scripts/bootstrap_bundle.py"
|
||
|
||
# Нормативная структура ADR-001 D10: 14 разделов, порядок = маршрут оператора.
|
||
SECTIONS: tuple[str, ...] = (
|
||
"## 1. Рамка Bundled",
|
||
"## 2. Требования к хосту",
|
||
"## 3. Предусловия",
|
||
"## 4. Получение кода",
|
||
"## 5. Секреты",
|
||
"## 6. Запуск bundle-compose",
|
||
"## 7. Bootstrap",
|
||
"## 8. LLM (claude CLI)",
|
||
"## 9. Telegram",
|
||
"## 10. Онбординг следующих проектов",
|
||
"## 11. Smoke",
|
||
"## 12. Stateless-проверка",
|
||
"## 13. Остановка и полный сброс",
|
||
"## 14. Траблшутинг",
|
||
)
|
||
|
||
# Обязательные кирпичи дока (FR-4; подстроки).
|
||
BRICKS: tuple[str, ...] = (
|
||
"bootstrap_bundle.py",
|
||
"gen_secrets.py",
|
||
"onboard_project.py",
|
||
"docker compose -f deploy/bundled/docker-compose.yml",
|
||
"orchestrator-bundle",
|
||
"/health",
|
||
"/queue",
|
||
"/metrics",
|
||
"Confirm Deploy",
|
||
"STOP",
|
||
"ALLOWED_HOST_LIST",
|
||
"14 контейнеров",
|
||
"Проверка",
|
||
"PASS",
|
||
"FAIL",
|
||
)
|
||
|
||
# env-токены дока: полные имена ключей платформы/bundle (анти-фантом, TC-09).
|
||
_ENV_TOKEN_RE = re.compile(r"\b(?:ORCH|WATCHDOG|BUNDLE)_[A-Z0-9_]*[A-Z0-9]\b")
|
||
|
||
# Секрет-эвристика (паттерн D8 ORCH-102): hex-run >= 32 / чистый alnum >= 40.
|
||
_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")
|
||
|
||
|
||
def _doc_text() -> str:
|
||
assert DOC.is_file(), "docs/deployment/BUNDLED_SETUP.md отсутствует (AC-4)"
|
||
return DOC.read_text(encoding="utf-8")
|
||
|
||
|
||
def _section_bodies() -> dict:
|
||
text = _doc_text()
|
||
bodies = {}
|
||
for i, header in enumerate(SECTIONS):
|
||
start = text.find(header)
|
||
assert start != -1, f"раздел {header!r} отсутствует (D10)"
|
||
end = text.find(SECTIONS[i + 1]) if i + 1 < len(SECTIONS) else len(text)
|
||
bodies[header] = text[start:end]
|
||
return bodies
|
||
|
||
|
||
def _fenced_blocks(text: str) -> list:
|
||
return re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
|
||
|
||
|
||
def _env_keys(path: Path) -> set:
|
||
keys = 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 _bootstrap_module():
|
||
spec = importlib.util.spec_from_file_location("bootstrap_bundle", BOOTSTRAP)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-05: 14 разделов в порядке + форма «команда + проверка» + цифры хоста.
|
||
# ---------------------------------------------------------------------------
|
||
def test_doc_exists_with_all_14_sections_in_order():
|
||
text = _doc_text()
|
||
positions = []
|
||
for header in SECTIONS:
|
||
idx = text.find(header)
|
||
assert idx != -1, f"нормативный раздел {header!r} отсутствует (D10/FR-4)"
|
||
positions.append(idx)
|
||
assert positions == sorted(positions), (
|
||
"разделы BUNDLED_SETUP.md идут не в порядке маршрута оператора (D10)"
|
||
)
|
||
|
||
|
||
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"обязательные кирпичи отсутствуют (FR-4): {missing}"
|
||
|
||
|
||
def test_every_executable_section_carries_commands():
|
||
"""§2–§14 несут минимум одну fenced-команду; §1 (рамка) — без команд."""
|
||
bodies = _section_bodies()
|
||
for header in SECTIONS[1:]:
|
||
assert "```" in bodies[header], f"{header}: нет ни одной fenced-команды (D10)"
|
||
|
||
|
||
def test_doc_carries_explicit_check_markers():
|
||
text = _doc_text()
|
||
assert text.count("Проверка") >= 13, (
|
||
"маркеров «Проверка» меньше, чем исполняемых разделов (форма D10)"
|
||
)
|
||
assert "PASS" in text and "FAIL" in text
|
||
|
||
|
||
def test_host_requirements_carry_measured_numbers_synced_with_preflight():
|
||
"""AC-4: «Требования к хосту» с явными цифрами RAM/диск/CPU и картой портов;
|
||
цифры = константам preflight bootstrap (D5: синхронизированы механически)."""
|
||
mod = _bootstrap_module()
|
||
body = _section_bodies()["## 2. Требования к хосту"]
|
||
assert f"{mod.MIN_RAM_GB} GB" in body, "цифра RAM разъехалась с MIN_RAM_GB"
|
||
assert f"{mod.MIN_DISK_GB} GB" in body, "цифра диска разъехалась с MIN_DISK_GB"
|
||
assert f"{mod.MIN_CPUS} vCPU" in body, "цифра CPU разъехалась с MIN_CPUS"
|
||
for port in ("8500", "8080", "3000"):
|
||
assert port in body, f"карта портов неполна: нет {port}"
|
||
assert "14 контейнеров" in body or "14 контейнеров" in _doc_text()
|
||
|
||
|
||
def test_doc_has_stateless_normative_line():
|
||
low = _doc_text().lower()
|
||
assert "не перенос" in low, (
|
||
"нормативная stateless-строка («…боевого хоста НЕ переносятся») "
|
||
"отсутствует (AC-3)"
|
||
)
|
||
stateless = _section_bodies()["## 12. Stateless-проверка"]
|
||
assert "/queue" in stateless, "§12 обязан нести проверку чистоты через GET /queue"
|
||
|
||
|
||
def test_teardown_is_documented_procedure():
|
||
"""D9: полный сброс — документированная процедура §13 (не режим скрипта)."""
|
||
teardown = _section_bodies()["## 13. Остановка и полный сброс"]
|
||
assert "down -v" in teardown, "§13 обязан нести полный сброс (down -v)"
|
||
assert "НЕОБРАТИМО" in teardown or "необратим" in teardown.lower()
|
||
|
||
|
||
def test_troubleshooting_covers_mandatory_symptoms():
|
||
"""FR-4 п.14: webhook, RAM/OOM, порт занят, claude, миграции Plane."""
|
||
tr = _section_bodies()["## 14. Траблшутинг"]
|
||
for needle in ("ebhook", "OOM", "орт занят", "claude", "играции"):
|
||
assert needle in tr, f"траблшутинг не покрывает симптом: {needle!r}"
|
||
assert "ALLOWED_HOST_LIST" in tr # мина Gitea — явно (D10)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-06: гигиена новых артефактов — FORBIDDEN (импорт) + секрет-эвристика.
|
||
# ---------------------------------------------------------------------------
|
||
def _new_artifact_texts() -> dict:
|
||
return {
|
||
"BUNDLED_SETUP.md (fenced)": "\n".join(_fenced_blocks(_doc_text())),
|
||
"deploy/bundled/docker-compose.yml": BUNDLE_COMPOSE.read_text(encoding="utf-8"),
|
||
"deploy/bundled/.env.example": BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8"),
|
||
"scripts/bootstrap_bundle.py": BOOTSTRAP.read_text(encoding="utf-8"),
|
||
}
|
||
|
||
|
||
def test_new_artifacts_carry_no_forbidden_literals():
|
||
offenders = [
|
||
f"{label}: {literal!r}"
|
||
for label, text in _new_artifact_texts().items()
|
||
for literal in FORBIDDEN
|
||
if literal in text
|
||
]
|
||
assert not offenders, (
|
||
"боевые литералы в артефактах bundle (NFR-3/TC-06):\n" + "\n".join(offenders)
|
||
)
|
||
|
||
|
||
def test_new_artifacts_carry_no_secret_like_values():
|
||
offenders = []
|
||
for label, text in _new_artifact_texts().items():
|
||
for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
|
||
m = rx.search(text)
|
||
if m is not None:
|
||
offenders.append(f"{label}: {m.group(0)[:16]}…")
|
||
assert not offenders, (
|
||
"секретоподобные значения в артефактах bundle (NFR-3):\n" + "\n".join(offenders)
|
||
)
|
||
|
||
|
||
def test_secret_heuristic_is_not_evergreen():
|
||
"""Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит."""
|
||
assert _SECRET_HEX_RE.search("KEY=" + "0fa1" * 16) is not None
|
||
assert _SECRET_ALNUM_RE.search("token" + "Ab1" * 15) 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-09: env-канон без фантомов + число статусов сверкой импорта.
|
||
# ---------------------------------------------------------------------------
|
||
def test_every_env_token_in_doc_exists_in_canons():
|
||
canon = _env_keys(ENV_EXAMPLE) | _env_keys(BUNDLE_ENV_EXAMPLE)
|
||
mentioned = set(_ENV_TOKEN_RE.findall(_doc_text()))
|
||
assert mentioned, "в BUNDLED_SETUP.md не упомянут ни один env-ключ — док не полон"
|
||
unknown = sorted(mentioned - canon)
|
||
assert not unknown, (
|
||
f"ключи из BUNDLED_SETUP.md отсутствуют в канонах (.env.example ∪ "
|
||
f"deploy/bundled/.env.example) — опечатка или дрейф (TC-09): {unknown}"
|
||
)
|
||
|
||
|
||
def test_status_count_claim_matches_plane_sync():
|
||
"""«22 статуса» держится фактическим маппингом src/plane_sync.py (AC-7:
|
||
сверка импортом, не строковой копией)."""
|
||
from src.plane_sync import _PLANE_NAME_TO_KEY
|
||
|
||
assert len(_PLANE_NAME_TO_KEY) == 22, (
|
||
"число статусов в plane_sync изменилось — обнови BUNDLED_SETUP.md §7 "
|
||
"(и ONBOARDING.md §1)"
|
||
)
|
||
assert "Confirm Deploy" in _PLANE_NAME_TO_KEY
|
||
assert "STOP" in _PLANE_NAME_TO_KEY
|
||
assert "22" in _doc_text(), "число статусов в BUNDLED_SETUP.md разъехалось с plane_sync"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-10: канон не форкается — кросс-ссылки; REPLICATION §1 отмечает Type B.
|
||
# ---------------------------------------------------------------------------
|
||
def test_doc_links_canons_instead_of_forking():
|
||
text = _doc_text()
|
||
for canon in ("LITE_SETUP.md", "ONBOARDING.md", "REPLICATION.md"):
|
||
assert canon in text, f"BUNDLED_SETUP.md не ссылается на канон {canon} (FR-4)"
|
||
bodies = _section_bodies()
|
||
assert "LITE_SETUP.md" in bodies["## 8. LLM (claude CLI)"], "§8 — ссылкой на LITE_SETUP §7"
|
||
assert "LITE_SETUP.md" in bodies["## 9. Telegram"], "§9 — ссылкой на LITE_SETUP §8"
|
||
assert "ONBOARDING.md" in bodies["## 10. Онбординг следующих проектов"]
|
||
assert "REPLICATION.md" in bodies["## 11. Smoke"], "smoke — на REPLICATION §4, без форка"
|
||
|
||
|
||
def test_replication_marks_type_b_done():
|
||
text = REPLICATION.read_text(encoding="utf-8")
|
||
assert "BUNDLED_SETUP.md" in text, (
|
||
"REPLICATION.md §1 обязан ссылаться на BUNDLED_SETUP.md (Type B реализован)"
|
||
)
|
||
assert "ORCH-103" in text, "строка Type B в REPLICATION.md §1 не отмечена ✅ ORCH-103"
|
||
|
||
|
||
def test_lite_setup_untouched_reference_exists():
|
||
"""Канон Lite остаётся на месте (Bundled его дополняет, не заменяет)."""
|
||
assert LITE_SETUP.is_file()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TC-11: CHANGELOG.
|
||
# ---------------------------------------------------------------------------
|
||
def test_changelog_has_orch_103_entry():
|
||
assert "ORCH-103" in CHANGELOG.read_text(encoding="utf-8")
|