Files
orchestrator/tests/test_infra_parametrization.py
claude-bot f1635ddb39
All checks were successful
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 55s
feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
Фундамент тиража 10-common (эпик ORCH-10): платформа разворачивается на
новой инфре без правки кода — только env/конфиг. Каждый дефолт = боевому
значению (пустой .env => поведение 1:1, kill-switch-природа, NFR-2);
STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/схема БД не тронуты.

- config: agent_home_dir / agent_git_name / git_email_domain / staging_port
  (ADR-001 D2/D4); код-блокеры A1-A4 закрыты: plane_sync ссылки из
  gitea_public_url+gitea_owner, launcher - единый agent_git_env() (x2 места),
  self_deploy/post_deploy - HOME+домен из Settings (имена системных акторов -
  платформенные литералы)
- image_freshness: staging_port из конфига + fail-closed guard
  staging_port == прод-порт -> отказ ДО ssh/build (инвариант ORCH-058 AC-9
  стал исполняемым); REPO= передаётся хуку явно обоими инвокерами (D7)
- SELF_HOSTING_REPO - нормативная платформенная константа (D3, пин-тест)
- compose: полная ${VAR:-default}-интерполяция (реестр B, карта D6); группа
  ORCH-040 uid/gid/HOME/маунты двигается согласованно (build.args APP_*);
  group_add "МИНА 1" сохранён x3; оба app-сервиса с явным command:
- Dockerfile: ARG APP_UID/APP_GID/APP_USER/APP_HOME (CMD exec-form 8500
  сознательно не тронут - D5); deploy-hook: REPO="${REPO:-...}" (D1 реестра)
- секреты: stdlib scripts/gen_secrets.py (token_hex(32); печать по умолчанию;
  --write никогда не перезаписывает существующий .env молча, exit=2;
  перезапись только --force); .env.example дополнен до полноты ключей старта
- доки: новый docs/operations/REPLICATION.md (карта env, чек-лист секретов,
  smoke-процедура с PASS/FAIL, границы 10-common/Lite/Bundled), INFRA.md,
  README, CLAUDE.md, CHANGELOG
- анти-регресс: tests/test_no_host_hardcodes.py (tokenize-сканер запрещённых
  литералов, config-модули - структурное исключение, allowlist пуст,
  негативная самопроверка) + test_host_config_keys / test_infra_parametrization
  / test_secrets_gen / test_replication_smoke; согласованные структурные
  правки test_orch040_compose (судит резолв дефолтов) и
  test_deploy_hook_rollback_sim (REPO через env-override = контракт D7)

Полный регресс: 1764 passed.

Refs: ORCH-101

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:50:43 +03:00

262 lines
11 KiB
Python

"""ORCH-101 (TC-06/TC-07/TC-08, AC-2/AC-5/AC-6): structural checks of the
infra-file parametrization — docker-compose.yml interpolation, Dockerfile ARGs,
deploy-hook env-override and .env.example completeness.
Every ${VAR:-default} default must equal the previous production value, so an
empty environment resolves byte-for-byte to the pre-ORCH-101 configuration
(BR-5 zero-regression). The ORCH-040 group (uid/gid/HOME/mount targets/
useradd) must move as ONE coherent set of variables, and the docker-gid
group_add («МИНА 1») must stay on all three services.
"""
import re
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
COMPOSE = REPO_ROOT / "docker-compose.yml"
DOCKERFILE = REPO_ROOT / "Dockerfile"
HOOK = REPO_ROOT / "scripts/orchestrator-deploy-hook.sh"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
# The normative interpolation map (ADR-001 D6): variable -> default that MUST
# equal the previous hardcoded production value.
EXPECTED_DEFAULTS = {
"ORCH_HOST_REPOS_DIR": "/home/slin/repos",
"ORCH_HOST_CLAUDE_DIR": "/home/slin/.claude",
"ORCH_HOST_CLAUDE_JSON": "/home/slin/.claude.json",
"ORCH_HOST_SSH_DIR": "/home/slin/.orchestrator-ssh",
"ORCH_AGENT_HOME_DIR": "/home/slin",
"ORCH_HOST_CLAUDE_CODE_DIR": "/usr/lib/node_modules/@anthropic-ai/claude-code",
"ORCH_HOST_NODE_BIN": "/usr/bin/node",
"ORCH_DOCKER_GID": "999",
"ORCH_RUN_UID": "1000",
"ORCH_RUN_GID": "1000",
"ORCH_DEPLOY_SSH_USER": "slin",
"ORCH_DEPLOY_HOST_REPO_PATH": "/home/slin/repos/orchestrator",
"DEPLOY_HOOK_SCRIPT": "/home/slin/bin/enduro-deploy-hook.sh",
"ORCH_STAGING_PORT": "8501",
"ORCH_DEPLOY_PROD_TARGET_PORT": "8500",
}
_INTERP_RE = re.compile(r"\$\{([A-Z0-9_]+):-([^}]*)\}")
def _compose_raw() -> str:
return COMPOSE.read_text(encoding="utf-8")
def _compose_code_lines() -> list[tuple[int, str]]:
"""Compose lines with comment-only lines dropped (comments are prose, not
configuration — the interpolation contract is judged on values)."""
out = []
for lineno, line in enumerate(_compose_raw().splitlines(), 1):
if line.strip().startswith("#"):
continue
out.append((lineno, line))
return out
def _compose_services() -> dict:
return yaml.safe_load(_compose_raw())["services"]
# ---------------------------------------------------------------------------
# TC-06: compose interpolation + defaults == current values + ORCH-040 group.
# ---------------------------------------------------------------------------
def test_compose_interpolation_defaults_match_production_values():
found: dict[str, set[str]] = {}
for _, line in _compose_code_lines():
for var, default in _INTERP_RE.findall(line):
found.setdefault(var, set()).add(default)
# Every expected variable interpolates somewhere, with EXACTLY the expected
# default, and consistently (one default per variable across the file).
for var, expected in EXPECTED_DEFAULTS.items():
assert var in found, f"{var} is not interpolated in docker-compose.yml"
assert found[var] == {expected}, (
f"{var}: defaults {found[var]} != expected {{{expected!r}}}"
)
# No stray interpolations outside the normative map (single point of truth).
unknown = set(found) - set(EXPECTED_DEFAULTS)
assert not unknown, f"unmapped compose variables: {unknown}"
def test_compose_no_raw_host_paths_outside_interpolation_defaults():
"""Registry B closed: after stripping ${VAR:-default} expressions and
comments, no /home/slin (or node/claude-code host path) literal remains."""
leftovers = []
for lineno, line in _compose_code_lines():
code = _INTERP_RE.sub("", line)
# NB: /usr/bin/node is NOT a needle — the mount TARGET inside the
# container is a layout convention and legitimately stays literal.
for needle in ("/home/slin", "/usr/lib/node_modules"):
if needle in code:
leftovers.append(f"{lineno}: {line.strip()}")
assert not leftovers, "raw host paths left in compose:\n" + "\n".join(leftovers)
def test_compose_group_add_docker_gid_on_all_three_services():
"""ORCH-040 «МИНА 1»: group_add stays on every service, parametrised."""
services = _compose_services()
assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}
for name, svc in services.items():
group_add = svc.get("group_add")
assert group_add == ["${ORCH_DOCKER_GID:-999}"], (
f"{name}: group_add must carry the parametrised docker gid, got {group_add}"
)
def test_compose_uid_gid_home_move_as_one_group():
"""ORCH-040 coherence: runtime user:, build args and mount targets read the
SAME variables, so uid/gid/HOME can only move together."""
services = _compose_services()
for name in ("orchestrator", "orchestrator-staging"):
svc = services[name]
assert svc["user"] == "${ORCH_RUN_UID:-1000}:${ORCH_RUN_GID:-1000}", name
args = svc["build"]["args"]
assert args["APP_UID"] == "${ORCH_RUN_UID:-1000}", name
assert args["APP_GID"] == "${ORCH_RUN_GID:-1000}", name
assert args["APP_HOME"] == "${ORCH_AGENT_HOME_DIR:-/home/slin}", name
volumes = svc["volumes"]
home = "${ORCH_AGENT_HOME_DIR:-/home/slin}"
assert f"${{ORCH_HOST_CLAUDE_DIR:-/home/slin/.claude}}:{home}/.claude" in volumes, name
assert f"${{ORCH_HOST_SSH_DIR:-/home/slin/.orchestrator-ssh}}:{home}/.ssh:ro" in volumes, name
def test_compose_ports_parametrised_with_current_defaults():
services = _compose_services()
prod_cmd = services["orchestrator"]["command"]
staging_cmd = services["orchestrator-staging"]["command"]
# D5: prod reuses the existing ORCH_DEPLOY_PROD_TARGET_PORT (one truth);
# D4: staging reuses the Settings-shared ORCH_STAGING_PORT.
assert prod_cmd[-1] == "${ORCH_DEPLOY_PROD_TARGET_PORT:-8500}"
assert staging_cmd[-1] == "${ORCH_STAGING_PORT:-8501}"
assert prod_cmd[:2] == ["uvicorn", "src.main:app"]
assert staging_cmd[:2] == ["uvicorn", "src.main:app"]
def test_compose_container_layout_paths_stay_constant():
"""Container-side paths are a layout convention, not host values (D6)."""
services = _compose_services()
for name in ("orchestrator", "orchestrator-staging"):
volumes = services[name]["volumes"]
assert any(v.endswith(":/repos") for v in volumes), name
assert any(v.endswith(":/opt/claude-code:ro") for v in volumes), name
env = services[name]["environment"]
assert "ORCH_REPOS_DIR=/repos" in env, name
# ---------------------------------------------------------------------------
# TC-07: Dockerfile — useradd via ARG with production defaults; CMD untouched.
# ---------------------------------------------------------------------------
def test_dockerfile_useradd_parametrised_via_args():
text = DOCKERFILE.read_text(encoding="utf-8")
assert "ARG APP_UID=1000" in text
assert "ARG APP_GID=1000" in text
assert "ARG APP_USER=slin" in text
assert "ARG APP_HOME=/home/slin" in text
useradd = next(
line for line in text.splitlines()
if line.startswith("RUN") and "useradd" in line
)
for ref in ("${APP_UID}", "${APP_GID}", "${APP_HOME}", "${APP_USER}"):
assert ref in useradd, f"useradd does not use {ref}"
assert "/home/slin" not in useradd # the literal moved into the ARG default
def test_dockerfile_cmd_stays_exec_form_8500():
"""ADR-001 D5: CMD keeps the documented exec-form 8500 default (PID-1 /
signal semantics of init:true + exec-form preserved); the prod port is
parametrised on the compose layer instead."""
text = DOCKERFILE.read_text(encoding="utf-8")
assert 'CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]' in text
# ---------------------------------------------------------------------------
# TC-08: deploy-hook env-override + .env.example completeness.
# ---------------------------------------------------------------------------
def test_deploy_hook_repo_is_env_overridable():
text = HOOK.read_text(encoding="utf-8")
assert 'REPO="${REPO:-/home/slin/repos/orchestrator}"' in text
# The old unconditional assignment is gone.
assert "\nREPO=/home/slin/repos/orchestrator\n" not in text
def _env_example_keys() -> dict[str, str]:
keys: dict[str, str] = {}
for line in ENV_EXAMPLE.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)
keys[k.strip()] = v.strip()
return keys
# Keys an operator must see when provisioning a new host: the new ORCH-101
# parametrization keys + the start-mandatory identity/secret keys (FR-4.4).
NEW_KEYS = (
"ORCH_AGENT_HOME_DIR",
"ORCH_AGENT_GIT_NAME",
"ORCH_GIT_EMAIL_DOMAIN",
"ORCH_STAGING_PORT",
"ORCH_HOST_REPOS_DIR",
"ORCH_HOST_CLAUDE_DIR",
"ORCH_HOST_CLAUDE_JSON",
"ORCH_HOST_SSH_DIR",
"ORCH_HOST_CLAUDE_CODE_DIR",
"ORCH_HOST_NODE_BIN",
"ORCH_DOCKER_GID",
"ORCH_RUN_UID",
"ORCH_RUN_GID",
"ORCH_DEPLOY_PROD_TARGET_PORT", # pre-existing, reused by compose command:
)
START_MANDATORY_KEYS = (
"ORCH_PLANE_API_URL",
"ORCH_PLANE_API_TOKEN",
"ORCH_PLANE_WORKSPACE_SLUG",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_URL",
"ORCH_GITEA_PUBLIC_URL",
"ORCH_GITEA_TOKEN",
"ORCH_GITEA_WEBHOOK_SECRET",
"ORCH_GITEA_OWNER",
"ORCH_TELEGRAM_BOT_TOKEN",
"ORCH_TELEGRAM_CHAT_ID",
"ORCH_PROJECTS_JSON",
)
SECRET_KEYS = (
"ORCH_PLANE_API_TOKEN",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_TOKEN",
"ORCH_GITEA_WEBHOOK_SECRET",
"ORCH_TELEGRAM_BOT_TOKEN",
"WATCHDOG_TG_BOT_TOKEN",
)
def test_env_example_contains_all_new_keys():
keys = _env_example_keys()
missing = [k for k in NEW_KEYS if k not in keys]
assert not missing, f".env.example is missing new ORCH-101 keys: {missing}"
# Defaults documented in .env.example match the compose interpolation map.
for var, expected in EXPECTED_DEFAULTS.items():
if var in keys and var not in ("DEPLOY_HOOK_SCRIPT",):
assert keys[var] in ("", expected), (
f".env.example {var}={keys[var]!r} contradicts default {expected!r}"
)
def test_env_example_contains_start_mandatory_keys():
keys = _env_example_keys()
missing = [k for k in START_MANDATORY_KEYS if k not in keys]
assert not missing, f".env.example is missing start-mandatory keys: {missing}"
def test_env_example_secrets_are_placeholders_only():
keys = _env_example_keys()
for k in SECRET_KEYS:
value = keys.get(k, "")
assert value == "", f"{k} must be an empty placeholder in git, got {value!r}"