"""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}"