Фундамент тиража 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>
262 lines
11 KiB
Python
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}"
|