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

@@ -5,14 +5,68 @@ ORCH_PLANE_API_URL=http://plane-app-api-1:8000
ORCH_PLANE_WEB_URL=
ORCH_PLANE_API_TOKEN=
ORCH_PLANE_WORKSPACE_SLUG=
# Webhook secrets are GENERATED PER HOST: python3 scripts/gen_secrets.py
# (ORCH-101 / AC-5: production secrets are NEVER copied to a new host).
ORCH_PLANE_WEBHOOK_SECRET=
ORCH_GITEA_URL=http://localhost:3000
# External (browser) URL of Gitea for clickable Branch/PR links in comments;
# empty -> falls back to ORCH_GITEA_URL.
ORCH_GITEA_PUBLIC_URL=
ORCH_GITEA_TOKEN=
ORCH_GITEA_WEBHOOK_SECRET=
ORCH_GITEA_OWNER=admin
# Per-agent Plane bot tokens (optional): when set, comments are posted under
# the matching bot so Plane shows the real author; empty -> ORCH_PLANE_API_TOKEN.
ORCH_PLANE_BOT_ANALYST=
ORCH_PLANE_BOT_ARCHITECT=
ORCH_PLANE_BOT_DEVELOPER=
ORCH_PLANE_BOT_REVIEWER=
ORCH_PLANE_BOT_TESTER=
ORCH_PLANE_BOT_DEPLOYER=
ORCH_PLANE_BOT_STREAM=
# Telegram live-tracker / alerts (empty -> notifications are logged, not sent).
ORCH_TELEGRAM_BOT_TOKEN=
ORCH_TELEGRAM_CHAT_ID=
# ORCH-6: project registry — JSON array of {plane_project_id, repo,
# work_item_prefix, name}. Empty -> built-in default registry (src/projects.py)
# whose Plane UUIDs belong to the ORIGINAL host. On a NEW host this key is
# MANDATORY (ORCH-101 replication checklist, docs/operations/REPLICATION.md).
ORCH_PROJECTS_JSON=
ORCH_CLAUDE_BIN=/usr/bin/claude
ORCH_REPOS_DIR=/home/slin/repos
ORCH_DB_PATH=/app/data/orchestrator.db
# ── ORCH-101: host parametrization (replication foundation, ADR-001 D1D7) ───
# Every host-specific value lives HERE (defaults = the current production host;
# an empty/absent value keeps behaviour 1:1). The same names are read by BOTH
# pydantic Settings (env_file) and docker-compose ${VAR:-default} interpolation
# (compose reads .env/shell, NOT a service's env_file). Full variable map and
# the new-host procedure: docs/operations/REPLICATION.md.
# AGENT_HOME_DIR -> HOME of all actor subprocesses (agents/finalizer/monitor)
# AND the target of the .claude/.claude.json/.ssh mounts AND
# Dockerfile ARG APP_HOME (ORCH-040 group moves together).
# AGENT_GIT_NAME / GIT_EMAIL_DOMAIN -> git identity of agent commits; system
# actors keep platform names deploy-finalizer/post-deploy-
# monitor under the same domain.
# STAGING_PORT -> staging instance port; image_freshness fail-closes when it
# equals the prod port (ORCH-058 AC-9 guard).
# HOST_* -> host-side sources of the bind mounts (repos, ~/.claude,
# ~/.claude.json, ssh keydir, claude-code dist, node binary).
# RUN_UID/RUN_GID/DOCKER_GID -> container uid:gid + host docker group for
# docker.sock access (group_add «МИНА 1», ORCH-040).
ORCH_AGENT_HOME_DIR=/home/slin
ORCH_AGENT_GIT_NAME=claude-bot
ORCH_GIT_EMAIL_DOMAIN=mva154.local
ORCH_STAGING_PORT=8501
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_HOST_CLAUDE_CODE_DIR=/usr/lib/node_modules/@anthropic-ai/claude-code
ORCH_HOST_NODE_BIN=/usr/bin/node
ORCH_RUN_UID=1000
ORCH_RUN_GID=1000
ORCH_DOCKER_GID=999
# ── Agent model / effort / fallback (ORCH-41, validation ORCH-74) ─────────────
# Per-agent LLM model + reasoning effort, resolved by launcher.resolve_agent_*.
# Resolution priority (per agent): project-override (projects_json agent_models/

View File

@@ -36,6 +36,14 @@ ORCH_CLAUDE_BIN=/usr/bin/claude
ORCH_REPOS_DIR=/repos
ORCH_HOST_REPOS_DIR=/home/slin/repos
# ── ORCH-101: host parametrization ───────────────────────────────────────────
# The host keys (ORCH_AGENT_HOME_DIR / ORCH_AGENT_GIT_NAME / ORCH_GIT_EMAIL_DOMAIN /
# ORCH_STAGING_PORT / ORCH_HOST_* / ORCH_RUN_* / ORCH_DOCKER_GID) default to the
# current production host — set them ONLY on a new/different host (see
# docs/operations/REPLICATION.md). NB: docker-compose ${VAR:-default}
# interpolation reads the project .env / shell, NOT this env_file — values that
# must reach compose (mounts/uid/ports) belong in .env, not here.
# ── Database (ISOLATION KEY for staging) ─────────────────────────────────────
# The staging volume mounts ./data/staging:/app/data, so the DB physically lives
# at ./data/staging/orchestrator.db on the host — fully isolated from prod.

View File

@@ -3,6 +3,13 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **Фундамент тиража 10-common: расхардкод хоста + секреты нового хоста + smoke-процедура** (ORCH-101, `feat`): платформа разворачивается на новой инфре **без правки кода** — только env/конфиг (эпик ORCH-10, критический путь обоих типов A Lite / B Bundled; stateless по решению 10.06). Конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД) — байт-в-байт не тронут; **каждый дефолт = боевому значению** → пустой/неизменённый `.env` ⇒ поведение 1:1 (kill-switch-природа, отдельный флаг не вводится — NFR-2; enduro не затронут). ADR: `docs/work-items/ORCH-101/06-adr/ADR-001-host-parametrization-secrets-smoke.md`, сквозной `adr-0036-replication-foundation-host-parametrization.md`.
- **Расхардкод (D2, FR-1/FR-2):** четыре код-блокера закрыты тремя новыми `Settings`-ключами + реюзом существующих: `agent_home_dir` (`ORCH_AGENT_HOME_DIR`, HOME всех акторских env), `agent_git_name`/`git_email_domain` (`ORCH_AGENT_GIT_NAME`/`ORCH_GIT_EMAIL_DOMAIN`, git-идентичность: агенты — `claude-bot@<домен>` через единый `launcher.agent_git_env()` ×2 места; системные акторы держат платформенные имена `deploy-finalizer`/`post-deploy-monitor` под тем же доменом). `plane_sync.notify_stage_change` строит ссылки Branch/PR из `gitea_public_url`(fallback `gitea_url`)+`gitea_owner` вместо литералов `git.mva154.duckdns.org`/`admin`. `SELF_HOSTING_REPO`**нормативная платформенная константа** тиража (D3: конфиг-ключ превращал бы опечатку в активацию деплой-машинерии на чужом репо или тихое выключение всех self-гейтов), пин-тест.
- **Staging-порт + исполняемый инвариант ORCH-058 (D4):** `_STAGING_PORT` → ключ `staging_port` (`ORCH_STAGING_PORT`, дефолт 8501; то же имя интерполируется в compose `command:` staging — один факт, одно имя); в начале freshness-пути новый **fail-closed guard**: `staging_port == deploy_prod_target_port` → отказ «staging rebuild refused» + Telegram-алерт, **без тихого fallback** — анти-prod-гарантия из подразумеваемой константы стала исполняемой. Имена сервисов/профиля остаются константами.
- **Инфра-файлы (D5/D6/D7, FR-3):** `docker-compose.yml` — полная интерполяция `${VAR:-default}` (реестр B: `ORCH_HOST_REPOS_DIR`/`_CLAUDE_DIR`/`_CLAUDE_JSON`/`_SSH_DIR`/`_CLAUDE_CODE_DIR`/`_NODE_BIN`, `ORCH_DOCKER_GID` (group_add «МИНА 1» сохранён ×3), `ORCH_RUN_UID/GID`, реюз `ORCH_DEPLOY_SSH_USER`/`_HOST_REPO_PATH`/`_PROD_TARGET_PORT`); оба app-сервиса получили явный `command:` (прод — `${ORCH_DEPLOY_PROD_TARGET_PORT:-8500}`); группа ORCH-040 (uid/gid/HOME/маунты/useradd) двигается согласованно через `build.args APP_UID/APP_GID/APP_HOME`. `Dockerfile``ARG APP_UID/APP_GID/APP_USER/APP_HOME` (useradd параметризован; CMD сознательно остаётся exec-form 8500 — PID-1/сигнальная семантика `init: true` не тронута). `orchestrator-deploy-hook.sh``REPO="${REPO:-…}"`; **оба инвокера** (`self_deploy.build_deploy_command`, `image_freshness.rebuild_staging_image`) передают `REPO=` явно из конфига (exit-контракт хука 0/1/2 не тронут).
- **Секреты (D8, FR-4):** новый stdlib-only `scripts/gen_secrets.py` — криптослучайные `ORCH_PLANE_WEBHOOK_SECRET`/`ORCH_GITEA_WEBHOOK_SECRET` (`secrets.token_hex(32)`, повторный запуск — другие значения); режим по умолчанию — печать; `--write` **никогда не перезаписывает существующий `.env` молча** (отказ exit=2, перезапись только `--force`); чек-лист внешних токенов (Plane/Gitea/BotFather/watchdog) + нормативное «боевые секреты не копируются». `.env.example` дополнен до полноты ключей старта (+`ORCH_GITEA_OWNER`/`_PUBLIC_URL`, `ORCH_PLANE_BOT_*`, `ORCH_TELEGRAM_*`, `ORCH_PROJECTS_JSON`, блок хост-параметризации).
- **Smoke + доки (D9, FR-5/FR-7):** новый runbook `docs/operations/REPLICATION.md` — карта переменных, процедура секретов, пошаговая smoke-процедура с явными PASS/FAIL (compose config → `/health``/queue`+`/metrics` → onboarding sandbox → тестовая задача → артефакты `0104` стадии analysis; расширенно — до `done`), границы 10-common vs Lite vs Bundled, платформенные конвенции; карта env `INFRA.md` дополнена; `.env.staging.example` согласован.
- **Анти-регресс (D10, FR-6):** новый структурный сканер `tests/test_no_host_hardcodes.py` — запрещённые литералы (`82.22.50.71`/`/home/slin`/`mva154`/`duckdns`) в исполняемом коде `src/**`+`watchdog/**` ломают CI; комментарии/докстринги исключены через `tokenize`; `config.py` — структурное исключение (канон дефолтов); allowlist пуст; негативная самопроверка (подсаженный литерал ловится). Тесты: `test_host_config_keys.py` (ключи/guard/REPO/D3-пин), `test_infra_parametrization.py` (интерполяция compose = боевым дефолтам, ORCH-040-группа, Dockerfile ARG, полнота `.env.example`), `test_secrets_gen.py`, `test_replication_smoke.py`.
- **Turnkey-онбординг проектов: kit + операторский CLI + runbook** (ORCH-009, `feat`): способность развернуть **новый** проект одним проходом (домен D5.2 эпика саморазвития) — **вне рантайма и вне конвейера**: `src/**` байт-в-байт (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты, снапшот-контроль `tests/test_onboarding_invariants.py`), kill-switch не нужен (активация — только явный запуск CLI человеком). Эталон — сам репозиторий orchestrator (каноны ORCH-52b/c/d/e). ADR: `docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` (D1…D11), сквозной `docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`.
- **Kit `onboarding/repo-skeleton/` (D1D3, FR-1/FR-2/FR-3):** параметризуемый каркас нового репо — 6 промптов агентов канона 52d/92 (5 XML-секций в нормативном порядке, «❌ → ✅», `<escalation>` у developer/reviewer/tester, frontmatter-схема 52c с плейсхолдерными датами/моделями, machine-verdict ключи байт-в-байт; язык — канон орка: 5 ru + deployer en c рамкой shared-host-гардрейлов), reviewer-gate «дока не обновлена → `REQUEST_CHANGES`», паспорт `CLAUDE.md`, `AGENTS.md` (карта доков + правила ведения), `CONTRIBUTING.md`, `README`/`CHANGELOG`, скелет `docs/` (`ARCHITECTURE`/`PIPELINE`/`PRODUCT_VISION`/`operations/INFRA.md` с обязательными секциями топологии/env/границ/рисков общего хоста, реестр сквозных ADR), `.env.example`. Плейсхолдеры `{{NAME}}` + stdlib-рендер (без новых pip-зависимостей); словарь — `onboarding/placeholders.json` (биекция словарь↔kit держится тестом). **Канон не форкается (BR-2):** `docs/_templates/` (16) + `docs/_standards/` (3) в kit не хранятся — копируются live из чекаута в момент материализации.
- **CLI `scripts/onboard_project.py` (D4D7, D11, FR-4/FR-5):** режимы `plan` (дефолт, GET-only, ноль мутаций сети/диска) / `apply` (идемпотентный ensure: существующее → `skipped(exists)`, delete-операций нет вовсе) / `verify` (round-trip реестра, резолв всех 22 статусов включая fail-closed `Confirm Deploy`/`STOP`, лейблы, webhook активен, полнота kit в репо, скан неразрешённых плейсхолдеров). Закрытый список read-only импортов из `src` (нулевой дрейф по построению): `projects._parse_projects_json`, `plane_sync._PLANE_NAME_TO_KEY`, `config.settings`. Канонические группы статусов фиксированы ADR D5 (код-критично: `STOP``cancelled` ORCH-090; терминальные группы только у Done/Cancelled/STOP — иначе terminal-detection ORCH-068 ложно терминалит). Gitea: репо `auto_init=false` + per-repo webhook (`push`/`pull_request`/`status`, **переиспользует** глобальный `ORCH_GITEA_WEBHOOK_SECRET` — новый сломал бы HMAC существующих, TR-6); initial push — **только** в свежесозданный пустой репо (INV-4 не затрагивается). Реестр: merged-вывод `ORCH_PROJECTS_JSON` через фактический парсер; скрипт `.env` НЕ правит, прод НЕ рестартит, ничего не удаляет (NFR-2); секреты маскируются (NFR-3); Plane CE API-пробел → `manual-step` со ссылкой на runbook (fail-safe, TR-8). Отчёт `created/skipped(exists)/manual-step` + `--json`; exit-коды 0/2/1.

View File

@@ -294,6 +294,33 @@ API → `manual-step` (fail-safe); **runbook** `docs/operations/ONBOARDING.md` (
`docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md`, сквозной
`docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`.
## Тираж платформы: фундамент 10-common (ORCH-101)
Платформа разворачивается на новой инфре **без правки кода** — только env/конфиг (эпик ORCH-10,
оба типа A Lite / B Bundled, stateless). Принцип: **дефолт каждого параметра = боевому значению**
(пустой `.env` ⇒ поведение байт-в-байт; kill-switch-природа, отдельный флаг не вводится).
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты.
- **Расхардкод:** ключи `agent_home_dir`/`agent_git_name`/`git_email_domain` (HOME + git-идентичность
акторов: агенты — единый `launcher.agent_git_env()`; системные имена `deploy-finalizer`/
`post-deploy-monitor` — платформенные литералы под тем же доменом), `staging_port`; ссылки
Plane-комментариев — из `gitea_public_url`/`gitea_owner`. `docker-compose.yml` — интерполяция
`${VAR:-default}` (карта `ORCH_HOST_*`/`ORCH_DOCKER_GID`/`ORCH_RUN_UID/GID`; группа ORCH-040
uid/gid/HOME/маунты — одни env насквозь, «МИНА 1» сохранена); `Dockerfile` — `ARG APP_*`
(CMD exec-form 8500 не тронут); deploy-hook — `"${REPO:-…}"` + явная передача `REPO=` обоими
инвокерами. **Платформенные константы (НЕ конфиг):** `SELF_HOSTING_REPO="orchestrator"` (узел
«empty CSV → self-hosting only» всех `*_repos`-leaf'ов), имена сервисов/профиля, контейнерный
layout. **Инвариант ORCH-058 усилен:** guard fail-closed `staging_port == прод-порт` → отказ
freshness-пути ДО любого ssh/build, без тихого fallback.
- **Секреты нового хоста:** stdlib `scripts/gen_secrets.py` (`secrets.token_hex(32)`; печать по
умолчанию; `--write` отказывает при существующем `.env`, перезапись только `--force`); норматив —
боевые секреты не копируются. `.env.example` — канон 100% ключей старта.
- **Smoke тиража:** runbook `docs/operations/REPLICATION.md` (карта env, чек-лист секретов,
пошаговый smoke с PASS/FAIL до артефактов `0104`/`done`, границы 10-common vs Lite vs Bundled).
Анти-регресс — `tests/test_no_host_hardcodes.py` (запрещённые литералы в исполняемом коде
`src/**`+`watchdog/**`; `tokenize`-исключение комментариев/докстрингов; config-модули — канон
дефолтов, вне скана; allowlist пуст). Детали —
`docs/work-items/ORCH-101/06-adr/ADR-001-host-parametrization-secrets-smoke.md`, сквозной
`docs/architecture/adr/adr-0036-replication-foundation-host-parametrization.md`.
## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`

View File

@@ -35,7 +35,16 @@ RUN set -eux; \
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh
# launch (ORCH-36 Phase B). Create a real user 1000 with a home dir so getpwuid()
# resolves and ssh can start.
RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bash slin
# ORCH-101 (D5): uid/gid/home/username are build ARGs (defaults = current prod
# values); compose build.args wires APP_UID/APP_GID/APP_HOME from the SAME env
# vars as the runtime user: and the mount targets, so the ORCH-040 group
# (uid/gid/HOME/mounts/useradd) moves coherently. APP_USER is passwd cosmetics
# (the ENTRY matters for getpwuid/ssh, not the name) — Dockerfile-default only.
ARG APP_UID=1000
ARG APP_GID=1000
ARG APP_USER=slin
ARG APP_HOME=/home/slin
RUN groupadd -g ${APP_GID} app && useradd -u ${APP_UID} -g ${APP_GID} -m -d ${APP_HOME} -s /bin/bash ${APP_USER}
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
@@ -48,4 +57,9 @@ COPY src/ ./src/
# and bounced the task off `deploy-staging`. We just ensure the mountpoint exists.
RUN mkdir -p /app/data
ENV PYTHONPATH=/app
# ORCH-101 (D5): CMD deliberately stays exec-form with the documented 8500
# default — an ARG cannot reach a runtime CMD, and a shell-form CMD would break
# the verified `init: true` + exec-form PID-1/signal semantics (B-2). The prod
# port is parametrised on the compose layer (`command:` with
# ${ORCH_DEPLOY_PROD_TARGET_PORT:-8500}), which overrides this CMD.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]

View File

@@ -144,6 +144,13 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_BUG_FAST_TRACK_ENABLED` | Kill-switch багфикс-трека (ORCH-019): задача с меткой Plane `Bug` пропускает стадию `architecture`; `false` → старт и маршрут 1:1 как до ORCH-019 (нулевая регрессия) | `true` |
| `ORCH_BUG_FAST_TRACK_LABEL` | Имя метки Plane, активирующей багфикс-трек (ORCH-019) | `Bug` |
| `ORCH_BUG_FAST_TRACK_REPOS` | CSV область репо для багфикс-трека; **пусто → self-hosting only** (`orchestrator`) — enduro подключается явным CSV (ORCH-019) | `""` |
| `ORCH_AGENT_HOME_DIR` | ORCH-101: HOME акторских процессов + таргет маунтов `.claude`/`.ssh` + `ARG APP_HOME` (группа ORCH-040) | `/home/slin` |
| `ORCH_AGENT_GIT_NAME` / `ORCH_GIT_EMAIL_DOMAIN` | ORCH-101: git-идентичность коммитов агентов (`claude-bot@mva154.local` при дефолтах) | `claude-bot` / `mva154.local` |
| `ORCH_STAGING_PORT` | ORCH-101: порт staging (читают `image_freshness` и compose); guard fail-closed при совпадении с прод-портом (ORCH-058 AC-9) | `8501` |
| `ORCH_HOST_CLAUDE_DIR` / `_CLAUDE_JSON` / `_SSH_DIR` / `_CLAUDE_CODE_DIR` / `_NODE_BIN` | ORCH-101: host-источники bind-маунтов (compose-интерполяция) | боевые пути mva154 |
| `ORCH_RUN_UID` / `ORCH_RUN_GID` / `ORCH_DOCKER_GID` | ORCH-101: uid:gid контейнера и gid docker-группы (`group_add`, ORCH-040) | `1000`/`1000`/`999` |
Тираж платформы на новый хост (полная карта, секреты, smoke) — `docs/operations/REPLICATION.md` (ORCH-101).
## Очередь задач (ORCH-1 / F-2b)

View File

@@ -1,42 +1,65 @@
# ORCH-101 (replication foundation): every host-specific value is interpolated
# as ${VAR:-default}; the defaults equal the current production values, so an
# empty environment resolves to a byte-for-byte equivalent of the previous file
# (zero regression, BR-5). Compose reads ${VAR} from the project `.env` /shell —
# NOT from a service's env_file (so .env.staging does NOT interpolate); the
# Settings-shared names (ORCH_AGENT_HOME_DIR, ORCH_STAGING_PORT, ...) are read
# by pydantic from env_file AND by compose from .env — one name per fact (D1).
# Container-side paths (/app/data, /repos, /opt/claude-code, docker.sock) are a
# container-layout convention, NOT host values — deliberately not parametrised.
# See docs/operations/REPLICATION.md for the full variable map.
services:
orchestrator:
build: .
build:
context: .
# ORCH-101 (D5): uid/gid/home move as ONE coherent group with the runtime
# user: and the mount targets below (ORCH-040 invariant).
args:
APP_UID: ${ORCH_RUN_UID:-1000}
APP_GID: ${ORCH_RUN_GID:-1000}
APP_HOME: ${ORCH_AGENT_HOME_DIR:-/home/slin}
container_name: orchestrator
restart: unless-stopped
# ORCH-040: бежим под uid:gid хоста (slin=1000:1000), а не root, чтобы
# артефакты конвейера (worktree + docs) создавались как slin:slin и git на
# хосте работал без ручного chown. Доступ к docker.sock сохранён через
# group_add: ["999"] (МИНА 1 — НЕ удалять). См. ADR-001 ORCH-040.
user: "1000:1000"
user: "${ORCH_RUN_UID:-1000}:${ORCH_RUN_GID:-1000}"
# init: true injects docker-init (tini) as PID 1 so reparented grandchild
# processes from the claude/node subprocess tree are reaped (no zombies, B-2).
init: true
network_mode: host
# ORCH-101 (D5): the prod port is configurable on the compose layer (the
# Dockerfile CMD keeps its exec-form 8500 default — ADR-001 D5); the default
# resolves byte-for-byte to the previous image CMD. Reuses the existing
# ORCH_DEPLOY_PROD_TARGET_PORT (no second truth about the prod port).
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${ORCH_DEPLOY_PROD_TARGET_PORT:-8500}"]
volumes:
- ./data:/app/data
- /home/slin/repos:/repos
- ${ORCH_HOST_REPOS_DIR:-/home/slin/repos}:/repos
- /var/run/docker.sock:/var/run/docker.sock
- /usr/lib/node_modules/@anthropic-ai/claude-code:/opt/claude-code:ro
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
- ${ORCH_HOST_CLAUDE_CODE_DIR:-/usr/lib/node_modules/@anthropic-ai/claude-code}:/opt/claude-code:ro
- ${ORCH_HOST_NODE_BIN:-/usr/bin/node}:/usr/bin/node:ro
- ${ORCH_HOST_CLAUDE_DIR:-/home/slin/.claude}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.claude
- ${ORCH_HOST_CLAUDE_JSON:-/home/slin/.claude.json}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.claude.json:ro
# ORCH-040: target согласован с HOME (launcher: settings.agent_home_dir),
# не /root/.ssh — обе стороны двигаются одной переменной ORCH_AGENT_HOME_DIR.
- ${ORCH_HOST_SSH_DIR:-/home/slin/.orchestrator-ssh}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.ssh:ro
env_file: .env
environment:
- ORCH_REPOS_DIR=/repos
- ORCH_HOST_REPOS_DIR=/home/slin/repos
- ORCH_HOST_REPOS_DIR=${ORCH_HOST_REPOS_DIR:-/home/slin/repos}
# legacy enduro deployer (read via os.environ, keep as-is):
- DEPLOY_SSH_USER=slin
- DEPLOY_SSH_USER=${ORCH_DEPLOY_SSH_USER:-slin}
- DEPLOY_SSH_HOST=127.0.0.1
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
- DEPLOY_HOOK_SCRIPT=${DEPLOY_HOOK_SCRIPT:-/home/slin/bin/enduro-deploy-hook.sh}
# ORCH-036 self-deploy (read via pydantic ORCH_ prefix; host-network -> 127.0.0.1, ssh key mounted):
- ORCH_DEPLOY_SSH_USER=slin
- ORCH_DEPLOY_SSH_USER=${ORCH_DEPLOY_SSH_USER:-slin}
- ORCH_DEPLOY_SSH_HOST=127.0.0.1
- ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
- ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
- ORCH_DEPLOY_HOST_REPO_PATH=${ORCH_DEPLOY_HOST_REPO_PATH:-/home/slin/repos/orchestrator}
group_add:
- "999"
- "${ORCH_DOCKER_GID:-999}"
# ORCH-100 (FND/F1b): sidecar-watchdog — the monitoring brain in a SEPARATE
# container (observer separated from observed, ADR-001 D2). Deploying it builds
@@ -60,7 +83,7 @@ services:
mem_reservation: 32m
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /home/slin/repos:/repos:ro
- ${ORCH_HOST_REPOS_DIR:-/home/slin/repos}:/repos:ro
- ./data:/app/data:ro
# Optional env_file (required: false): a missing .env.watchdog must NOT fail
# `docker compose up` for the prod orchestrator (self-hosting safety). Absent
@@ -69,7 +92,7 @@ services:
- path: .env.watchdog
required: false
group_add:
- "999"
- "${ORCH_DOCKER_GID:-999}"
# ORCH-31: staging instance (port 8501, isolated DB).
# Starts ONLY with: docker compose --profile staging up -d orchestrator-staging
@@ -77,35 +100,42 @@ services:
orchestrator-staging:
profiles:
- staging
build: .
build:
context: .
args:
APP_UID: ${ORCH_RUN_UID:-1000}
APP_GID: ${ORCH_RUN_GID:-1000}
APP_HOME: ${ORCH_AGENT_HOME_DIR:-/home/slin}
container_name: orchestrator-staging
restart: unless-stopped
# ORCH-040: тот же uid хоста, что и у prod (см. комментарий выше / ADR-001).
user: "1000:1000"
user: "${ORCH_RUN_UID:-1000}:${ORCH_RUN_GID:-1000}"
init: true
network_mode: host
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
# ORCH-101 (D4): the same ORCH_STAGING_PORT that settings.staging_port reads —
# the image_freshness rebuild target and the listening port can never drift.
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${ORCH_STAGING_PORT:-8501}"]
volumes:
- ./data/staging:/app/data
- /home/slin/repos:/repos
- ${ORCH_HOST_REPOS_DIR:-/home/slin/repos}:/repos
- /var/run/docker.sock:/var/run/docker.sock
- /usr/lib/node_modules/@anthropic-ai/claude-code:/opt/claude-code:ro
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
- ${ORCH_HOST_CLAUDE_CODE_DIR:-/usr/lib/node_modules/@anthropic-ai/claude-code}:/opt/claude-code:ro
- ${ORCH_HOST_NODE_BIN:-/usr/bin/node}:/usr/bin/node:ro
- ${ORCH_HOST_CLAUDE_DIR:-/home/slin/.claude}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.claude
- ${ORCH_HOST_CLAUDE_JSON:-/home/slin/.claude.json}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.claude.json:ro
# ORCH-040: target согласован с HOME (settings.agent_home_dir), не /root/.ssh.
- ${ORCH_HOST_SSH_DIR:-/home/slin/.orchestrator-ssh}:${ORCH_AGENT_HOME_DIR:-/home/slin}/.ssh:ro
env_file: .env.staging
environment:
- ORCH_REPOS_DIR=/repos
- ORCH_HOST_REPOS_DIR=/home/slin/repos
- DEPLOY_SSH_USER=slin
- ORCH_HOST_REPOS_DIR=${ORCH_HOST_REPOS_DIR:-/home/slin/repos}
- DEPLOY_SSH_USER=${ORCH_DEPLOY_SSH_USER:-slin}
- DEPLOY_SSH_HOST=127.0.0.1
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
- DEPLOY_HOOK_SCRIPT=${DEPLOY_HOOK_SCRIPT:-/home/slin/bin/enduro-deploy-hook.sh}
# Staging DB is isolated via ./data/staging volume mount.
# Inside the container the path remains /app/data/orchestrator.db (same default),
# but on the host it physically lives at ./data/staging/orchestrator.db —
# but on the host it physically lives at ./data/staging/orchestrator.db —
# completely separate from prod ./data/orchestrator.db.
- ORCH_DB_PATH=/app/data/orchestrator.db
group_add:
- "999"
- "${ORCH_DOCKER_GID:-999}"

View File

@@ -158,7 +158,7 @@ orchestrator (каноны ORCH-52b/c/d/e); enduro-trails эталоном не
`docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` (D1…D11),
`docs/work-items/ORCH-009/07-infra-requirements.md`.
## Тираж платформы: фундамент 10-common (ORCH-101 — design)
## Тираж платформы: фундамент 10-common (ORCH-101)
Фундамент эпика ORCH-10 (D5.3 «Масштаб»: раздача платформы заказчикам-тестерам, типы A Lite /
B Bundled, оба stateless). Платформа разворачивается на новой инфре **без правки кода** — только

View File

@@ -171,8 +171,15 @@ watchdog'а: **watchdog сигналит, pruner убирает**.
| `ORCH_BUILD_CACHE_PRUNE_TIMEOUT_S` | таймаут ssh-команды prune, сек; дефолт `120` |
| `ORCH_BUILD_CACHE_PRUNE_NOTIFY_MIN_GB` | Telegram при освобождении ≥ N ГБ; дефолт `0` (тихо) |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
| `ORCH_AGENT_HOME_DIR` | ORCH-101: HOME всех акторских subprocess-env (агенты/finalizer/monitor) **и** таргет маунтов `.claude`/`.claude.json`/`.ssh` **и** `ARG APP_HOME` Dockerfile (группа ORCH-040 двигается согласованно); дефолт `/home/slin` |
| `ORCH_AGENT_GIT_NAME` / `ORCH_GIT_EMAIL_DOMAIN` | ORCH-101: git-идентичность коммитов агентов (`claude-bot` @ `mva154.local`); системные акторы держат платформенные имена `deploy-finalizer`/`post-deploy-monitor` под тем же доменом |
| `ORCH_STAGING_PORT` | ORCH-101: порт staging-инстанса (дефолт `8501`); читается и `image_freshness`, и compose `command:` staging; guard fail-closed при совпадении с прод-портом (ORCH-058 AC-9) |
| `ORCH_HOST_CLAUDE_DIR` / `_CLAUDE_JSON` / `_SSH_DIR` | ORCH-101: host-источники bind-маунтов `~/.claude`, `~/.claude.json`, ssh-ключей (`/home/slin/.{claude,claude.json,orchestrator-ssh}`) |
| `ORCH_HOST_CLAUDE_CODE_DIR` / `_NODE_BIN` | ORCH-101: host-пути дистрибутива claude-code и бинаря node (`/usr/lib/node_modules/@anthropic-ai/claude-code`, `/usr/bin/node`) |
| `ORCH_RUN_UID` / `ORCH_RUN_GID` | ORCH-101: uid:gid контейнера (`user:`) + `ARG APP_UID/APP_GID` (дефолт `1000:1000`, ORCH-040) |
| `ORCH_DOCKER_GID` | ORCH-101: gid docker-группы хоста для `group_add` (дефолт `999`; «МИНА 1» ORCH-040 — не удалять) |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`. Выпуск нового комплекта секретов для нового хоста — `scripts/gen_secrets.py` (боевые секреты не копируются). **Тираж платформы на новую инфру** (карта переменных, секреты, smoke-процедура, границы Lite/Bundled) — `docs/operations/REPLICATION.md` (ORCH-101). Когерентность портов при смене прод-порта: `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL``ORCH_POST_DEPLOY_BASE_URL`.
## Реестр проектов (`src/projects.py`, ORCH-6)
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.

View File

@@ -0,0 +1,155 @@
# REPLICATION — тираж платформы на новую инфраструктуру (ORCH-101)
> RUNBOOK фундамента тиража (эпик ORCH-10, слой **10-common**). Как развернуть
> оркестратор на чужом хосте **без правки кода**: переменные → секреты → smoke.
> Тираж **stateless**: данные/БД/секреты боевого хоста НЕ переносятся ни на одном
> шаге — на целевой инфре всё создаётся заново.
---
## 1. Границы: 10-common vs Lite vs Bundled
| Слой | Что это | Статус |
|------|---------|--------|
| **10-common** (этот док) | фундамент: все хост-значения параметризованы (env/конфиг), секреты выпускаются заново, smoke-процедура с PASS/FAIL | ✅ ORCH-101 |
| **Type A — Lite** | инструкция «поставь Plane+Gitea сам, подключи оркестратор» поверх 10-common | отдельная задача эпика |
| **Type B — Bundled** | комплект «всё в одном» (Plane+Gitea+оркестратор) поверх 10-common | отдельная задача эпика |
Этот док НЕ описывает установку Plane/Gitea — только параметризацию, секреты и
smoke самого оркестратора (анти-скоуп-крип Р-5).
### Платформенные конвенции тиража (нормативно, ADR-001 D3/D4)
- **Репо платформы обязан называться `orchestrator`.** Имя — узел безопасности
(`SELF_HOSTING_REPO`, на него завязаны все `*_repos`-leaf'ы «empty CSV →
self-hosting only»); оно сознательно НЕ конфигурируется.
- Имена compose-сервисов/профиля (`orchestrator`, `orchestrator-staging`,
`orchestrator-watchdog`, профиль `staging`) — константы платформы.
- Контейнерные пути (`/app/data`, `/repos`, `/opt/claude-code`) — layout
контейнера, не хост-значения; не параметризуются.
---
## 2. Карта переменных нового хоста
Принцип (ADR-001 D1): **дефолт каждой переменной = боевое значение текущего
хоста** — пустой `.env` ⇒ поведение байт-в-байт; на новом хосте задаёшь только
то, что отличается. Одно env-имя = один факт: pydantic `Settings` читает имя из
`env_file`, compose-интерполяция `${VAR:-default}` — из **`.env` проекта/shell**
(⚠️ НЕ из `env_file` сервиса: `.env.staging` на интерполяцию не влияет —
значения для маунтов/uid/портов живут в `.env`).
### 2.1. Хост-параметризация (новое в ORCH-101)
| Переменная | Дефолт | Назначение |
|-----------|--------|------------|
| `ORCH_AGENT_HOME_DIR` | `/home/slin` | HOME всех акторов (агенты, finalizer, monitor) + таргет маунтов `.claude`/`.claude.json`/`.ssh` + `ARG APP_HOME` (группа ORCH-040 двигается вместе) |
| `ORCH_AGENT_GIT_NAME` | `claude-bot` | git-имя коммитов агентов |
| `ORCH_GIT_EMAIL_DOMAIN` | `mva154.local` | домен git-email всех акторов (`claude-bot@…`, `deploy-finalizer@…`, `post-deploy-monitor@…`) |
| `ORCH_STAGING_PORT` | `8501` | порт staging; читают `image_freshness` И compose `command:`; guard: совпадение с прод-портом → отказ fail-closed (ORCH-058 AC-9) |
| `ORCH_HOST_REPOS_DIR` | `/home/slin/repos` | каталог репозиториев на хосте (источник маунта `/repos`) |
| `ORCH_HOST_CLAUDE_DIR` | `/home/slin/.claude` | источник маунта `~/.claude` |
| `ORCH_HOST_CLAUDE_JSON` | `/home/slin/.claude.json` | источник маунта `~/.claude.json` |
| `ORCH_HOST_SSH_DIR` | `/home/slin/.orchestrator-ssh` | источник маунта ssh-ключей (`→ $HOME/.ssh:ro`) |
| `ORCH_HOST_CLAUDE_CODE_DIR` | `/usr/lib/node_modules/@anthropic-ai/claude-code` | дистрибутив claude-code на хосте |
| `ORCH_HOST_NODE_BIN` | `/usr/bin/node` | бинарь node на хосте |
| `ORCH_RUN_UID` / `ORCH_RUN_GID` | `1000` / `1000` | uid:gid контейнера (`user:` + `ARG APP_UID/APP_GID`); = uid владельца `ORCH_HOST_REPOS_DIR` (ORCH-040) |
| `ORCH_DOCKER_GID` | `999` | gid группы docker хоста (`group_add`, «МИНА 1» — обязателен для docker.sock); узнать: `getent group docker` |
| `ORCH_DEPLOY_PROD_TARGET_PORT` | `8500` | (реюз) прод-порт; интерполируется в `command:` прод-сервиса |
| `ORCH_DEPLOY_SSH_USER` / `ORCH_DEPLOY_HOST_REPO_PATH` | `slin` / `/home/slin/repos/orchestrator` | (реюз) ssh-юзер хука и чекаут платформы на хосте; `REPO=` передаётся хуку явно из конфига |
### 2.2. Обязательные ключи идентичности нового хоста
| Переменная | Где взять |
|-----------|-----------|
| `ORCH_PLANE_API_URL` / `ORCH_PLANE_WEB_URL` / `ORCH_PLANE_WORKSPACE_SLUG` | инсталляция Plane целевого хоста |
| `ORCH_GITEA_URL` / `ORCH_GITEA_PUBLIC_URL` / `ORCH_GITEA_OWNER` | инсталляция Gitea целевого хоста |
| `ORCH_PROJECTS_JSON` | **обязателен на новом хосте**: встроенный fallback (`src/projects.py`) несёт Plane-UUID *исходного* хоста — чужие UUID безвредны (не сматчатся), но без своего реестра конвейер не увидит проекты. Сгенерировать: `scripts/onboard_project.py apply` печатает merged-значение |
| Когерентность портов | сменил прод-порт → синхронно `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL``ORCH_POST_DEPLOY_BASE_URL` |
Полный справочник всех остальных флагов — `.env.example` (канон) и
`docs/operations/INFRA.md` (карта env).
---
## 3. Секреты нового хоста (FR-4 / AC-5)
**Нормативно: боевые секреты текущего хоста НЕ копируются ни на одном шаге.**
Для нового хоста всегда выпускается новый комплект.
### 3.1. Генерация локальных webhook-секретов
```bash
python3 scripts/gen_secrets.py # печать .env-фрагмента в stdout
python3 scripts/gen_secrets.py --write # создать .env (существующий → отказ exit=2)
python3 scripts/gen_secrets.py --write --force # перезапись только явно
```
Скрипт stdlib-only (`secrets.token_hex(32)` — 32 байта энтропии); повторный
запуск даёт другие значения; существующий `.env` **никогда не перезаписывается
молча**.
| Секрет | Генерируется | Куда вписать |
|--------|--------------|--------------|
| `ORCH_PLANE_WEBHOOK_SECRET` | локально (gen_secrets) | `.env` + настройка webhook в Plane (см. `SETUP_WEBHOOKS.md`) |
| `ORCH_GITEA_WEBHOOK_SECRET` | локально (gen_secrets) | `.env` + webhook Gitea (создаёт `onboard_project.py apply`) |
### 3.2. Чек-лист внешних токенов
| Секрет | Где выпустить | Куда вписать | Как проверить |
|--------|---------------|--------------|---------------|
| `ORCH_PLANE_API_TOKEN` | Plane UI → Workspace Settings → API tokens | `.env` | `curl -H "X-API-Key: $TOKEN" $ORCH_PLANE_API_URL/api/v1/workspaces/<slug>/projects/` → 200 |
| `ORCH_PLANE_BOT_*` (7, опциональны) | Plane UI: bot-аккаунты per-агент; пусто → fallback на `ORCH_PLANE_API_TOKEN` | `.env` | комментарий в Plane от имени бота |
| `ORCH_GITEA_TOKEN` | Gitea UI → Settings → Applications → Generate Token (scope: repo, admin:repo_hook) | `.env` | `curl -H "Authorization: token $TOKEN" $ORCH_GITEA_URL/api/v1/user` → 200 |
| `ORCH_TELEGRAM_BOT_TOKEN` | BotFather (`/newbot`) | `.env` | `curl https://api.telegram.org/bot$TOKEN/getMe` → ok |
| `ORCH_TELEGRAM_CHAT_ID` (несекретный) | id чата оператора | `.env` | тестовое сообщение |
| `WATCHDOG_TG_BOT_TOKEN` / `WATCHDOG_TG_CHAT_ID` | отдельный бот sidecar-watchdog (ORCH-100, независимый канал) | `.env.watchdog` | алерт от sidecar |
### 3.3. Полнота
`.env.example` — канон 100% ключей старта (секретные значения — только
плейсхолдеры). Состав вывода `gen_secrets.py` сверяется с `.env.example`
тестом (`tests/test_secrets_gen.py`).
---
## 4. Smoke-верификация тиража (FR-5 / AC-3)
Процедура «инстанс → тестовый проект → тестовая задача → конвейер доехал» из
существующих кирпичей; **каждый шаг имеет явный PASS/FAIL**. Итог — однозначный
вердикт: все шаги PASS ⇒ тираж PASS; любой шаг FAIL ⇒ тираж FAIL (собери логи
контейнера `docker logs orchestrator` и снапшот `GET /queue` и разбирайся).
Воспроизводимость без нового железа: процедура прогоняется на текущей инфре —
staging-песочница (порт `ORCH_STAGING_PORT`, дефолт 8501) + sandbox-проект.
Stateless: ни один шаг не предполагает перенос данных/БД/секретов.
| # | Шаг | Команда | PASS | FAIL |
|---|-----|---------|------|------|
| 0 | Конфиг резолвится | `docker compose config` | резолв без ошибок; при пустом env значения = боевым дефолтам | ошибка интерполяции / неожиданные значения |
| 1 | Инстанс жив | `curl -fsS http://127.0.0.1:<port>/health` | HTTP 200, `"status":"ok"` | не-200 / таймаут |
| 2 | Контракты отвечают | `curl -fsS …/queue` и `…/metrics` | JSON со штатными блоками; `/metrics``schema_version: 1` | не-JSON / 5xx |
| 3 | Тестовый проект | `python3 scripts/onboard_project.py plan``apply``verify` (sandbox) | `verify` зелёный (статусы/лейблы/repo/webhook на месте) | `verify` красный / manual-step не выполнен |
| 4 | Тестовая задача | создать issue в Plane → статус «To Analyse» | задача в БД, analyst-job виден в `GET /queue` | задача не появилась (webhook/секрет/реестр) |
| 5 | **Минимальный сигнал «конвейер доехал»** | дождаться окончания `analysis` | артефакты `01``04` в ветке задачи: `git ls-tree origin/<branch> docs/work-items/<id>/` | стадия не завершилась / артефактов нет |
| 6 | Расширенный режим (опционально) | Approved → … → Confirm Deploy | задача дошла до `done` | застряла (разбор по `GET /queue` + логам) |
> Ручной запуск deploy-хука на нестандартных портах — всегда с явными
> `REPO=`/`TARGET_PORT=` (wired-путь оркестратора передаёт их сам из конфига;
> дефолты хука — staging текущего хоста).
---
## 5. Что НЕ переносится (stateless, решение 10.06)
- БД (`data/orchestrator.db`) — создаётся пустой при первом старте;
- `.env` / `.env.staging` / `.env.watchdog` — собираются заново (§2§3);
- worktree'ы/runs/логи — рабочее состояние, не данные;
- боевые секреты — никогда (§3).
---
*RUNBOOK ORCH-101. Поддерживать при добавлении хост-переменных (карта §2 +
`.env.example` + INFRA.md в том же PR). Анти-регресс возврата хардкодов —
`tests/test_no_host_hardcodes.py`; параметризация инфра-файлов —
`tests/test_infra_parametrization.py`.*

147
scripts/gen_secrets.py Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""gen_secrets.py — выпуск НОВОГО комплекта секретов для нового хоста (ORCH-101).
Provisioning-инструмент тиража (ADR-001 D8): генерирует криптослучайные
webhook-секреты оркестратора и печатает .env-фрагмент с плейсхолдерами внешних
токенов. Боевые секреты текущего хоста НЕ копируются ни на одном шаге — для
нового хоста всегда выпускается новый комплект (AC-5).
Состав (инвентаризация FR-4.1):
* генерируются локально (secrets.token_hex(32) — 32 байта энтропии, 64 hex):
ORCH_PLANE_WEBHOOK_SECRET, ORCH_GITEA_WEBHOOK_SECRET
* выпускаются внешними системами (только плейсхолдер + чек-лист в
docs/operations/REPLICATION.md: где выпустить -> куда вписать -> как
проверить): ORCH_PLANE_API_TOKEN, ORCH_PLANE_BOT_* (7, опциональны),
ORCH_GITEA_TOKEN, ORCH_TELEGRAM_BOT_TOKEN (+ несекретный
ORCH_TELEGRAM_CHAT_ID), sidecar WATCHDOG_TG_BOT_TOKEN / WATCHDOG_TG_CHAT_ID.
Поведение (NFR-3): по умолчанию — ТОЛЬКО печать в stdout (файлы не трогаются).
``--write [PATH]`` пишет фрагмент в файл (дефолт .env); СУЩЕСТВУЮЩИЙ файл ->
отказ с exit-кодом 2 и внятным сообщением; перезапись — только явный
``--force``. Повторный запуск всегда даёт другие значения секретов.
stdlib-only (secrets, argparse) — никаких зависимостей платформы; скрипт
работает на голом python3 целевого хоста до первого `docker compose up`.
Запуск:
python3 scripts/gen_secrets.py # печать в stdout
python3 scripts/gen_secrets.py --write # создать .env (если его нет)
python3 scripts/gen_secrets.py --write /tmp/e # создать произвольный файл
python3 scripts/gen_secrets.py --write --force # перезаписать существующий
"""
import argparse
import secrets
import sys
# Webhook-секреты, генерируемые локально на целевом хосте (FR-4.2).
GENERATED_KEYS = (
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
)
# Секреты внешних систем: только плейсхолдеры (значения выпускает оператор —
# чек-лист в docs/operations/REPLICATION.md). Имена согласованы с .env.example.
EXTERNAL_KEYS = (
"ORCH_PLANE_API_TOKEN",
"ORCH_PLANE_BOT_ANALYST",
"ORCH_PLANE_BOT_ARCHITECT",
"ORCH_PLANE_BOT_DEVELOPER",
"ORCH_PLANE_BOT_REVIEWER",
"ORCH_PLANE_BOT_TESTER",
"ORCH_PLANE_BOT_DEPLOYER",
"ORCH_PLANE_BOT_STREAM",
"ORCH_GITEA_TOKEN",
"ORCH_TELEGRAM_BOT_TOKEN",
"ORCH_TELEGRAM_CHAT_ID",
"WATCHDOG_TG_BOT_TOKEN",
"WATCHDOG_TG_CHAT_ID",
)
# 32 байта энтропии -> 64 hex-символа (AC-5: >= 32 байт).
TOKEN_BYTES = 32
def generate_secret() -> str:
"""Криптослучайный webhook-секрет: 32 байта энтропии, hex-кодировка."""
return secrets.token_hex(TOKEN_BYTES)
def build_fragment() -> str:
"""Собрать .env-фрагмент: свежие webhook-секреты + плейсхолдеры внешних.
Каждый вызов генерирует НОВЫЕ значения (secrets — CSPRNG); детерминизма нет
по построению (AC-5 «повторный запуск даёт другие значения»).
"""
lines = [
"# --- ORCH-101 gen_secrets.py: НОВЫЙ комплект секретов этого хоста ---",
"# Сгенерировано локально; боевые секреты другого хоста сюда НЕ копируются.",
"# Webhook-секреты вписать также в настройки webhook'ов Plane/Gitea",
"# (см. docs/operations/REPLICATION.md и docs/operations/SETUP_WEBHOOKS.md).",
]
for key in GENERATED_KEYS:
lines.append(f"{key}={generate_secret()}")
lines.append("# --- Внешние токены: выпустить по чек-листу REPLICATION.md ---")
for key in EXTERNAL_KEYS:
lines.append(f"{key}=")
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
"""CLI. Возвращает exit-код (0 ok; 2 — отказ перезаписи без --force)."""
parser = argparse.ArgumentParser(
description="Выпуск нового комплекта секретов оркестратора (ORCH-101)."
)
parser.add_argument(
"--write",
nargs="?",
const=".env",
default=None,
metavar="PATH",
help="записать фрагмент в файл (дефолт .env); существующий файл -> отказ",
)
parser.add_argument(
"--force",
action="store_true",
help="разрешить перезапись СУЩЕСТВУЮЩЕГО файла (явное подтверждение)",
)
args = parser.parse_args(argv)
fragment = build_fragment()
if args.write is None:
# Режим по умолчанию: только печать, файловая система не трогается.
sys.stdout.write(fragment)
return 0
path = args.write
if not args.force:
try:
# x-mode: атомарный «создать только если не существует» — никогда
# не перезаписывает существующий .env молча (NFR-3 / AC-5).
with open(path, "x", encoding="utf-8") as f:
f.write(fragment)
except FileExistsError:
sys.stderr.write(
f"ОТКАЗ: {path} уже существует — молча не перезаписываю. "
"Перезапись только с явным --force "
"(или укажи другой путь: --write PATH).\n"
)
return 2
except OSError as e:
sys.stderr.write(f"ОШИБКА записи {path}: {e}\n")
return 1
else:
try:
with open(path, "w", encoding="utf-8") as f:
f.write(fragment)
except OSError as e:
sys.stderr.write(f"ОШИБКА записи {path}: {e}\n")
return 1
sys.stderr.write(f"Записано: {path} (webhook-секреты сгенерированы заново)\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -35,7 +35,11 @@
set -euo pipefail
REPO=/home/slin/repos/orchestrator
# ORCH-101 (D7): env-override like every other variable of this hook. The wired
# invokers (self_deploy.build_deploy_command / image_freshness.rebuild_staging_image)
# pass REPO explicitly from ORCH_DEPLOY_HOST_REPO_PATH; the default below serves
# manual operator runs on the current host.
REPO="${REPO:-/home/slin/repos/orchestrator}"
# ---- Defaults (STAGING — safe) ---------------------------------------------
TARGET_SERVICE="${TARGET_SERVICE:-orchestrator-staging}"

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],

View File

@@ -79,17 +79,17 @@ exit 0
# by the hook's sleep args; here we only assert the rollback CONTROL FLOW).
_write_exec(str(binx / "sleep"), "#!/bin/bash\nexit 0\n")
# Copy the hook, repointing REPO to the sandbox (avoids the hardcoded prod path).
# Copy the hook verbatim and repoint REPO via the env-override — the SAME
# contract the wired invokers use since ORCH-101 (D7: REPO="${REPO:-…}");
# no text rewrite needed, so the simulation also proves the override works.
hook_text = open(HOOK, encoding="utf-8").read()
hook_text = hook_text.replace(
"REPO=/home/slin/repos/orchestrator", f"REPO={repo}"
)
hook_copy = tmp_path / "hook.sh"
_write_exec(str(hook_copy), hook_text)
env = {
**os.environ,
"PATH": f"{binx}:{os.environ['PATH']}",
"REPO": str(repo),
"LOG": str(state / "hook.log"),
"PREV_IMAGE_FILE": str(state / "prev-image"),
"COMPOSE_PROFILE": "staging",

View File

@@ -0,0 +1,264 @@
"""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")

View File

@@ -0,0 +1,261 @@
"""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}"

View File

@@ -0,0 +1,173 @@
"""ORCH-101 (FR-6 / AC-1, AC-7): structural anti-regression scanner — no host
hardcodes in executable platform code.
Scans ``src/**/*.py`` + ``watchdog/**/*.py`` for forbidden host-specific
literals (current host IP / home dir / hostname). Judges CODE only: comments
and docstrings are excluded via :mod:`tokenize` (NFR-5 — token types, not
line regexes, so the verdict is deterministic).
Structural exclusion (ADR-001 ORCH-101 D10): ``src/config.py`` and
``watchdog/config.py`` are skipped ENTIRELY — they are the canonical (and only
legitimate, BR-1) home of host-value defaults, and those defaults are REQUIRED
to equal the current production values (BR-5: /home/slin, mva154.local).
Scanning them would mean an eternally non-empty allowlist; the exclusion is a
rule of this test, not an allowlist entry.
The per-(file, literal) ALLOWLIST exists as a mechanism and MUST be empty at
ORCH-101 acceptance (AC-1): every code blocker A1A4 is closed by Settings
keys. A future entry requires a justification string.
Negative self-check (TC-02): the scanner is exercised against synthetic
sources with a planted literal and must catch it — the test can never go
evergreen by accident.
"""
import io
import tokenize
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
# Single point of truth for the forbidden literals (AC-7: centralised list).
FORBIDDEN: tuple[str, ...] = (
"82.22.50.71",
"/home/slin",
"mva154",
"duckdns",
)
# Scan zone: executable platform code only. tests/**, docs/**, scripts/**
# (the deploy hook carries a legitimate shell-default, ADR D7) and .env* are
# OUT of scope by construction.
SCAN_DIRS: tuple[str, ...] = ("src", "watchdog")
# Structural rule (ADR-001 D10), NOT an allowlist entry — see module docstring.
EXCLUDED_FILES: frozenset[str] = frozenset({"src/config.py", "watchdog/config.py"})
# {(relative_path, literal): "justification"} — MUST stay empty (AC-1/AC-7).
ALLOWLIST: dict[tuple[str, str], str] = {}
# Token types that are never judged: comments and non-logical newlines.
_TRIVIA = frozenset({tokenize.COMMENT, tokenize.NL, tokenize.ENCODING})
# A STRING token opening a logical line (after NEWLINE/INDENT/DEDENT or at
# file start) is a docstring / bare string statement -> not executable data.
_DOCSTRING_PREV = frozenset({None, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
def find_violations(source: str, forbidden: tuple[str, ...] = FORBIDDEN) -> list[tuple[int, str, str]]:
"""Return ``[(lineno, literal, token_text)]`` for forbidden literals in CODE.
Comments are skipped (COMMENT tokens); docstrings are skipped (STRING tokens
in statement position). Everything else — including string *values* assigned
or passed in code — is judged: a hardcoded host value in an executable
string is exactly the regression this test exists to block.
"""
violations: list[tuple[int, str, str]] = []
prev_significant: int | None = None
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
if tok.type in _TRIVIA:
continue # comments / blank-line NLs never update statement position
if tok.type == tokenize.STRING and prev_significant in _DOCSTRING_PREV:
prev_significant = tok.type # docstring / bare string statement
continue
for literal in forbidden:
if literal in tok.string:
violations.append((tok.start[0], literal, tok.string))
prev_significant = tok.type
return violations
def _scan_files() -> list[Path]:
"""Deterministic (sorted) list of python files in the scan zone."""
files: list[Path] = []
for d in SCAN_DIRS:
root = REPO_ROOT / d
if root.is_dir():
files.extend(sorted(root.glob("**/*.py")))
return [
f for f in files
if f.relative_to(REPO_ROOT).as_posix() not in EXCLUDED_FILES
]
# ---------------------------------------------------------------------------
# TC-01: the platform code carries no forbidden host literals (AC-1).
# ---------------------------------------------------------------------------
def test_no_host_hardcodes_in_executable_code():
offenders: list[str] = []
for path in _scan_files():
rel = path.relative_to(REPO_ROOT).as_posix()
source = path.read_text(encoding="utf-8")
for lineno, literal, token_text in find_violations(source):
if (rel, literal) in ALLOWLIST:
continue
offenders.append(f"{rel}:{lineno}: forbidden literal {literal!r} in {token_text!r}")
assert not offenders, (
"Host-specific hardcodes found in executable code (read the value from "
"src/config.py Settings instead — see ORCH-101 ADR-001 D1/D2):\n"
+ "\n".join(offenders)
)
def test_scan_zone_is_nonempty():
"""Guard against the scanner silently scanning nothing (path drift)."""
files = _scan_files()
assert len(files) > 10, f"scan zone unexpectedly small: {len(files)} files"
rels = {f.relative_to(REPO_ROOT).as_posix() for f in files}
assert "src/config.py" not in rels # structural exclusion intact
assert "src/plane_sync.py" in rels # the A1 blocker file IS scanned
def test_allowlist_is_empty_at_acceptance():
"""AC-1/AC-7: the allowlist mechanism exists but carries no entries."""
assert ALLOWLIST == {}, (
"ORCH-101 ships with an EMPTY allowlist; a new entry needs an explicit "
"justification and reviewer sign-off"
)
# ---------------------------------------------------------------------------
# TC-02: negative self-check — the scanner actually catches a planted literal
# (the test is not evergreen) and actually skips comments/docstrings (NFR-5).
# ---------------------------------------------------------------------------
def test_scanner_catches_planted_literal_in_code():
planted = 'BASE = "http://git.mva154.duckdns.org"\n'
hits = find_violations(planted)
assert hits, "scanner failed to catch a forbidden literal planted in code"
assert {lit for _, lit, _ in hits} == {"mva154", "duckdns"}
def test_scanner_catches_planted_literal_in_env_dict():
planted = 'env = {**os.environ, "HOME": "/home/slin"}\n'
hits = find_violations(planted)
assert [(lineno, lit) for lineno, lit, _ in hits] == [(1, "/home/slin")]
def test_scanner_catches_planted_literal_in_fstring():
planted = 'url = f"http://{host}.mva154.local/x"\n'
hits = find_violations(planted)
assert any(lit == "mva154" for _, lit, _ in hits)
def test_scanner_ignores_comments_and_docstrings():
clean = (
'"""Module docstring mentioning mva154 and /home/slin and duckdns."""\n'
"\n"
"# a comment about 82.22.50.71 and /home/slin\n"
"def f():\n"
' """Docstring: mva154.local lives here historically."""\n'
" return 1 # trailing comment: duckdns\n"
)
assert find_violations(clean) == []
def test_scanner_judges_string_values_not_in_statement_position():
# A string VALUE (right-hand side) with a literal must be caught even when
# a docstring with the same literal is present above it.
mixed = (
"def f():\n"
' """mva154 in a docstring is fine."""\n'
' return "/home/slin"\n'
)
hits = find_violations(mixed)
assert [(lineno, lit) for lineno, lit, _ in hits] == [(3, "/home/slin")]

View File

@@ -4,10 +4,18 @@
HOME, который форсит launcher. Чистые конфиг-тесты: парсят YAML и текст
launcher, без запуска docker/агентов.
ORCH-101 (согласованная структурная правка): compose параметризован
`${VAR:-default}`-интерполяцией с дефолтами = боевым значениям, а HOME
launcher'а читается из `settings.agent_home_dir` (тот же дефолт). Тесты судят
РЕЗОЛВ при пустом окружении (эквивалент `docker compose config` без
переменных) — сам инвариант ORCH-040 (uid хоста, group_add 999, SSH-маунт под
HOME) не ослаблен: смена дефолта по-прежнему валит тест.
См. docs/work-items/ORCH-040/{02-trz.md,03-acceptance-criteria.md,
04-test-plan.yaml} и 06-adr/ADR-001-run-agents-as-host-uid.md.
"""
import re
from pathlib import Path
import pytest
@@ -20,9 +28,18 @@ LAUNCHER_PATH = REPO_ROOT / "src" / "agents" / "launcher.py"
# Сервисы, которые исполняют конвейер и обязаны бежать под uid хоста.
PIPELINE_SERVICES = ("orchestrator", "orchestrator-staging")
# Единый HOME агента (форсится launcher'ом); под ним должны лежать .ssh/.claude.
# Единый HOME агента (дефолт settings.agent_home_dir, ORCH-101); под ним
# должны лежать .ssh/.claude.
EXPECTED_HOME = "/home/slin"
# ORCH-101: ${VAR:-default} -> default (поведение compose при пустом env).
_INTERP_RE = re.compile(r"\$\{[A-Z0-9_]+:-([^}]*)\}")
def _resolve(value) -> str:
"""Резолв compose-интерполяции при ПУСТОМ окружении (дефолты)."""
return _INTERP_RE.sub(lambda m: m.group(1), str(value))
@pytest.fixture(scope="module")
def compose() -> dict:
@@ -40,10 +57,13 @@ def _service(compose: dict, name: str) -> dict:
def _ssh_mount_target(service: dict) -> str:
"""Target SSH-маунта (источник .orchestrator-ssh) для сервиса."""
"""Target SSH-маунта (источник .orchestrator-ssh) для сервиса.
ORCH-101: volume-строка резолвится из интерполяции ДО разбора src:target.
"""
for vol in service.get("volumes", []):
# формат "src:target[:mode]"
parts = vol.split(":")
# формат "src:target[:mode]" (после резолва дефолтов)
parts = _resolve(vol).split(":")
src = parts[0]
if src.endswith(".orchestrator-ssh"):
assert len(parts) >= 2, f"SSH-маунт без target: {vol}"
@@ -57,9 +77,10 @@ def test_tc01_service_runs_as_host_uid(compose, name):
"""TC-01/AC-1: сервис бежит под uid:gid хоста 1000:1000, а не root."""
service = _service(compose, name)
assert "user" in service, f"{name}: отсутствует ключ user (нужен '1000:1000')"
# docker допускает int или строку; нормализуем к строке.
assert str(service["user"]) == "1000:1000", (
f"{name}: user={service['user']!r}, ожидалось '1000:1000'"
# docker допускает int или строку; нормализуем к строке. ORCH-101: судим
# резолв дефолтов интерполяции (= docker compose config при пустом env).
assert _resolve(service["user"]) == "1000:1000", (
f"{name}: user={service['user']!r}, резолв должен давать '1000:1000'"
)
@@ -69,9 +90,9 @@ def test_tc02_group_add_keeps_docker_gid(compose, name):
"""TC-02/AC-4: group_add содержит 999 (доступ к docker.sock не потерян)."""
service = _service(compose, name)
group_add = service.get("group_add", [])
normalized = {str(g) for g in group_add}
normalized = {_resolve(g) for g in group_add}
assert "999" in normalized, (
f"{name}: group_add={group_add!r}, должен содержать '999' (docker.sock)"
f"{name}: group_add={group_add!r}, резолв должен содержать '999' (docker.sock)"
)
@@ -95,16 +116,23 @@ def test_tc04_launcher_home_matches_mounts(compose):
"""TC-04: HOME, форсимый launcher'ом, совпадает с базой SSH/claude-маунтов.
Нет рассинхрона HOME vs uid: и env Popen, и git_env, и target SSH-маунта
все указывают на /home/slin.
все указывают на один HOME. ORCH-101: launcher читает HOME из
`settings.agent_home_dir` через единый `agent_git_env()` (оба места —
Popen агента и git commit/push), а маунты compose интерполируют ТОТ ЖЕ
`ORCH_AGENT_HOME_DIR` — рассинхрон структурно невозможен; здесь судим, что
дефолт ключа и резолв маунтов сходятся в /home/slin.
"""
from src.config import Settings
# Дефолт ключа = канонический HOME (BR-5 ORCH-101 / AC-5 ORCH-040).
assert Settings.model_fields["agent_home_dir"].default == EXPECTED_HOME
source = LAUNCHER_PATH.read_text(encoding="utf-8")
# launcher форсит HOME в двух местах (env Popen и git_env).
occurrences = source.count(f'"HOME": "{EXPECTED_HOME}"')
assert occurrences >= 2, (
f"launcher.py: ожидалось >=2 форсинга HOME={EXPECTED_HOME!r}, "
f"найдено {occurrences}"
# Оба места запуска используют единый Settings-driven env-словарь.
assert '"HOME": settings.agent_home_dir' in source
assert source.count("agent_git_env()") >= 2, (
"launcher.py: env Popen и git_env должны строиться единым agent_git_env()"
)
# И SSH-маунты обоих сервисов ведут в этот же HOME.
# И SSH-маунты обоих сервисов ведут в этот же HOME (резолв дефолтов).
for name in PIPELINE_SERVICES:
target = _ssh_mount_target(_service(compose, name))
assert target.startswith(f"{EXPECTED_HOME}/"), (

View File

@@ -0,0 +1,99 @@
"""ORCH-101 (TC-10, AC-3/AC-4): репликационная документация и smoke-процедура.
Структурные проверки: deployment-док `docs/operations/REPLICATION.md`
существует и несёт пошаговую smoke-процедуру с явными PASS/FAIL-критериями,
карту env-переменных, чек-лист секретов и границы 10-common vs Lite vs
Bundled; карта env в INFRA.md дополнена; CHANGELOG.md содержит запись
ORCH-101; гайд stateless (не предписывает перенос боевых данных/секретов).
Скрипт-обвязка ORCH-101 не вводилась (ADR-001 D9 — чистый runbook), поэтому
запускаемость проверяется только для генератора секретов (--help, без
сети/LLM).
"""
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
REPLICATION = REPO_ROOT / "docs/operations/REPLICATION.md"
INFRA = REPO_ROOT / "docs/operations/INFRA.md"
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
def _replication_text() -> str:
assert REPLICATION.is_file(), "docs/operations/REPLICATION.md отсутствует (AC-3)"
return REPLICATION.read_text(encoding="utf-8")
def test_replication_doc_has_smoke_procedure_with_pass_fail():
text = _replication_text()
# Каждый шаг smoke имеет явный критерий; маркеры вердикта присутствуют.
assert "PASS" in text and "FAIL" in text
# Ключевые кирпичи процедуры (D9): health-check, onboarding-CLI, очередь.
for brick in ("/health", "/queue", "onboard_project.py", "docker compose config"):
assert brick in text, f"smoke-кирпич {brick} не упомянут в REPLICATION.md"
# Минимальный сигнал «конвейер доехал»: стадия analysis + артефакты 0104.
assert "analysis" in text
assert "01" in text and "04" in text
def test_replication_doc_covers_secrets_checklist():
text = _replication_text()
assert "gen_secrets.py" in text
for token in (
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
"ORCH_PLANE_API_TOKEN",
"ORCH_GITEA_TOKEN",
"ORCH_TELEGRAM_BOT_TOKEN",
):
assert token in text, f"секрет {token} не покрыт чек-листом"
# Нормативная строка stateless-тиража (AC-5): боевые секреты не копируются.
assert "не копиру" in text.lower()
def test_replication_doc_has_env_map_and_boundaries():
text = _replication_text()
for var in (
"ORCH_AGENT_HOME_DIR",
"ORCH_AGENT_GIT_NAME",
"ORCH_GIT_EMAIL_DOMAIN",
"ORCH_STAGING_PORT",
"ORCH_DOCKER_GID",
"ORCH_RUN_UID",
"ORCH_HOST_REPOS_DIR",
):
assert var in text, f"переменная {var} отсутствует в карте env REPLICATION.md"
# Границы тиража (анти-скоуп-крип Р-5) + платформенные конвенции (D3/D4).
assert "Lite" in text and "Bundled" in text
assert "orchestrator" in text # конвенция имени репо платформы
# Чек-лист обязывает задать реестр проектов на новом хосте (A9).
assert "ORCH_PROJECTS_JSON" in text
def test_replication_doc_is_stateless():
text = _replication_text().lower()
# Процедура не предписывает перенос БД/данных с боевого хоста.
assert "перенос" not in text or "не предполагает перенос" in text or "без перенос" in text
def test_infra_env_map_extended():
text = INFRA.read_text(encoding="utf-8")
for var in ("ORCH_AGENT_HOME_DIR", "ORCH_STAGING_PORT", "ORCH_DOCKER_GID"):
assert var in text, f"{var} отсутствует в карте env INFRA.md (AC-4)"
assert "REPLICATION.md" in text # перекрёстная ссылка на deployment-док
def test_changelog_has_orch_101_entry():
text = CHANGELOG.read_text(encoding="utf-8")
assert "ORCH-101" in text
def test_gen_secrets_runs_in_safe_mode():
"""Обвязка запускается без ошибок в безопасном режиме (--help: без сети,
без LLM, без записи файлов)."""
r = subprocess.run(
[sys.executable, str(REPO_ROOT / "scripts/gen_secrets.py"), "--help"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, r.stderr

106
tests/test_secrets_gen.py Normal file
View File

@@ -0,0 +1,106 @@
"""ORCH-101 (TC-09, AC-5 / NFR-3): scripts/gen_secrets.py — выпуск нового
комплекта секретов.
Контракт D8: криптослучайные webhook-секреты (>= 32 байт энтропии, hex);
повторный запуск даёт другие значения; существующий .env никогда не
перезаписывается молча (отказ exit=2, перезапись только --force); состав
ключей вывода согласован с .env.example.
"""
import importlib.util
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts/gen_secrets.py"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
def _load_module():
spec = importlib.util.spec_from_file_location("gen_secrets", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _values(fragment: str) -> dict[str, str]:
out = {}
for line in fragment.splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
out[k] = v
return out
def test_secret_is_cryptorandom_64_hex():
mod = _load_module()
assert mod.TOKEN_BYTES >= 32 # AC-5: >= 32 байта энтропии
value = mod.generate_secret()
assert _HEX64.match(value), value
def test_two_runs_give_different_values():
mod = _load_module()
a, b = _values(mod.build_fragment()), _values(mod.build_fragment())
for key in mod.GENERATED_KEYS:
assert _HEX64.match(a[key]) and _HEX64.match(b[key])
assert a[key] != b[key], f"{key}: повторный запуск дал то же значение"
def test_external_tokens_are_placeholders():
mod = _load_module()
vals = _values(mod.build_fragment())
for key in mod.EXTERNAL_KEYS:
assert vals[key] == "", f"{key} должен быть пустым плейсхолдером"
def test_output_keys_consistent_with_env_example():
"""AC-5: каждое имя ключа из вывода генератора существует в .env.example."""
mod = _load_module()
example = ENV_EXAMPLE.read_text(encoding="utf-8")
example_keys = {
line.split("=", 1)[0].strip()
for line in example.splitlines()
if "=" in line and not line.strip().startswith("#")
}
for key in (*mod.GENERATED_KEYS, *mod.EXTERNAL_KEYS):
assert key in example_keys, f"{key} отсутствует в .env.example"
def test_default_mode_prints_and_touches_no_files(tmp_path, capsys, monkeypatch):
mod = _load_module()
monkeypatch.chdir(tmp_path)
rc = mod.main([])
assert rc == 0
out = capsys.readouterr().out
assert "ORCH_PLANE_WEBHOOK_SECRET=" in out
assert list(tmp_path.iterdir()) == [] # filesystem untouched
def test_write_refuses_existing_file_without_force(tmp_path):
mod = _load_module()
target = tmp_path / ".env"
target.write_text("KEEP=1\n", encoding="utf-8")
rc = mod.main(["--write", str(target)])
assert rc == 2 # отказ, не перезапись
assert target.read_text(encoding="utf-8") == "KEEP=1\n" # содержимое цело
def test_write_creates_new_file_and_force_overwrites(tmp_path):
mod = _load_module()
target = tmp_path / ".env"
assert mod.main(["--write", str(target)]) == 0
first = _values(target.read_text(encoding="utf-8"))
assert _HEX64.match(first["ORCH_GITEA_WEBHOOK_SECRET"])
# --force перезаписывает, и секреты другие (не детерминированы).
assert mod.main(["--write", str(target), "--force"]) == 0
second = _values(target.read_text(encoding="utf-8"))
assert first["ORCH_GITEA_WEBHOOK_SECRET"] != second["ORCH_GITEA_WEBHOOK_SECRET"]
def test_no_real_secret_committed_anywhere_near():
"""Генератор не несёт зашитых значений: единственный источник — CSPRNG."""
text = SCRIPT.read_text(encoding="utf-8")
assert not re.search(r"[0-9a-f]{32,}", text), "зашитое hex-значение в генераторе"