#!/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())