Files
orchestrator/tests/test_host_config_keys.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

265 lines
12 KiB
Python

"""ORCH-101 (TC-03/TC-04/TC-05/TC-11, AC-1/AC-2/AC-8): host values come from
Settings, defaults equal the previous production literals, and the ORCH-058
anti-prod invariant is an executable guard.
Functional where the seam allows (launcher.agent_git_env, plane_sync link
build, image_freshness guard — monkeypatch, no network), structural where the
env dict is built inline inside a never-raise actor (self_deploy/post_deploy:
the scanner in test_no_host_hardcodes.py already proves the literals are gone;
here we pin that the replacement reads the RIGHT Settings keys).
"""
from pathlib import Path
from src.config import Settings, settings
REPO_ROOT = Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# AC-2 / BR-5: defaults of the new keys equal the previous hardcoded values.
# Judged on the class fields (immune to ambient env / .env of the test host).
# ---------------------------------------------------------------------------
def test_new_settings_defaults_equal_previous_production_values():
fields = Settings.model_fields
assert fields["agent_home_dir"].default == "/home/slin"
assert fields["agent_git_name"].default == "claude-bot"
assert fields["git_email_domain"].default == "mva154.local"
assert fields["staging_port"].default == 8501
# Registry E (BR-5): pre-existing defaults are NOT changed by ORCH-101.
assert fields["deploy_prod_target_port"].default == 8500
assert fields["deploy_host_repo_path"].default == "/home/slin/repos/orchestrator"
assert fields["host_repos_dir"].default == "/home/slin/repos"
assert fields["deploy_ssh_user"].default == "slin"
assert fields["gitea_owner"].default == "admin"
# ---------------------------------------------------------------------------
# TC-04: launcher.agent_git_env — single Settings-driven source for BOTH the
# agent Popen and the post-run git commit/push.
# ---------------------------------------------------------------------------
def test_agent_git_env_reads_settings(monkeypatch):
from src.agents import launcher
monkeypatch.setattr(launcher.settings, "agent_home_dir", "/home/deploy")
monkeypatch.setattr(launcher.settings, "agent_git_name", "robo-bot")
monkeypatch.setattr(launcher.settings, "git_email_domain", "newhost.example")
env = launcher.agent_git_env()
assert env["HOME"] == "/home/deploy"
assert env["GIT_AUTHOR_NAME"] == "robo-bot"
assert env["GIT_COMMITTER_NAME"] == "robo-bot"
assert env["GIT_AUTHOR_EMAIL"] == "robo-bot@newhost.example"
assert env["GIT_COMMITTER_EMAIL"] == "robo-bot@newhost.example"
def test_agent_git_env_default_identity_matches_previous_hardcode(monkeypatch):
from src.agents import launcher
# Pin the resolved values to the class defaults (ambient-env immune).
monkeypatch.setattr(launcher.settings, "agent_home_dir", "/home/slin")
monkeypatch.setattr(launcher.settings, "agent_git_name", "claude-bot")
monkeypatch.setattr(launcher.settings, "git_email_domain", "mva154.local")
env = launcher.agent_git_env()
assert env["HOME"] == "/home/slin"
assert env["GIT_AUTHOR_EMAIL"] == "claude-bot@mva154.local"
assert env["GIT_COMMITTER_EMAIL"] == "claude-bot@mva154.local"
def test_agent_git_env_preserves_ambient_environ(monkeypatch):
from src.agents import launcher
monkeypatch.setenv("ORCH101_CANARY", "yes")
assert launcher.agent_git_env()["ORCH101_CANARY"] == "yes"
def test_both_launcher_sites_use_the_helper():
"""Structural: the Popen env AND the post-run git env share one source."""
src = (REPO_ROOT / "src/agents/launcher.py").read_text(encoding="utf-8")
assert "env=agent_git_env()" in src # agent Popen site
assert "git_env = agent_git_env()" in src # post-run commit/push site
# ---------------------------------------------------------------------------
# TC-05: system actors (deploy-finalizer / post-deploy-monitor) — HOME + email
# domain from Settings, actor NAMES stay platform literals (ADR-001 D2).
# ---------------------------------------------------------------------------
def test_system_actor_envs_read_settings():
sd = (REPO_ROOT / "src/self_deploy.py").read_text(encoding="utf-8")
pd = (REPO_ROOT / "src/post_deploy.py").read_text(encoding="utf-8")
for source, actor in ((sd, "deploy-finalizer"), (pd, "post-deploy-monitor")):
assert '"HOME": settings.agent_home_dir' in source
assert f'f"{actor}@{{settings.git_email_domain}}"' in source
assert f'"GIT_AUTHOR_NAME": "{actor}"' in source # platform literal kept
# ---------------------------------------------------------------------------
# TC-03: plane_sync.notify_stage_change builds links from Settings
# (gitea_public_url fallback gitea_url + gitea_owner). No network: every
# outbound seam is monkeypatched.
# ---------------------------------------------------------------------------
def _capture_stage_change_msg(monkeypatch, new_stage="development"):
import src.db as db
from src import plane_sync
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda wi, pid=None: "proj")
monkeypatch.setattr(plane_sync, "update_issue_state", lambda *a, **k: None)
sent = {}
monkeypatch.setattr(
plane_sync, "add_comment",
lambda wi, msg, pid=None, author=None: sent.setdefault("msg", msg),
)
class _Cursor:
def fetchone(self):
return ("feature/ORCH-1-demo", "demo-repo")
class _Conn:
def execute(self, *a):
return _Cursor()
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _Conn())
plane_sync.notify_stage_change("ORCH-1", "analysis", new_stage)
return sent["msg"]
def test_stage_change_link_uses_public_url_and_owner(monkeypatch):
from src import plane_sync
monkeypatch.setattr(plane_sync.settings, "gitea_public_url", "https://git.example.org/")
monkeypatch.setattr(plane_sync.settings, "gitea_owner", "acme")
msg = _capture_stage_change_msg(monkeypatch)
assert "https://git.example.org/acme/demo-repo/src/branch/feature/ORCH-1-demo" in msg
assert "mva154" not in msg and "duckdns" not in msg
assert "/admin/" not in msg # the hardcoded owner is gone
def test_stage_change_link_falls_back_to_gitea_url(monkeypatch):
from src import plane_sync
monkeypatch.setattr(plane_sync.settings, "gitea_public_url", "")
monkeypatch.setattr(plane_sync.settings, "gitea_url", "http://gitea.lan:3000")
monkeypatch.setattr(plane_sync.settings, "gitea_owner", "owner1")
msg = _capture_stage_change_msg(monkeypatch)
assert "http://gitea.lan:3000/owner1/demo-repo/src/branch/" in msg
def test_stage_change_hardcoded_base_removed_from_source():
src = (REPO_ROOT / "src/plane_sync.py").read_text(encoding="utf-8")
assert "git.mva154.duckdns.org" not in src
assert 'gitea_base + "/admin/"' not in src
# ---------------------------------------------------------------------------
# TC-11 / AC-8: ORCH-058 invariant — the freshness path can never be aimed at
# the prod target. staging_port is a config key (default 8501) WITH a
# fail-closed guard; service/profile names stay platform constants.
# ---------------------------------------------------------------------------
def test_staging_port_guard_refuses_prod_port(monkeypatch):
from src import image_freshness as imf
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf.settings, "staging_port", 8500)
monkeypatch.setattr(imf.settings, "deploy_prod_target_port", 8500)
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-1", "feature/x")
assert ok is False
assert "misconfiguration" in reason and "ORCH-058" in reason
assert "refused" in reason # loud refusal, no silent 8501 fallback
def test_staging_port_default_passes_guard(monkeypatch, tmp_path):
from src import image_freshness as imf
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf.settings, "staging_port", 8501)
monkeypatch.setattr(imf.settings, "deploy_prod_target_port", 8500)
# Point the worktree somewhere empty: the check must get PAST the guard and
# fail-close later on the missing validated revision (proves the guard
# itself did not fire on the default 8501/8500 split).
monkeypatch.setattr(imf.settings, "worktrees_dir", str(tmp_path / "none"))
monkeypatch.setattr(imf.settings, "repos_dir", str(tmp_path / "none"))
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-1", "feature/x")
assert ok is False
assert "misconfiguration" not in reason
assert "validated revision" in reason
def test_staging_service_names_stay_platform_constants():
from src import image_freshness as imf
assert imf._STAGING_SERVICE == "orchestrator-staging"
assert imf._STAGING_COMPOSE_PROFILE == "staging"
def test_rebuild_staging_passes_configured_port_and_repo(monkeypatch):
"""D4+D7: the wired path passes TARGET_PORT from settings.staging_port and
REPO from settings.deploy_host_repo_path EXPLICITLY (explicit-pass
discipline of ORCH-058 kept; config is the single truth on the wired path)."""
from src import image_freshness as imf
monkeypatch.setattr(imf.settings, "deploy_ssh_user", "u")
monkeypatch.setattr(imf.settings, "deploy_ssh_host", "127.0.0.1")
monkeypatch.setattr(imf.settings, "staging_port", 9501)
monkeypatch.setattr(imf.settings, "deploy_host_repo_path", "/srv/orchestrator")
captured = {}
class _R:
returncode = 0
stdout = ""
stderr = ""
def fake_run(cmd, **kw):
captured["cmd"] = cmd
return _R()
monkeypatch.setattr(imf.subprocess, "run", fake_run)
ok, _ = imf.rebuild_staging_image("orchestrator", "feature/x", "abc1234")
assert ok is True
inner = captured["cmd"][-1]
assert "TARGET_PORT=9501" in inner
assert "REPO=/srv/orchestrator" in inner
assert "TARGET_SERVICE=orchestrator-staging" in inner
def test_build_deploy_command_passes_repo_explicitly(monkeypatch):
"""D7: the detached prod deploy passes REPO= so the hook env-override is
actually exercised on a parametrised host (hook default = manual runs only)."""
from src import self_deploy
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "u")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "127.0.0.1")
monkeypatch.setattr(self_deploy.settings, "deploy_host_repo_path", "/srv/orchestrator")
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-101", "feature/x")
remote = cmd[-1]
assert "REPO=/srv/orchestrator" in remote
# Exit-code contract untouched: the hook is still invoked with --deploy and
# the wrapper still writes the result sentinel.
assert "--deploy" in remote and "echo $?" in remote
# ---------------------------------------------------------------------------
# ADR-001 D3 anti-drift: SELF_HOSTING_REPO is a PLATFORM CONSTANT (a tirage
# convention — the platform repo MUST be named `orchestrator`), NOT a config key.
# ---------------------------------------------------------------------------
def test_self_hosting_repo_is_platform_constant():
from src.qg.checks import SELF_HOSTING_REPO, is_self_hosting_repo
assert SELF_HOSTING_REPO == "orchestrator"
assert is_self_hosting_repo("orchestrator") is True
assert is_self_hosting_repo("enduro-trails") is False
# NOT configurable: no Settings key may claim this fact (D3 — a typo would
# either aim deploy machinery at a foreign repo or mute all self-gates).
assert "self_hosting_repo" not in Settings.model_fields
def test_settings_instance_importable():
"""The shared settings instance carries the new keys (smoke)."""
assert hasattr(settings, "agent_home_dir")
assert hasattr(settings, "agent_git_name")
assert hasattr(settings, "git_email_domain")
assert hasattr(settings, "staging_port")