Фундамент тиража 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>
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""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-значение в генераторе"
|