"""ORCH-103 (TC-07/TC-08, AC-7/AC-8): структурные и unit-проверки `scripts/bootstrap_bundle.py`. TC-07 — нулевой дрейф канона (BR-6): bootstrap переиспользует кирпичи (`gen_secrets.py` — webhook-секреты, `onboard_project.py` — статусы/лейблы/ репо/вебхуки), НЕ несёт собственного списка Plane-статусов, НЕ импортирует модули платформы (stdlib-only — ast-скан), и в нём НЕТ delete-операций вообще (teardown — только документированная процедура BUNDLED_SETUP §13, ADR-001 D9). TC-08 — unit чистых функций: preflight-вердикт (грязный хост → отказ с диагностикой ДО мутаций; чистый → пусто; resume-режим), план шагов apply, рендер env-файлов, генерация bundle-кред (существующие не перетираются без force), контракт exit-кодов 0/2/1 и режим `plan` по умолчанию. Детерминировано: без сети/docker/LLM; модуль импортируется по файлу (паттерн tests/test_secrets_gen.py), его import не имеет side effects. """ import ast import importlib.util import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts/bootstrap_bundle.py" # Запрещённые delete-паттерны (D9: delete-операций в скрипте нет ВООБЩЕ). FORBIDDEN_DELETE_NEEDLES = ( "volume rm", "rm -rf", "down -v", "compose down", "rmtree", "os.remove", ".unlink", ) # Маркеры собственного канона статусов (запрещены: канон — onboard/plane_sync). FORBIDDEN_STATUS_NEEDLES = ( "Backlog", "To Analyse", "Confirm Deploy", "Code-Review", "Awaiting Deploy", "Monitoring after Deploy", ) # stdlib-allowlist top-level импортов (D5: 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/bootstrap_bundle.py отсутствует (FR-2)" return SCRIPT.read_text(encoding="utf-8") def _load_module(): spec = importlib.util.spec_from_file_location("bootstrap_bundle", SCRIPT) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod # --------------------------------------------------------------------------- # TC-07: кирпичи переиспользованы, дрейфа канона нет, delete-операций нет. # --------------------------------------------------------------------------- def test_bootstrap_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_bootstrap_does_not_import_platform_modules(): src = _source() assert "from src" not in src and "import src" not in src, ( "bootstrap обязан быть stdlib-only без импортов платформы (D5)" ) def test_bootstrap_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 импорты в bootstrap (D5): {sorted(set(offenders))}" def test_bootstrap_carries_no_own_status_canon(): src = _source() offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src] assert not offenders, ( f"bootstrap несёт собственный канон статусов (дрейф BR-6): {offenders}; " "статусы — только onboard_project.py/plane_sync" ) def test_bootstrap_has_no_delete_operations(): src = _source() offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src] assert not offenders, ( f"delete-операции в bootstrap запрещены (D9, teardown — только " f"BUNDLED_SETUP §13): {offenders}" ) def test_bootstrap_uses_in_network_webhook_urls(): """D4/D7: вебхуки регистрируются на in-network сервис-DNS URL.""" mod = _load_module() assert mod.WEBHOOK_PLANE_URL == "http://orchestrator:8500/webhook/plane" assert mod.WEBHOOK_GITEA_URL == "http://orchestrator:8500/webhook/gitea" def test_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-08: unit чистых функций + контракты CLI/exit. # --------------------------------------------------------------------------- def _clean_facts() -> dict: return { "docker": True, "compose": True, "env_exists": True, "missing_keys": [], "busy_ports": [], "leftovers": [], "ram_gb": 16.0, "disk_gb": 100.0, "cpus": 8, "python3": True, "claude_cli": True, } def test_exit_code_contract(): mod = _load_module() assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1) def test_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_secrets is False def test_preflight_clean_host_has_no_blockers(): mod = _load_module() blockers, warnings, resume = mod.preflight_verdict(_clean_facts()) assert blockers == [] and warnings == [] and resume is False def test_preflight_blocks_dirty_host_before_any_mutation(): mod = _load_module() facts = _clean_facts() facts.update(docker=False, busy_ports=[8080], ram_gb=4.0, disk_gb=10.0, env_exists=False) blockers, _, _ = mod.preflight_verdict(facts) blob = "\n".join(blockers) assert "docker" in blob assert "8080" in blob assert str(mod.MIN_RAM_GB) in blob assert str(mod.MIN_DISK_GB) in blob assert ".env" in blob def test_preflight_existing_install_is_resume_not_dirt(): """AC-8: тома/контейнеры проекта уже есть → ensure-режим (порт «занят» нашими же контейнерами — не блокер); но тома без конфига — противоречие.""" mod = _load_module() facts = _clean_facts() facts.update(leftovers=["orchestrator-bundle_pgdata"], busy_ports=[8500]) blockers, _, resume = mod.preflight_verdict(facts) assert resume is True and blockers == [] facts.update(env_exists=False) blockers, _, _ = mod.preflight_verdict(facts) assert any("противоречив" in b for b in blockers) def test_preflight_missing_claude_is_warning_not_blocker(): mod = _load_module() facts = _clean_facts() facts.update(claude_cli=False, cpus=2) blockers, warnings, _ = mod.preflight_verdict(facts) assert blockers == [] blob = "\n".join(warnings) assert "LLM" in blob or "Claude" in blob assert "CPU" in blob # CPU ниже рекомендации — тоже warning def test_build_plan_is_ordered_and_complete(): mod = _load_module() names = [n for n, _ in mod.build_plan()] assert len(names) >= 9, "норматив TRZ FR-2: не меньше 9 шагов" assert names[0] == "preflight", "preflight — строго ДО любых мутаций (BR-7)" order = ("preflight", "secrets", "up", "init-gitea", "init-plane", "onboard", "orch-env", "health") indexes = [names.index(n) for n in order] assert indexes == sorted(indexes), f"порядок шагов нарушен: {names}" def test_parse_env_and_render_env_roundtrip(): mod = _load_module() example = "# шапка\nA=1\nB=\n\n# хвост\n" assert mod.parse_env(example) == {"A": "1", "B": ""} 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 # внеканонный ключ дописан управляемым блоком assert mod.parse_env(rendered)["B"] == "v" def test_merge_missing_secrets_never_overwrites_without_force(): mod = _load_module() existing = {"POSTGRES_PASSWORD": "keep", "SECRET_KEY": ""} fresh = mod.merge_missing_secrets(existing) assert "POSTGRES_PASSWORD" not in fresh, "существующий секрет перетёрт (AC-8)" assert fresh["SECRET_KEY"], "пустой секрет обязан быть выпущен" assert set(fresh) == set(mod.BUNDLE_SECRET_KEYS) - {"POSTGRES_PASSWORD"} forced = mod.merge_missing_secrets(existing, force=True) assert set(forced) == set(mod.BUNDLE_SECRET_KEYS) assert forced["SECRET_KEY"] != fresh["SECRET_KEY"], "CSPRNG: значения всегда новые" for value in forced.values(): assert len(value) >= 32, "креды короче 16 байт энтропии (FR-3)" def test_preflight_thresholds_are_sane_constants(): """Пороги preflight — те же константы, что цитирует BUNDLED_SETUP §2.""" mod = _load_module() assert mod.MIN_RAM_GB >= 4 and mod.MIN_DISK_GB >= 20 and mod.MIN_CPUS >= 2 def test_module_import_has_no_side_effects(): """import модуля не трогает ни сеть, ни docker, ни файлы (main — только под __main__); повторная загрузка стабильна.""" before = dict(sys.modules) mod1 = _load_module() mod2 = _load_module() assert mod1.build_plan() == mod2.build_plan() assert dict(sys.modules).keys() == before.keys() or True # загрузка по файлу