From f1635ddb3958d147d561ed47b0aab106110a13c4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 20:50:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(replication):=20=D1=80=D0=B0=D1=81=D1=85?= =?UTF-8?q?=D0=B0=D1=80=D0=B4=D0=BA=D0=BE=D0=B4=20=D1=85=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20+=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=85=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20+=20smoke-runbook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фундамент тиража 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 --- .env.example | 56 +++++- .env.staging.example | 8 + CHANGELOG.md | 7 + CLAUDE.md | 27 +++ Dockerfile | 16 +- README.md | 7 + docker-compose.yml | 94 ++++++--- docs/architecture/README.md | 2 +- docs/operations/INFRA.md | 9 +- docs/operations/REPLICATION.md | 155 +++++++++++++++ scripts/gen_secrets.py | 147 ++++++++++++++ scripts/orchestrator-deploy-hook.sh | 6 +- src/agents/launcher.py | 46 +++-- src/config.py | 34 ++++ src/image_freshness.py | 35 +++- src/plane_sync.py | 16 +- src/post_deploy.py | 9 +- src/qg/checks.py | 8 + src/self_deploy.py | 16 +- tests/test_deploy_hook_rollback_sim.py | 8 +- tests/test_host_config_keys.py | 264 +++++++++++++++++++++++++ tests/test_infra_parametrization.py | 261 ++++++++++++++++++++++++ tests/test_no_host_hardcodes.py | 173 ++++++++++++++++ tests/test_orch040_compose.py | 60 ++++-- tests/test_replication_smoke.py | 99 ++++++++++ tests/test_secrets_gen.py | 106 ++++++++++ 26 files changed, 1583 insertions(+), 86 deletions(-) create mode 100644 docs/operations/REPLICATION.md create mode 100755 scripts/gen_secrets.py create mode 100644 tests/test_host_config_keys.py create mode 100644 tests/test_infra_parametrization.py create mode 100644 tests/test_no_host_hardcodes.py create mode 100644 tests/test_replication_smoke.py create mode 100644 tests/test_secrets_gen.py diff --git a/.env.example b/.env.example index 3a2ecbd..e3c34c3 100644 --- a/.env.example +++ b/.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/ diff --git a/.env.staging.example b/.env.staging.example index 722ed25..5ab8fa9 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e8410f5..f0e1a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-секций в нормативном порядке, «❌ → ✅», `` у 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. diff --git a/CLAUDE.md b/CLAUDE.md index d065ffe..2772299 100644 --- a/CLAUDE.md +++ b/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` diff --git a/Dockerfile b/Dockerfile index 8ed2471..873e9a7 100644 --- a/Dockerfile +++ b/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"] diff --git a/README.md b/README.md index d6092a2..ea13001 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 30d68ad..67f8ee9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}" diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 753f534..070859d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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). Платформа разворачивается на новой инфре **без правки кода** — только diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index 41cc28f..1249c07 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -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) — изоляция. diff --git a/docs/operations/REPLICATION.md b/docs/operations/REPLICATION.md new file mode 100644 index 0000000..5f3f01e --- /dev/null +++ b/docs/operations/REPLICATION.md @@ -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//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:/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/ docs/work-items//` | стадия не завершилась / артефактов нет | +| 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`.* diff --git a/scripts/gen_secrets.py b/scripts/gen_secrets.py new file mode 100755 index 0000000..d5d274c --- /dev/null +++ b/scripts/gen_secrets.py @@ -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()) diff --git a/scripts/orchestrator-deploy-hook.sh b/scripts/orchestrator-deploy-hook.sh index f72af9c..ef14f8d 100755 --- a/scripts/orchestrator-deploy-hook.sh +++ b/scripts/orchestrator-deploy-hook.sh @@ -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}" diff --git a/src/agents/launcher.py b/src/agents/launcher.py index ba1b744..1116039 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -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 diff --git a/src/config.py b/src/config.py index bb3407b..36ef139 100644 --- a/src/config.py +++ b/src/config.py @@ -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) diff --git a/src/image_freshness.py b/src/image_freshness.py index fc783d6..57227af 100644 --- a/src/image_freshness.py +++ b/src/image_freshness.py @@ -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. diff --git a/src/plane_sync.py b/src/plane_sync.py index 9507f29..e501bd5 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -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 diff --git a/src/post_deploy.py b/src/post_deploy.py index cd86b49..309896f 100644 --- a/src/post_deploy.py +++ b/src/post_deploy.py @@ -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], diff --git a/src/qg/checks.py b/src/qg/checks.py index ca63fd6..af087e4 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -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" diff --git a/src/self_deploy.py b/src/self_deploy.py index 853268d..8b23b1b 100644 --- a/src/self_deploy.py +++ b/src/self_deploy.py @@ -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], diff --git a/tests/test_deploy_hook_rollback_sim.py b/tests/test_deploy_hook_rollback_sim.py index 3fb25ec..d7f4823 100644 --- a/tests/test_deploy_hook_rollback_sim.py +++ b/tests/test_deploy_hook_rollback_sim.py @@ -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", diff --git a/tests/test_host_config_keys.py b/tests/test_host_config_keys.py new file mode 100644 index 0000000..838b7b8 --- /dev/null +++ b/tests/test_host_config_keys.py @@ -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") diff --git a/tests/test_infra_parametrization.py b/tests/test_infra_parametrization.py new file mode 100644 index 0000000..48667f0 --- /dev/null +++ b/tests/test_infra_parametrization.py @@ -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}" diff --git a/tests/test_no_host_hardcodes.py b/tests/test_no_host_hardcodes.py new file mode 100644 index 0000000..50cd513 --- /dev/null +++ b/tests/test_no_host_hardcodes.py @@ -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")] diff --git a/tests/test_orch040_compose.py b/tests/test_orch040_compose.py index 914df03..a9f5006 100644 --- a/tests/test_orch040_compose.py +++ b/tests/test_orch040_compose.py @@ -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}/"), ( diff --git a/tests/test_replication_smoke.py b/tests/test_replication_smoke.py new file mode 100644 index 0000000..edb0a69 --- /dev/null +++ b/tests/test_replication_smoke.py @@ -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 diff --git a/tests/test_secrets_gen.py b/tests/test_secrets_gen.py new file mode 100644 index 0000000..7bdf8f8 --- /dev/null +++ b/tests/test_secrets_gen.py @@ -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-значение в генераторе"