feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
All checks were successful
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 55s

Фундамент тиража 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>
This commit is contained in:
2026-06-10 20:50:43 +03:00
parent 26bdd783d6
commit f1635ddb39
26 changed files with 1583 additions and 86 deletions

View File

@@ -51,6 +51,31 @@ def is_valid_model(name: str) -> bool:
return False
return bool(_MODEL_NAME_RE.match(name.strip()))
def agent_git_env() -> dict:
"""ORCH-101 (A2): subprocess env for agent runs and their git commit/push.
HOME and the git identity are read from Settings (ORCH_AGENT_HOME_DIR /
ORCH_AGENT_GIT_NAME / ORCH_GIT_EMAIL_DOMAIN) instead of host hardcodes; the
defaults equal the previous production literals (/home/slin,
claude-bot@mva154.local), so an unset env is byte-for-byte the old
behaviour (BR-5 zero-regression). Single source for BOTH launch sites (the
agent Popen and the post-run git commit/push), so the two can never drift.
HOME must stay consistent with the compose mounts of .claude/.ssh
(ORCH-040 invariant — the same ORCH_AGENT_HOME_DIR interpolates the mount
targets in docker-compose.yml).
"""
email = f"{settings.agent_git_name}@{settings.git_email_domain}"
return {
**os.environ,
"HOME": settings.agent_home_dir,
"GIT_AUTHOR_NAME": settings.agent_git_name,
"GIT_AUTHOR_EMAIL": email,
"GIT_COMMITTER_NAME": settings.agent_git_name,
"GIT_COMMITTER_EMAIL": email,
}
# ORCH-061: action stages whose success is an ACTION (restart/retag), not a src
# edit — so "no changes to commit" is EXPECTED there, not under-delivery (FR-3).
_ACTION_STAGES = frozenset({"deploy-staging", "deploy"})
@@ -589,14 +614,9 @@ class AgentLauncher:
["bash", "-c", cmd],
stdout=log_fh,
stderr=subprocess.STDOUT,
env={
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "claude-bot",
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
"GIT_COMMITTER_NAME": "claude-bot",
"GIT_COMMITTER_EMAIL": "claude-bot@mva154.local",
},
# ORCH-101 (A2): HOME + git identity from Settings (defaults = the
# previous hardcoded values), shared with the post-run git env.
env=agent_git_env(),
)
# Update DB with output path
@@ -820,14 +840,8 @@ class AgentLauncher:
# (ensure_worktree did the checkout), so no checkout is needed here.
repo_path = get_worktree_path(repo, branch)
try:
git_env = {
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "claude-bot",
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
"GIT_COMMITTER_NAME": "claude-bot",
"GIT_COMMITTER_EMAIL": "claude-bot@mva154.local",
}
# ORCH-101 (A2): same Settings-driven env as the agent Popen above.
git_env = agent_git_env()
result = subprocess.run(
["git", "-C", repo_path, "status", "--porcelain"],
capture_output=True, text=True, timeout=10, env=git_env

View File

@@ -55,6 +55,40 @@ class Settings(BaseSettings):
# DB
db_path: str = "/app/data/orchestrator.db"
# ORCH-101 (replication foundation, ADR-001 D2/D4): host-parametrization keys.
# config.py is the ONLY legitimate home of host-specific literals in src/**
# (BR-1); every default below equals the current production value, so an
# absent/unchanged .env keeps behaviour byte-for-byte (BR-5, kill-switch
# nature — no extra flag is introduced, NFR-2).
# agent_home_dir -> HOME of every actor subprocess env (agent CLI Popen +
# git commit/push in agents/launcher, self-deploy
# finalizer, post-deploy monitor). The SAME env name is
# interpolated by docker-compose.yml as the target of
# the .claude/.claude.json/.ssh mounts and wired into
# Dockerfile ARG APP_HOME — one env name per fact (D1);
# the ORCH-040 uid/HOME/mounts group moves together.
# Env ORCH_AGENT_HOME_DIR.
# agent_git_name -> GIT_AUTHOR/COMMITTER_NAME of agent commits (the
# customer-visible identity). Env ORCH_AGENT_GIT_NAME.
# git_email_domain -> domain of ALL actor git emails, built as
# f"{name}@{git_email_domain}"; name = agent_git_name
# for agents, and the PLATFORM literals
# deploy-finalizer / post-deploy-monitor for system
# actors (their names are not host-specific, D2).
# Env ORCH_GIT_EMAIL_DOMAIN.
# staging_port -> port of the staging instance (8501). Replaces the
# module constant image_freshness._STAGING_PORT; the
# SAME env name is interpolated into the staging
# compose `command:` so both readers see one fact (D1).
# Fail-closed guard in check_staging_image_fresh:
# staging_port == deploy_prod_target_port -> the
# freshness path REFUSES to run (ORCH-058 AC-9 made
# executable, D4). Env ORCH_STAGING_PORT.
agent_home_dir: str = "/home/slin"
agent_git_name: str = "claude-bot"
git_email_domain: str = "mva154.local"
staging_port: int = 8501
# ORCH-1 (F-2b): persistent job queue / background worker.
# max_concurrency -> max agent jobs running in parallel (env ORCH_MAX_CONCURRENCY)
# queue_poll_interval -> worker loop poll seconds (env ORCH_QUEUE_POLL_INTERVAL)

View File

@@ -57,8 +57,15 @@ _REBUILD_TIMEOUT = 1200
# the hook's staging-safe defaults but are passed EXPLICITLY so a future change to the
# hook defaults can never silently retarget the self-rebuild at prod (8500) — the whole
# path builds/recreates STAGING ONLY (AC-9, review P2). Never the prod 8500 target.
# ORCH-101 (ADR-001 D4): the staging PORT moved to `settings.staging_port`
# (env ORCH_STAGING_PORT, default 8501 — the same env name is interpolated into the
# staging compose `command:`, one fact one name). The service/profile NAMES stay
# platform constants: they are names of our own compose file (a tirage convention,
# same logic as SELF_HOSTING_REPO / D3) — making them configurable could only
# desynchronise the rebuild from compose within one repo. The ORCH-058 anti-prod
# invariant is now an EXECUTABLE fail-closed guard in check_staging_image_fresh
# (staging_port == prod port -> refuse loudly, never a silent 8501 fallback).
_STAGING_SERVICE = "orchestrator-staging"
_STAGING_PORT = 8501
_STAGING_COMPOSE_PROFILE = "staging"
@@ -264,12 +271,16 @@ def rebuild_staging_image(repo: str, branch: str, sha: str) -> tuple[bool, str]:
# rebuild + recreate + staging_check can never drift onto the prod 8500 service
# even if the hook's defaults change (AC-9, review P2). STAGING_CONTAINER is the
# container staging_check is docker-exec'd inside (step 3b).
# ORCH-101 (D7): REPO is passed EXPLICITLY (same source the `cd` below uses)
# — the hook's own default only serves manual operator runs; on the wired
# path the config is the single source of truth for the host repo path.
env_assignments = (
f"REPO={shlex.quote(settings.deploy_host_repo_path)} "
f"GIT_SHA={shlex.quote(sha)} "
f"BUILD_CONTEXT={shlex.quote(host_ctx)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(_STAGING_SERVICE)} "
f"TARGET_PORT={shlex.quote(str(_STAGING_PORT))} "
f"TARGET_PORT={shlex.quote(str(int(settings.staging_port)))} "
f"COMPOSE_PROFILE={shlex.quote(_STAGING_COMPOSE_PROFILE)} "
f"STAGING_CONTAINER={shlex.quote(_STAGING_SERVICE)}"
)
@@ -319,6 +330,26 @@ def check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tupl
if not image_freshness_applies(repo):
return True, f"image-freshness N/A for {repo}"
# ORCH-101 (D4): fail-closed misconfiguration guard, BEFORE any
# ssh/build/recreate. The freshness path must NEVER be aimable at the
# prod target (ORCH-058 AC-9 / INV-FRESH) — with the port now a config
# key, the invariant is enforced here instead of implied by a constant.
# Deliberately NO silent fallback to 8501: a silent target substitution
# is exactly the failure class ORCH-058 was built against; the operator
# must fix the env (refuse loudly).
if int(settings.staging_port) == int(settings.deploy_prod_target_port):
reason = (
"misconfiguration: ORCH_STAGING_PORT == prod target port "
"(ORCH-058 AC-9) — staging rebuild refused"
)
logger.error("check_staging_image_fresh: %s", reason)
try: # best-effort operator alert; never blocks the verdict
from .notifications import send_telegram
send_telegram(f"🚨 image-freshness [{repo}]: {reason}")
except Exception: # noqa: BLE001 - alert is best-effort
pass
return False, reason
sha = validated_revision(repo, branch)
if not sha:
# Fail-closed: without the validated commit we cannot prove freshness.

View File

@@ -1060,8 +1060,15 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent
if agent:
msg += f" (launching {agent})"
# Add relevant links
gitea_base = "http://git.mva154.duckdns.org"
# Add relevant links.
# ORCH-101 (A1): the link base and owner come from Settings — base is
# gitea_public_url with a gitea_url fallback (the exact semantics of the
# existing consumers notifications._build_brd_link / usage.py), owner is
# gitea_owner. No hardcoded host/owner in the executable path.
gitea_base = (
getattr(settings, "gitea_public_url", "") or getattr(settings, "gitea_url", "")
).rstrip("/")
gitea_owner = getattr(settings, "gitea_owner", "")
try:
from .db import get_db
conn = get_db()
@@ -1071,10 +1078,9 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent
conn.close()
if row:
branch, repo = row
msg += chr(10) + "📂 Branch: [" + branch + "](" + gitea_base + "/admin/" + repo + "/src/branch/" + branch + ")"
msg += chr(10) + "📂 Branch: [" + branch + "](" + gitea_base + "/" + gitea_owner + "/" + repo + "/src/branch/" + branch + ")"
if new_stage in ("review", "testing", "deploy"):
import httpx as _httpx
from .config import settings
_headers = {"Authorization": f"token {settings.gitea_token}"}
_resp = _httpx.get(
f"{settings.gitea_url}/api/v1/repos/{settings.gitea_owner}/{repo}/pulls",
@@ -1085,7 +1091,7 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent
_prs = _resp.json()
if _prs:
pr_num = _prs[0]["number"]
msg += chr(10) + "🔗 PR: [#" + str(pr_num) + "](" + gitea_base + "/admin/" + repo + "/pulls/" + str(pr_num) + ")"
msg += chr(10) + "🔗 PR: [#" + str(pr_num) + "](" + gitea_base + "/" + gitea_owner + "/" + repo + "/pulls/" + str(pr_num) + ")"
except Exception:
pass

View File

@@ -570,13 +570,16 @@ def write_post_deploy_log(
logger.error("write_post_deploy_log: write error at %s: %s", path, e)
return False
# ORCH-101 (A4): HOME + email domain from Settings; the actor NAME stays the
# platform literal `post-deploy-monitor` (D2). Defaults = previous values.
_email = f"post-deploy-monitor@{settings.git_email_domain}"
git_env = {
**os.environ,
"HOME": "/home/slin",
"HOME": settings.agent_home_dir,
"GIT_AUTHOR_NAME": "post-deploy-monitor",
"GIT_AUTHOR_EMAIL": "post-deploy-monitor@mva154.local",
"GIT_AUTHOR_EMAIL": _email,
"GIT_COMMITTER_NAME": "post-deploy-monitor",
"GIT_COMMITTER_EMAIL": "post-deploy-monitor@mva154.local",
"GIT_COMMITTER_EMAIL": _email,
}
try:
subprocess.run(["git", "-C", wt, "add", rel],

View File

@@ -513,6 +513,14 @@ def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None)
# and their deployer prompts know nothing about it -- the gate must be a no-op
# for them. The repo value is the plain gitea repo name (ProjectConfig.repo),
# matching what _run_qg/advance_stage pass in. See ORCH-35 / PR #31.
#
# ORCH-101 (ADR-001 D3): deliberately a PLATFORM CONSTANT, NOT a config key.
# Every "*_repos empty CSV -> self-hosting only" leaf (merge/security/coverage/
# image-freshness/fs-normalize/auto-labels/...) keys off this value; a
# hypothetical ORCH_SELF_HOSTING_REPO typo would either aim the deploy
# machinery at a foreign repo or silently disable ALL self-gates. The tirage
# convention is normative instead: the platform repo MUST be named
# `orchestrator` (docs/operations/REPLICATION.md). Anti-drift: pinned by test.
# ---------------------------------------------------------------------------
SELF_HOSTING_REPO = "orchestrator"

View File

@@ -245,7 +245,13 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
result_sentinel = os.path.join(host_dir, RESULT)
hook_log = os.path.join(host_dir, "hook.log")
# ORCH-101 (D7): REPO is passed EXPLICITLY (same source the `cd` below uses)
# so the hook's env-override actually works on a parametrised host; the
# hook's own default only serves manual operator runs. The exit-code
# contract of the hook (0/1/2, ORCH-036) is untouched — this is one
# additional env assignment in the prefix.
env_assignments = (
f"REPO={shlex.quote(settings.deploy_host_repo_path)} "
f"SOURCE_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
@@ -327,13 +333,17 @@ def write_deploy_log(repo: str, work_item_id: str, branch: str, exit_code, statu
return False
# Best-effort commit + push (the gate also falls back to origin/main).
# ORCH-101 (A3): HOME + email domain from Settings; the actor NAME stays the
# platform literal `deploy-finalizer` (D2 — distinguishable system-actor
# commits, not host-specific). Defaults = the previous hardcoded values.
_email = f"deploy-finalizer@{settings.git_email_domain}"
git_env = {
**os.environ,
"HOME": "/home/slin",
"HOME": settings.agent_home_dir,
"GIT_AUTHOR_NAME": "deploy-finalizer",
"GIT_AUTHOR_EMAIL": "deploy-finalizer@mva154.local",
"GIT_AUTHOR_EMAIL": _email,
"GIT_COMMITTER_NAME": "deploy-finalizer",
"GIT_COMMITTER_EMAIL": "deploy-finalizer@mva154.local",
"GIT_COMMITTER_EMAIL": _email,
}
try:
subprocess.run(["git", "-C", wt, "add", rel],