Files
orchestrator/tests/test_install_lite_script.py
claude-bot 5aaf4989f0
Some checks failed
CI / test (push) Failing after 1m7s
CI / test (pull_request) Failing after 1m1s
developer(ET): auto-commit from developer run_id=658
2026-06-12 11:25:04 +03:00

736 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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