feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
Фундамент тиража 10-common (эпик ORCH-10): платформа разворачивается на
новой инфре без правки кода — только env/конфиг. Каждый дефолт = боевому
значению (пустой .env => поведение 1:1, kill-switch-природа, NFR-2);
STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/схема БД не тронуты.
- config: agent_home_dir / agent_git_name / git_email_domain / staging_port
(ADR-001 D2/D4); код-блокеры A1-A4 закрыты: plane_sync ссылки из
gitea_public_url+gitea_owner, launcher - единый agent_git_env() (x2 места),
self_deploy/post_deploy - HOME+домен из Settings (имена системных акторов -
платформенные литералы)
- image_freshness: staging_port из конфига + fail-closed guard
staging_port == прод-порт -> отказ ДО ssh/build (инвариант ORCH-058 AC-9
стал исполняемым); REPO= передаётся хуку явно обоими инвокерами (D7)
- SELF_HOSTING_REPO - нормативная платформенная константа (D3, пин-тест)
- compose: полная ${VAR:-default}-интерполяция (реестр B, карта D6); группа
ORCH-040 uid/gid/HOME/маунты двигается согласованно (build.args APP_*);
group_add "МИНА 1" сохранён x3; оба app-сервиса с явным command:
- Dockerfile: ARG APP_UID/APP_GID/APP_USER/APP_HOME (CMD exec-form 8500
сознательно не тронут - D5); deploy-hook: REPO="${REPO:-...}" (D1 реестра)
- секреты: stdlib scripts/gen_secrets.py (token_hex(32); печать по умолчанию;
--write никогда не перезаписывает существующий .env молча, exit=2;
перезапись только --force); .env.example дополнен до полноты ключей старта
- доки: новый docs/operations/REPLICATION.md (карта env, чек-лист секретов,
smoke-процедура с PASS/FAIL, границы 10-common/Lite/Bundled), INFRA.md,
README, CLAUDE.md, CHANGELOG
- анти-регресс: tests/test_no_host_hardcodes.py (tokenize-сканер запрещённых
литералов, config-модули - структурное исключение, allowlist пуст,
негативная самопроверка) + test_host_config_keys / test_infra_parametrization
/ test_secrets_gen / test_replication_smoke; согласованные структурные
правки test_orch040_compose (судит резолв дефолтов) и
test_deploy_hook_rollback_sim (REPO через env-override = контракт D7)
Полный регресс: 1764 passed.
Refs: ORCH-101
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
56
.env.example
56
.env.example
@@ -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 D1–D7) ───
|
||||
# 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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 → тестовая задача → артефакты `01–04` стадии 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/` (D1–D3, 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` (D4–D7, 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.
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -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 до артефактов `01–04`/`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`
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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). Платформа разворачивается на новой инфре **без правки кода** — только
|
||||
|
||||
@@ -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) — изоляция.
|
||||
|
||||
155
docs/operations/REPLICATION.md
Normal file
155
docs/operations/REPLICATION.md
Normal 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
147
scripts/gen_secrets.py
Executable 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())
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
264
tests/test_host_config_keys.py
Normal file
264
tests/test_host_config_keys.py
Normal 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")
|
||||
261
tests/test_infra_parametrization.py
Normal file
261
tests/test_infra_parametrization.py
Normal 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}"
|
||||
173
tests/test_no_host_hardcodes.py
Normal file
173
tests/test_no_host_hardcodes.py
Normal 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 A1–A4 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")]
|
||||
@@ -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}/"), (
|
||||
|
||||
99
tests/test_replication_smoke.py
Normal file
99
tests/test_replication_smoke.py
Normal 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 + артефакты 01–04.
|
||||
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
106
tests/test_secrets_gen.py
Normal 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-значение в генераторе"
|
||||
Reference in New Issue
Block a user