Files
orchestrator/scripts/gen_secrets.py
claude-bot f1635ddb39
All checks were successful
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 55s
feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
Фундамент тиража 10-common (эпик ORCH-10): платформа разворачивается на
новой инфре без правки кода — только env/конфиг. Каждый дефолт = боевому
значению (пустой .env => поведение 1:1, kill-switch-природа, NFR-2);
STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/схема БД не тронуты.

- config: agent_home_dir / agent_git_name / git_email_domain / staging_port
  (ADR-001 D2/D4); код-блокеры A1-A4 закрыты: plane_sync ссылки из
  gitea_public_url+gitea_owner, launcher - единый agent_git_env() (x2 места),
  self_deploy/post_deploy - HOME+домен из Settings (имена системных акторов -
  платформенные литералы)
- image_freshness: staging_port из конфига + fail-closed guard
  staging_port == прод-порт -> отказ ДО ssh/build (инвариант ORCH-058 AC-9
  стал исполняемым); REPO= передаётся хуку явно обоими инвокерами (D7)
- SELF_HOSTING_REPO - нормативная платформенная константа (D3, пин-тест)
- compose: полная ${VAR:-default}-интерполяция (реестр B, карта D6); группа
  ORCH-040 uid/gid/HOME/маунты двигается согласованно (build.args APP_*);
  group_add "МИНА 1" сохранён x3; оба app-сервиса с явным command:
- Dockerfile: ARG APP_UID/APP_GID/APP_USER/APP_HOME (CMD exec-form 8500
  сознательно не тронут - D5); deploy-hook: REPO="${REPO:-...}" (D1 реестра)
- секреты: stdlib scripts/gen_secrets.py (token_hex(32); печать по умолчанию;
  --write никогда не перезаписывает существующий .env молча, exit=2;
  перезапись только --force); .env.example дополнен до полноты ключей старта
- доки: новый docs/operations/REPLICATION.md (карта env, чек-лист секретов,
  smoke-процедура с PASS/FAIL, границы 10-common/Lite/Bundled), INFRA.md,
  README, CLAUDE.md, CHANGELOG
- анти-регресс: tests/test_no_host_hardcodes.py (tokenize-сканер запрещённых
  литералов, config-модули - структурное исключение, allowlist пуст,
  негативная самопроверка) + test_host_config_keys / test_infra_parametrization
  / test_secrets_gen / test_replication_smoke; согласованные структурные
  правки test_orch040_compose (судит резолв дефолтов) и
  test_deploy_hook_rollback_sim (REPO через env-override = контракт D7)

Полный регресс: 1764 passed.

Refs: ORCH-101

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:50:43 +03:00

148 lines
7.0 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())