feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
Фундамент тиража 10-common (эпик ORCH-10): платформа разворачивается на
новой инфре без правки кода — только env/конфиг. Каждый дефолт = боевому
значению (пустой .env => поведение 1:1, kill-switch-природа, NFR-2);
STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/схема БД не тронуты.
- config: agent_home_dir / agent_git_name / git_email_domain / staging_port
(ADR-001 D2/D4); код-блокеры A1-A4 закрыты: plane_sync ссылки из
gitea_public_url+gitea_owner, launcher - единый agent_git_env() (x2 места),
self_deploy/post_deploy - HOME+домен из Settings (имена системных акторов -
платформенные литералы)
- image_freshness: staging_port из конфига + fail-closed guard
staging_port == прод-порт -> отказ ДО ssh/build (инвариант ORCH-058 AC-9
стал исполняемым); REPO= передаётся хуку явно обоими инвокерами (D7)
- SELF_HOSTING_REPO - нормативная платформенная константа (D3, пин-тест)
- compose: полная ${VAR:-default}-интерполяция (реестр B, карта D6); группа
ORCH-040 uid/gid/HOME/маунты двигается согласованно (build.args APP_*);
group_add "МИНА 1" сохранён x3; оба app-сервиса с явным command:
- Dockerfile: ARG APP_UID/APP_GID/APP_USER/APP_HOME (CMD exec-form 8500
сознательно не тронут - D5); deploy-hook: REPO="${REPO:-...}" (D1 реестра)
- секреты: stdlib scripts/gen_secrets.py (token_hex(32); печать по умолчанию;
--write никогда не перезаписывает существующий .env молча, exit=2;
перезапись только --force); .env.example дополнен до полноты ключей старта
- доки: новый docs/operations/REPLICATION.md (карта env, чек-лист секретов,
smoke-процедура с PASS/FAIL, границы 10-common/Lite/Bundled), INFRA.md,
README, CLAUDE.md, CHANGELOG
- анти-регресс: tests/test_no_host_hardcodes.py (tokenize-сканер запрещённых
литералов, config-модули - структурное исключение, allowlist пуст,
негативная самопроверка) + test_host_config_keys / test_infra_parametrization
/ test_secrets_gen / test_replication_smoke; согласованные структурные
правки test_orch040_compose (судит резолв дефолтов) и
test_deploy_hook_rollback_sim (REPO через env-override = контракт D7)
Полный регресс: 1764 passed.
Refs: ORCH-101
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
147
scripts/gen_secrets.py
Executable file
147
scripts/gen_secrets.py
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_secrets.py — выпуск НОВОГО комплекта секретов для нового хоста (ORCH-101).
|
||||
|
||||
Provisioning-инструмент тиража (ADR-001 D8): генерирует криптослучайные
|
||||
webhook-секреты оркестратора и печатает .env-фрагмент с плейсхолдерами внешних
|
||||
токенов. Боевые секреты текущего хоста НЕ копируются ни на одном шаге — для
|
||||
нового хоста всегда выпускается новый комплект (AC-5).
|
||||
|
||||
Состав (инвентаризация FR-4.1):
|
||||
* генерируются локально (secrets.token_hex(32) — 32 байта энтропии, 64 hex):
|
||||
ORCH_PLANE_WEBHOOK_SECRET, ORCH_GITEA_WEBHOOK_SECRET
|
||||
* выпускаются внешними системами (только плейсхолдер + чек-лист в
|
||||
docs/operations/REPLICATION.md: где выпустить -> куда вписать -> как
|
||||
проверить): ORCH_PLANE_API_TOKEN, ORCH_PLANE_BOT_* (7, опциональны),
|
||||
ORCH_GITEA_TOKEN, ORCH_TELEGRAM_BOT_TOKEN (+ несекретный
|
||||
ORCH_TELEGRAM_CHAT_ID), sidecar WATCHDOG_TG_BOT_TOKEN / WATCHDOG_TG_CHAT_ID.
|
||||
|
||||
Поведение (NFR-3): по умолчанию — ТОЛЬКО печать в stdout (файлы не трогаются).
|
||||
``--write [PATH]`` пишет фрагмент в файл (дефолт .env); СУЩЕСТВУЮЩИЙ файл ->
|
||||
отказ с exit-кодом 2 и внятным сообщением; перезапись — только явный
|
||||
``--force``. Повторный запуск всегда даёт другие значения секретов.
|
||||
|
||||
stdlib-only (secrets, argparse) — никаких зависимостей платформы; скрипт
|
||||
работает на голом python3 целевого хоста до первого `docker compose up`.
|
||||
|
||||
Запуск:
|
||||
python3 scripts/gen_secrets.py # печать в stdout
|
||||
python3 scripts/gen_secrets.py --write # создать .env (если его нет)
|
||||
python3 scripts/gen_secrets.py --write /tmp/e # создать произвольный файл
|
||||
python3 scripts/gen_secrets.py --write --force # перезаписать существующий
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
# Webhook-секреты, генерируемые локально на целевом хосте (FR-4.2).
|
||||
GENERATED_KEYS = (
|
||||
"ORCH_PLANE_WEBHOOK_SECRET",
|
||||
"ORCH_GITEA_WEBHOOK_SECRET",
|
||||
)
|
||||
|
||||
# Секреты внешних систем: только плейсхолдеры (значения выпускает оператор —
|
||||
# чек-лист в docs/operations/REPLICATION.md). Имена согласованы с .env.example.
|
||||
EXTERNAL_KEYS = (
|
||||
"ORCH_PLANE_API_TOKEN",
|
||||
"ORCH_PLANE_BOT_ANALYST",
|
||||
"ORCH_PLANE_BOT_ARCHITECT",
|
||||
"ORCH_PLANE_BOT_DEVELOPER",
|
||||
"ORCH_PLANE_BOT_REVIEWER",
|
||||
"ORCH_PLANE_BOT_TESTER",
|
||||
"ORCH_PLANE_BOT_DEPLOYER",
|
||||
"ORCH_PLANE_BOT_STREAM",
|
||||
"ORCH_GITEA_TOKEN",
|
||||
"ORCH_TELEGRAM_BOT_TOKEN",
|
||||
"ORCH_TELEGRAM_CHAT_ID",
|
||||
"WATCHDOG_TG_BOT_TOKEN",
|
||||
"WATCHDOG_TG_CHAT_ID",
|
||||
)
|
||||
|
||||
# 32 байта энтропии -> 64 hex-символа (AC-5: >= 32 байт).
|
||||
TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Криптослучайный webhook-секрет: 32 байта энтропии, hex-кодировка."""
|
||||
return secrets.token_hex(TOKEN_BYTES)
|
||||
|
||||
|
||||
def build_fragment() -> str:
|
||||
"""Собрать .env-фрагмент: свежие webhook-секреты + плейсхолдеры внешних.
|
||||
|
||||
Каждый вызов генерирует НОВЫЕ значения (secrets — CSPRNG); детерминизма нет
|
||||
по построению (AC-5 «повторный запуск даёт другие значения»).
|
||||
"""
|
||||
lines = [
|
||||
"# --- ORCH-101 gen_secrets.py: НОВЫЙ комплект секретов этого хоста ---",
|
||||
"# Сгенерировано локально; боевые секреты другого хоста сюда НЕ копируются.",
|
||||
"# Webhook-секреты вписать также в настройки webhook'ов Plane/Gitea",
|
||||
"# (см. docs/operations/REPLICATION.md и docs/operations/SETUP_WEBHOOKS.md).",
|
||||
]
|
||||
for key in GENERATED_KEYS:
|
||||
lines.append(f"{key}={generate_secret()}")
|
||||
lines.append("# --- Внешние токены: выпустить по чек-листу REPLICATION.md ---")
|
||||
for key in EXTERNAL_KEYS:
|
||||
lines.append(f"{key}=")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI. Возвращает exit-код (0 ok; 2 — отказ перезаписи без --force)."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Выпуск нового комплекта секретов оркестратора (ORCH-101)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write",
|
||||
nargs="?",
|
||||
const=".env",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="записать фрагмент в файл (дефолт .env); существующий файл -> отказ",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="разрешить перезапись СУЩЕСТВУЮЩЕГО файла (явное подтверждение)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
fragment = build_fragment()
|
||||
|
||||
if args.write is None:
|
||||
# Режим по умолчанию: только печать, файловая система не трогается.
|
||||
sys.stdout.write(fragment)
|
||||
return 0
|
||||
|
||||
path = args.write
|
||||
if not args.force:
|
||||
try:
|
||||
# x-mode: атомарный «создать только если не существует» — никогда
|
||||
# не перезаписывает существующий .env молча (NFR-3 / AC-5).
|
||||
with open(path, "x", encoding="utf-8") as f:
|
||||
f.write(fragment)
|
||||
except FileExistsError:
|
||||
sys.stderr.write(
|
||||
f"ОТКАЗ: {path} уже существует — молча не перезаписываю. "
|
||||
"Перезапись только с явным --force "
|
||||
"(или укажи другой путь: --write PATH).\n"
|
||||
)
|
||||
return 2
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"ОШИБКА записи {path}: {e}\n")
|
||||
return 1
|
||||
else:
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(fragment)
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"ОШИБКА записи {path}: {e}\n")
|
||||
return 1
|
||||
|
||||
sys.stderr.write(f"Записано: {path} (webhook-секреты сгенерированы заново)\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -35,7 +35,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO=/home/slin/repos/orchestrator
|
||||
# ORCH-101 (D7): env-override like every other variable of this hook. The wired
|
||||
# invokers (self_deploy.build_deploy_command / image_freshness.rebuild_staging_image)
|
||||
# pass REPO explicitly from ORCH_DEPLOY_HOST_REPO_PATH; the default below serves
|
||||
# manual operator runs on the current host.
|
||||
REPO="${REPO:-/home/slin/repos/orchestrator}"
|
||||
|
||||
# ---- Defaults (STAGING — safe) ---------------------------------------------
|
||||
TARGET_SERVICE="${TARGET_SERVICE:-orchestrator-staging}"
|
||||
|
||||
Reference in New Issue
Block a user