Фундамент тиража 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>
265 lines
12 KiB
Python
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")
|