Files
orchestrator/scripts/bootstrap_bundle.py
claude-bot f0cd19d748 feat(replication): ORCH-10b Bundled-тираж — bundle-compose всего стека + bootstrap-скрипт
Закрывает Type B эпика ORCH-10 (по ADR-001 ORCH-103, D1–D11):

- deploy/bundled/docker-compose.yml — самодостаточный compose всего стека
  (орк + watchdog + Gitea 1.22.6 + зеркало upstream Plane CE v0.23.1,
  ~14 контейнеров); project name orchestrator-bundle (узнаваемый префикс),
  container_name не пиннится, staging-контура нет; одна bridge-сеть,
  машинный трафик — сервис-DNS, наружу только человеческие порты;
  GITEA__webhook__ALLOWED_HOST_LIST=orchestrator; все образы пиннованы
  неподвижными тегами. Корневой compose/Dockerfile/src/** — байт-в-байт.
- deploy/bundled/.env.example — конфиг-канон bundle (плейсхолдеры, ни одного
  дефолтного пароля; key-set-sync интерполяций держит тест).
- scripts/bootstrap_bundle.py — python stdlib-only, режимы plan/apply/verify,
  step-движок check→ensure, exit 0/2/1: preflight (fail-fast до мутаций) →
  секреты (gen_secrets.py + stdlib secrets, без перетирания) → up+готовность →
  init Gitea автоматом → init Plane (manual-step с API-верификацией) →
  онбординг строго onboard_project.py apply+verify → token-remote клон →
  сборка .env/.env.watchdog (единственный писатель, права 600) → health.
  Delete-операций нет вообще (D9), секреты не печатаются (NFR-3).
- CHANGELOG.md, CLAUDE.md (абзац Type B), .gitignore (deploy/bundled/repos/).

Док BUNDLED_SETUP.md, REPLICATION §1, arch README, adr-0038 и три структурных
тест-модуля (TC-01…TC-11) — в предыдущих коммитах ветки; полный регресс
1844 passed, ruff по файлам задачи чистый.

Refs: ORCH-103

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:16:32 +03:00

973 lines
47 KiB
Python
Raw 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
"""bootstrap_bundle.py — доводка Bundled-инсталляции до рабочего конвейера (ORCH-103).
Один запуск поверх `deploy/bundled/docker-compose.yml` доводит свежеподнятый стек
(орк + watchdog + Gitea + Plane CE) до рабочего состояния: preflight → секреты →
up + готовность → init Gitea (полностью автоматом) → init Plane (честные
manual-step чекпоинты с верификацией) → онбординг sandbox-проекта строго
кирпичом ``scripts/onboard_project.py`` → git-доступ агентов (HTTP token-remote)
→ сборка runtime-конфига орка (корневые ``.env`` / ``.env.watchdog``) → health.
Режимы (ADR-001 D5, паттерн ORCH-009):
plan — дефолт; ноль мутаций: печать плана + read-only preflight-диагностика.
apply — полный прогон; step-движок check→ensure (повторный запуск = каскад
skip; «resume» после manual-step = просто повторный запуск).
verify — read-only пост-проверка (health/queue/metrics + onboard verify).
Exit-коды (контракт TRZ FR-2): 0 — успех; 2 — остановка на manual-step или
незавершённое предусловие; 1 — ошибка.
Гарантии (NFR-3 / D5 / D9):
* python stdlib-only; модули платформы не импортируются (канон-знания — только
субпроцессами кирпичей gen_secrets.py / onboard_project.py, AC-7);
* значения секретов НИКОГДА не печатаются (только имена ключей/пути файлов);
* delete-операций НЕТ ВООБЩЕ: teardown — только документированная процедура
docs/deployment/BUNDLED_SETUP.md §13 (ADR-001 D9);
* существующие секреты не перетираются без явного ``--force-secrets``
(использовать только ДО первого подъёма стека: уже инициализированные
Plane/Gitea новых паролей сами не подхватят);
* скрипт говорит только с локальным docker целевого хоста.
Запуск — из корня чекаута репо orchestrator на целевом хосте:
python3 scripts/bootstrap_bundle.py # план + диагностика
python3 scripts/bootstrap_bundle.py apply # полный прогон
python3 scripts/bootstrap_bundle.py verify # read-only пост-проверка
"""
import argparse
import getpass
import json
import os
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import uuid
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BUNDLE_DIR = os.path.join(REPO_ROOT, "deploy", "bundled")
BUNDLE_COMPOSE = os.path.join(BUNDLE_DIR, "docker-compose.yml")
BUNDLE_ENV_EXAMPLE = os.path.join(BUNDLE_DIR, ".env.example")
BUNDLE_ENV = os.path.join(BUNDLE_DIR, ".env")
ROOT_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.example")
ROOT_ENV = os.path.join(REPO_ROOT, ".env")
WATCHDOG_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.watchdog.example")
WATCHDOG_ENV = os.path.join(REPO_ROOT, ".env.watchdog")
GEN_SECRETS = os.path.join(REPO_ROOT, "scripts", "gen_secrets.py")
ONBOARD = os.path.join(REPO_ROOT, "scripts", "onboard_project.py")
REQUIREMENTS = os.path.join(REPO_ROOT, "requirements.txt")
VENV_DIR = os.path.join(REPO_ROOT, ".venv")
VENV_PY = os.path.join(VENV_DIR, "bin", "python")
DOC = "docs/deployment/BUNDLED_SETUP.md"
# Узнаваемый префикс томов/контейнеров инсталляции (compose project name, D1).
PROJECT = "orchestrator-bundle"
ORCH_CONTAINER = "orchestrator-bundle-orchestrator-1"
# Машинные in-network URL (D4): сервис-DNS bundle-сети, не хост.
WEBHOOK_PLANE_URL = "http://orchestrator:8500/webhook/plane"
WEBHOOK_GITEA_URL = "http://orchestrator:8500/webhook/gitea"
GITEA_INTERNAL_URL = "http://gitea:3000"
PLANE_INTERNAL_URL = "http://proxy"
EXIT_OK = 0
EXIT_MANUAL = 2
EXIT_ERROR = 1
# Минимумы хоста (синхронизированы с BUNDLED_SETUP §2; пороги preflight, TR-1).
MIN_RAM_GB = 8
MIN_DISK_GB = 40
MIN_CPUS = 4
# Тайм-ауты ожидания готовности (D5 шаг 3): миграции Plane — самые долгие.
READY_TIMEOUT_S = 180
PLANE_READY_TIMEOUT_S = 600
# Bundle-внутренние креды (upstream-имена, D2/FR-3) — генерирует bootstrap.
BUNDLE_SECRET_KEYS = (
"POSTGRES_PASSWORD",
"SECRET_KEY",
"RABBITMQ_DEFAULT_PASS",
"MINIO_ROOT_PASSWORD",
"GITEA_ADMIN_PASSWORD",
)
# Обязательные НЕсекретные ключи bundle-конфига (preflight, D5 шаг 1).
REQUIRED_BUNDLE_KEYS = (
"BUNDLE_PUBLIC_HOST",
"BUNDLE_ORCH_PORT",
"BUNDLE_PLANE_PORT",
"BUNDLE_GITEA_HTTP_PORT",
"ORCH_RUN_UID",
"ORCH_RUN_GID",
"ORCH_DOCKER_GID",
"ORCH_AGENT_HOME_DIR",
"GITEA_ADMIN_USERNAME",
)
# Webhook-секреты орка — выпускает ТОЛЬКО кирпич gen_secrets.py (AC-7).
WEBHOOK_SECRET_KEYS = ("ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET")
# Sandbox-проект первого smoke (онбордится строго onboard_project.py, BR-6).
SANDBOX_DEFAULTS = {
"name": "Sandbox",
"repo": "sandbox",
"prefix": "SBX",
"stack": "python",
"test_cmd": "pytest -q",
"prod_port": "8600",
"staging_port": "8601",
}
class ManualStop(Exception):
"""Остановка на manual-step / незавершённом предусловии → exit 2."""
class BootstrapError(Exception):
"""Невосстановимая ошибка шага → exit 1."""
def log(msg: str) -> None:
"""Печать строки прогресса. Значения секретов сюда НЕ передаются (NFR-3)."""
print(msg, flush=True)
# --------------------------------------------------------------------------- #
# Чистые функции (unit-тесты — tests/test_bootstrap_script.py, TC-08)
# --------------------------------------------------------------------------- #
def parse_env(text: str) -> dict:
"""``KEY=value``-строки текста → словарь (комментарии/пустые — мимо)."""
out: dict = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
out[key.strip()] = value.strip()
return out
def render_env(example_text: str, overrides: dict) -> str:
"""Рендер env-файла от канона-example: ``KEY=`` строки получают значения
overrides (та же строка, комментарии сохранены); ключи overrides, которых
в каноне нет, дописываются управляемым блоком в конец."""
used: set = set()
lines: list = []
for line in example_text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in overrides:
lines.append(f"{key}={overrides[key]}")
used.add(key)
continue
lines.append(line)
extra = [k for k in overrides if k not in used]
if extra:
lines.append("")
lines.append("# --- bootstrap_bundle.py (ORCH-103): управляемые ключи ---")
for key in extra:
lines.append(f"{key}={overrides[key]}")
return "\n".join(lines) + "\n"
def merge_missing_secrets(existing: dict, keys: tuple = BUNDLE_SECRET_KEYS,
force: bool = False, gen=None) -> dict:
"""Новые значения ТОЛЬКО для пустых/отсутствующих секрет-ключей (AC-8:
существующие не перетираются; ``force=True`` — явная регенерация всех)."""
gen = gen or (lambda key: secrets.token_hex(32 if key == "SECRET_KEY" else 16))
fresh: dict = {}
for key in keys:
if force or not existing.get(key, ""):
fresh[key] = gen(key)
return fresh
def preflight_verdict(facts: dict) -> tuple:
"""Чистый вердикт preflight (BR-7): ``(blockers, warnings, resume)``.
resume=True — на хосте уже есть тома/контейнеры с префиксом проекта:
не «грязь», а инициализированная инсталляция → ensure-режим (AC-8);
противоречивое состояние (есть тома, но нет конфига) — блокер.
"""
blockers: list = []
warnings: list = []
resume = bool(facts.get("leftovers"))
if not facts.get("docker"):
blockers.append("docker не найден — установите Docker Engine (BUNDLED_SETUP §3)")
if not facts.get("compose"):
blockers.append("docker compose v2 не найден (BUNDLED_SETUP §3)")
if not facts.get("env_exists"):
if resume:
blockers.append(
"противоречивое состояние: тома/контейнеры orchestrator-bundle уже "
"есть, а deploy/bundled/.env отсутствует — восстановите конфиг "
"или выполните полный сброс (BUNDLED_SETUP §13)"
)
else:
blockers.append(
"deploy/bundled/.env отсутствует — создайте: "
"cp deploy/bundled/.env.example deploy/bundled/.env (BUNDLED_SETUP §5)"
)
for key in facts.get("missing_keys", []):
blockers.append(f"deploy/bundled/.env: обязательный ключ {key} пуст")
if not resume:
for port in facts.get("busy_ports", []):
blockers.append(
f"порт {port} уже занят на хосте — освободите его или смените "
f"BUNDLE_*-порт в deploy/bundled/.env (BUNDLED_SETUP §2)"
)
ram = facts.get("ram_gb")
if ram is not None and ram < MIN_RAM_GB:
blockers.append(
f"RAM {ram:.1f} GB < минимума {MIN_RAM_GB} GB (Plane ≈ 14 контейнеров, "
f"BUNDLED_SETUP §2)"
)
disk = facts.get("disk_gb")
if disk is not None and disk < MIN_DISK_GB:
blockers.append(f"свободный диск {disk:.0f} GB < минимума {MIN_DISK_GB} GB")
cpus = facts.get("cpus")
if cpus is not None and cpus < MIN_CPUS:
warnings.append(f"CPU {cpus} < рекомендуемых {MIN_CPUS} vCPU — стек будет медленным")
if not facts.get("python3", True):
blockers.append("python3/venv недоступны — нужны для onboard-кирпича (TR-9)")
if not facts.get("claude_cli"):
warnings.append(
"Claude CLI/креды не найдены на хосте — стек поднимется, но конвейер "
"без LLM не поедет (BUNDLED_SETUP §8)"
)
return blockers, warnings, resume
def build_plan() -> list:
"""Нормативный план apply (нумерация — TRZ FR-2; механика — ADR-001 D5)."""
return [
("preflight", "fail-fast проверки хоста ДО любых мутаций (BR-7)"),
("secrets", "новые секреты инсталляции: gen_secrets.py + bundle-креды (FR-3)"),
("up", "подъём bundle-compose + ожидание готовности (миграции Plane/Gitea)"),
("init-gitea", "админ-бот + API-токен через `gitea admin ...` (полностью автоматом)"),
("init-plane", "instance-setup/workspace/API-токен — manual-step с верификацией"),
("plane-webhook", "workspace-webhook Plane → орк (ensure либо manual-step + проверка)"),
("onboard", "sandbox-проект строго через onboard_project.py apply+verify (BR-6)"),
("agent-git", "git-доступ агентов: клон sandbox-репо token-remote в /repos (D8)"),
("orch-env", "сборка корневых .env/.env.watchdog + пересоздание орка/watchdog"),
("health", "GET /health, /queue, /metrics + итоговая сводка PASS/FAIL"),
]
def build_arg_parser() -> argparse.ArgumentParser:
"""CLI: режимы plan (дефолт) / apply / verify + параметры sandbox."""
parser = argparse.ArgumentParser(
description="Bootstrap Bundled-инсталляции (ORCH-103). Канон — "
f"{DOC}."
)
parser.add_argument(
"mode", nargs="?", default="plan", choices=("plan", "apply", "verify"),
help="plan — дефолт, ноль мутаций; apply — прогон; verify — пост-проверка",
)
parser.add_argument(
"--force-secrets", action="store_true",
help="регенерировать СУЩЕСТВУЮЩИЕ bundle-креды (только ДО первого up!)",
)
parser.add_argument("--sandbox-name", default=SANDBOX_DEFAULTS["name"])
parser.add_argument("--sandbox-repo", default=SANDBOX_DEFAULTS["repo"])
parser.add_argument("--sandbox-prefix", default=SANDBOX_DEFAULTS["prefix"])
parser.add_argument("--sandbox-stack", default=SANDBOX_DEFAULTS["stack"])
parser.add_argument("--sandbox-test-cmd", default=SANDBOX_DEFAULTS["test_cmd"])
parser.add_argument("--sandbox-prod-port", default=SANDBOX_DEFAULTS["prod_port"])
parser.add_argument("--sandbox-staging-port", default=SANDBOX_DEFAULTS["staging_port"])
return parser
# --------------------------------------------------------------------------- #
# Тонкие обёртки subprocess/HTTP (единственные точки side-effects)
# --------------------------------------------------------------------------- #
def _run(cmd: list, input_text: str | None = None, env: dict | None = None,
timeout: int = 600) -> subprocess.CompletedProcess:
"""subprocess.run c capture; команды логируются БЕЗ секретов вызывающим."""
return subprocess.run(
cmd, input=input_text, env=env, capture_output=True, text=True,
timeout=timeout, check=False,
)
def _compose(*args: str, input_text: str | None = None,
timeout: int = 600) -> subprocess.CompletedProcess:
return _run(["docker", "compose", "-f", BUNDLE_COMPOSE, *args],
input_text=input_text, timeout=timeout)
def _http(url: str, headers: dict | None = None, timeout: int = 10) -> tuple:
"""GET url → (status|None, body). Никогда не бросает (poll-friendly)."""
req = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, ""
except (urllib.error.URLError, OSError, ValueError):
return None, ""
def _port_busy(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
return s.connect_ex(("127.0.0.1", port)) == 0
def _write_private(path: str, content: str) -> None:
"""Запись live-конфига: права 600, без печати содержимого (NFR-3)."""
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, 0o600)
log(f" записан {os.path.relpath(path, REPO_ROOT)} (права 600)")
def update_env_file(path: str, example_path: str, overrides: dict) -> None:
"""Идемпотентный ensure env-файла: существующий — обновить ключи overrides,
отсутствующий — отрендерить от канона-example. Никаких удалений."""
if os.path.isfile(path):
base = open(path, encoding="utf-8").read()
else:
base = open(example_path, encoding="utf-8").read()
_write_private(path, render_env(base, overrides))
# --------------------------------------------------------------------------- #
# Сбор фактов хоста (read-only; используется plan/apply/verify)
# --------------------------------------------------------------------------- #
def bundle_ports(bundle_env: dict) -> list:
out = []
for key, default in (("BUNDLE_ORCH_PORT", 8500), ("BUNDLE_PLANE_PORT", 8080),
("BUNDLE_GITEA_HTTP_PORT", 3000)):
try:
out.append(int(bundle_env.get(key) or default))
except ValueError:
out.append(default)
return out
def collect_facts(bundle_env: dict) -> dict:
"""Read-only снимок хоста для preflight_verdict (ни одной мутации)."""
docker = shutil.which("docker") is not None
compose = docker and _compose("version", timeout=30).returncode == 0
leftovers: list = []
if docker:
vols = _run(["docker", "volume", "ls", "--format", "{{.Name}}"], timeout=30)
names = _run(["docker", "ps", "-a", "--format", "{{.Names}}"], timeout=30)
for line in (vols.stdout + "\n" + names.stdout).splitlines():
if line.strip().startswith(PROJECT):
leftovers.append(line.strip())
ram_gb = None
try:
with open("/proc/meminfo", encoding="utf-8") as f:
for line in f:
if line.startswith("MemTotal:"):
ram_gb = int(line.split()[1]) / 1024 / 1024
break
except OSError:
pass
try:
disk_gb = shutil.disk_usage(REPO_ROOT).free / 2**30
except OSError:
disk_gb = None
env_exists = os.path.isfile(BUNDLE_ENV)
missing = [k for k in REQUIRED_BUNDLE_KEYS if not bundle_env.get(k, "")]
claude_ok = (
shutil.which("claude") is not None
or os.path.isdir(os.path.expanduser(
bundle_env.get("ORCH_HOST_CLAUDE_DIR", "") or "~/.claude"))
)
return {
"docker": docker,
"compose": compose,
"env_exists": env_exists,
"missing_keys": missing if env_exists else [],
"busy_ports": [p for p in bundle_ports(bundle_env) if _port_busy(p)],
"leftovers": leftovers,
"ram_gb": ram_gb,
"disk_gb": disk_gb,
"cpus": os.cpu_count(),
"python3": True, # мы уже исполняемся под python3
"claude_cli": claude_ok,
}
# --------------------------------------------------------------------------- #
# Manual-step контракт (D5/D7): инструкция → подтверждение → верификация
# --------------------------------------------------------------------------- #
def manual_checkpoint(title: str, instructions: list, verify, max_tries: int = 3):
"""Честный чекпоинт: печать точной инструкции; без TTY — немедленный exit 2
с той же инструкцией; с TTY — ожидание подтверждения и ВЕРИФИКАЦИЯ результата
(молчаливый пропуск запрещён). verify() → (ok, hint)."""
log(f"\n🖐 MANUAL-STEP: {title}")
for line in instructions:
log(f" {line}")
if not sys.stdin.isatty():
log(" Нет TTY: выполните шаги и перезапустите `apply` (resume = повторный запуск).")
raise ManualStop(title)
for _ in range(max_tries):
input(" Когда выполнено — нажмите Enter: ")
ok, hint = verify()
if ok:
log(" ✓ верификация пройдена")
return
log(f" ✗ верификация не прошла: {hint}")
raise ManualStop(f"{title}: верификация не прошла после {max_tries} попыток")
# --------------------------------------------------------------------------- #
# Шаги apply (step-движок check→ensure; каждый идемпотентен)
# --------------------------------------------------------------------------- #
def step_preflight(ctx: dict) -> str:
facts = collect_facts(ctx["bundle_env"])
blockers, warnings, resume = preflight_verdict(facts)
for w in warnings:
log(f"{w}")
if blockers:
for b in blockers:
log(f"{b}")
raise ManualStop("preflight: незавершённые предусловия хоста")
ctx["resume"] = resume
if resume:
log(" инсталляция уже существует — продолжаю в ensure-режиме (AC-8)")
return "ok"
def step_secrets(ctx: dict) -> str:
"""FR-3: bundle-креды (stdlib secrets) + webhook-секреты (gen_secrets.py)."""
force = ctx["args"].force_secrets
bundle_env = ctx["bundle_env"]
fresh = merge_missing_secrets(bundle_env, force=force)
# uid/gid/docker-gid хоста — дозаполняются фактическими значениями оператора
infra: dict = {}
if not bundle_env.get("ORCH_RUN_UID"):
infra["ORCH_RUN_UID"] = str(os.getuid())
if not bundle_env.get("ORCH_RUN_GID"):
infra["ORCH_RUN_GID"] = str(os.getgid())
if fresh or infra:
update_env_file(BUNDLE_ENV, BUNDLE_ENV_EXAMPLE, {**infra, **fresh})
ctx["bundle_env"] = parse_env(open(BUNDLE_ENV, encoding="utf-8").read())
log(f" bundle-креды выпущены: {', '.join(sorted(fresh)) or ''}")
else:
log(" bundle-креды уже на месте (не перетираю без --force-secrets)")
# webhook-секреты орка — СТРОГО кирпичом gen_secrets.py (AC-7)
root_env = ctx["root_env"]
if all(root_env.get(k) for k in WEBHOOK_SECRET_KEYS) and not force:
log(" webhook-секреты уже в .env — skip")
return "skipped"
with tempfile.TemporaryDirectory() as tmp:
frag_path = os.path.join(tmp, "fragment.env")
proc = _run([sys.executable, GEN_SECRETS, "--write", frag_path], timeout=60)
if proc.returncode != 0:
raise BootstrapError(f"gen_secrets.py отказал (rc={proc.returncode})")
fragment = parse_env(open(frag_path, encoding="utf-8").read())
overrides = {k: fragment[k] for k in WEBHOOK_SECRET_KEYS
if fragment.get(k) and (force or not root_env.get(k))}
update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides)
ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
log(f" webhook-секреты выпущены: {', '.join(sorted(overrides)) or ''}")
return "ok"
def _wait_http(url: str, timeout_s: int, label: str, ok_statuses=(200,)) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
status, _ = _http(url, timeout=5)
if status in ok_statuses:
log(f"{label} готов ({url})")
return
time.sleep(5)
tail = _compose("logs", "--tail", "30", label, timeout=60).stdout[-2000:]
raise BootstrapError(f"{label} не дождались за {timeout_s}с ({url}); хвост логов:\n{tail}")
def _wait_migrator(timeout_s: int) -> None:
deadline = time.monotonic() + timeout_s
name = f"{PROJECT}-migrator-1"
while time.monotonic() < deadline:
proc = _run(["docker", "inspect", "-f",
"{{.State.Status}} {{.State.ExitCode}}", name], timeout=30)
state = proc.stdout.strip()
if proc.returncode == 0 and state.startswith("exited"):
if state.endswith(" 0"):
log(" ✓ миграции Plane завершились (migrator exit 0)")
return
tail = _compose("logs", "--tail", "30", "migrator", timeout=60).stdout[-2000:]
raise BootstrapError(f"миграции Plane упали ({state}); хвост логов:\n{tail}")
time.sleep(5)
raise BootstrapError(f"миграции Plane не завершились за {timeout_s}с (TR-1: проверьте RAM/диск)")
def step_up(ctx: dict) -> str:
"""Подъём стека + ожидание готовности каждого слоя (D5 шаг 3)."""
for sub in ("data", "repos"):
os.makedirs(os.path.join(BUNDLE_DIR, sub), exist_ok=True)
proc = _compose("up", "-d", timeout=1800)
if proc.returncode != 0:
raise BootstrapError(f"docker compose up отказал:\n{proc.stderr[-2000:]}")
ports = dict(zip(("orch", "plane", "gitea"), bundle_ports(ctx["bundle_env"])))
_wait_http(f"http://127.0.0.1:{ports['gitea']}/api/healthz", READY_TIMEOUT_S, "gitea")
_wait_migrator(PLANE_READY_TIMEOUT_S)
_wait_http(f"http://127.0.0.1:{ports['plane']}/", PLANE_READY_TIMEOUT_S,
"proxy", ok_statuses=(200, 301, 302))
_wait_http(f"http://127.0.0.1:{ports['orch']}/health", READY_TIMEOUT_S, "orchestrator")
return "ok"
def step_init_gitea(ctx: dict) -> str:
"""D6: админ-бот + API-токен официальным CLI в контейнере; идемпотентно.
Branch protection НЕ настраивается (норматив D10 ORCH-009 / INV-4)."""
bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
user = bundle_env.get("GITEA_ADMIN_USERNAME", "orchestrator-bot")
gitea_port = bundle_ports(bundle_env)[2]
ctx["gitea_owner"] = user
proc = _compose(
"exec", "-T", "-u", "git", "gitea",
"gitea", "admin", "user", "create", "--admin",
"--username", user, "--password", bundle_env.get("GITEA_ADMIN_PASSWORD", ""),
"--email", f"{user}@{PROJECT}.local", "--must-change-password=false",
timeout=120,
)
blob = proc.stdout + proc.stderr
if proc.returncode == 0:
log(f" создан админ-бот Gitea: {user}")
elif "already exists" in blob:
log(f" админ-бот {user} уже существует — skip")
else:
raise BootstrapError(f"gitea admin user create отказал: {blob[-500:]}")
# API-токен (носитель — root .env, ORCH_GITEA_TOKEN)
token = root_env.get("ORCH_GITEA_TOKEN", "")
if token:
status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user",
headers={"Authorization": f"token {token}"})
if status == 200:
log(" ORCH_GITEA_TOKEN валиден — skip")
return "skipped"
proc = _compose(
"exec", "-T", "-u", "git", "gitea",
"gitea", "admin", "user", "generate-access-token",
"--username", user, "--token-name", f"orchestrator-{int(time.time())}",
"--scopes", "all", "--raw",
timeout=120,
)
if proc.returncode != 0:
raise BootstrapError(f"generate-access-token отказал: {proc.stderr[-500:]}")
token = proc.stdout.strip().splitlines()[-1].strip()
status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user",
headers={"Authorization": f"token {token}"})
if status != 200:
raise BootstrapError(f"свежий токен Gitea не прошёл верификацию (HTTP {status})")
update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE,
{"ORCH_GITEA_TOKEN": token, "ORCH_GITEA_OWNER": user})
ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
log(" выпущен ORCH_GITEA_TOKEN (значение в .env, не печатается)")
return "ok"
def _verify_plane_token(plane_port: int, slug: str, token: str) -> tuple:
status, _ = _http(
f"http://127.0.0.1:{plane_port}/api/v1/workspaces/{slug}/projects/",
headers={"X-API-Key": token}, timeout=15,
)
if status == 200:
return True, ""
return False, f"GET /api/v1/workspaces/{slug}/projects/ → HTTP {status}"
def step_init_plane(ctx: dict) -> str:
"""D7: instance-setup / workspace / API-токен — честные manual-step
чекпоинты (Plane CE не даёт API первичной инициализации)."""
bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
plane_port = bundle_ports(bundle_env)[1]
slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "")
token = root_env.get("ORCH_PLANE_API_TOKEN", "")
if slug and token and _verify_plane_token(plane_port, slug, token)[0]:
log(" workspace и ORCH_PLANE_API_TOKEN валидны — skip")
return "skipped"
def _instance_done():
status, body = _http(f"http://127.0.0.1:{plane_port}/api/instances/", timeout=10)
if status == 200 and '"is_setup_done":true' in body.replace(" ", ""):
return True, ""
if status == 200:
return False, "instance setup ещё не завершён (is_setup_done != true)"
# эндпоинт недоступен в этой сборке CE → деградация: живость UI
ui, _ = _http(f"http://127.0.0.1:{plane_port}/", timeout=10)
return (ui in (200, 301, 302)), f"Plane UI отвечает HTTP {ui}"
manual_checkpoint(
"Plane: instance setup (первый администратор)",
[f"Откройте http://{host}:{plane_port}/ и зарегистрируйте первого",
"пользователя — он станет администратором инстанса (Plane CE)."],
_instance_done,
)
if not sys.stdin.isatty():
raise ManualStop("Plane: workspace/API-токен требуют интерактивного ввода")
slug = input(" Введите slug созданного workspace: ").strip()
log(" Plane UI → Workspace Settings → API tokens → выпустите токен.")
token = getpass.getpass(" Вставьте ORCH_PLANE_API_TOKEN (ввод скрыт): ").strip()
ok, hint = _verify_plane_token(plane_port, slug, token)
if not ok:
raise ManualStop(f"Plane: токен/slug не прошли верификацию ({hint})")
update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE,
{"ORCH_PLANE_WORKSPACE_SLUG": slug, "ORCH_PLANE_API_TOKEN": token})
ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
log(" ✓ workspace и ORCH_PLANE_API_TOKEN верифицированы (значения в .env)")
return "ok"
def _psql(sql: str, bundle_env: dict) -> subprocess.CompletedProcess:
"""SQL в plane-db через stdin (секреты не попадают в argv, NFR-3)."""
return _compose(
"exec", "-T", "plane-db", "psql",
"-U", bundle_env.get("POSTGRES_USER", "plane"),
"-d", bundle_env.get("POSTGRES_DB", "plane"),
"-t", "-A", "-v", "ON_ERROR_STOP=1",
input_text=sql, timeout=60,
)
def step_plane_webhook(ctx: dict) -> str:
"""Workspace-webhook Plane→орк. CE не даёт API → ensure прямой записью в
Postgres инсталляции (прогрессивная автоматизация D7: контракт чекпоинта —
та же верификация SELECT'ом); схема — канон LITE_SETUP §5.4 (путь Б)."""
bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
secret = root_env.get("ORCH_PLANE_WEBHOOK_SECRET", "")
slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "")
if not (secret and slug):
raise BootstrapError("нет ORCH_PLANE_WEBHOOK_SECRET/ORCH_PLANE_WORKSPACE_SLUG в .env")
def _exists() -> tuple:
probe = _psql(
f"SELECT count(*) FROM webhooks WHERE url='{WEBHOOK_PLANE_URL}' "
f"AND deleted_at IS NULL;", bundle_env)
ok = probe.returncode == 0 and probe.stdout.strip().isdigit() \
and int(probe.stdout.strip()) > 0
return ok, f"SELECT по webhooks: rc={probe.returncode}"
if _exists()[0]:
log(" workspace-webhook уже зарегистрирован — skip")
return "skipped"
wid = _psql(f"SELECT id FROM workspaces WHERE slug='{slug}';", bundle_env)
workspace_id = wid.stdout.strip().splitlines()[0].strip() if wid.stdout.strip() else ""
if wid.returncode == 0 and workspace_id:
ins = _psql(
"INSERT INTO webhooks (id, created_at, updated_at, deleted_at, "
"workspace_id, url, is_active, secret_key, project, issue, module, "
"cycle, issue_comment, is_internal, version) VALUES "
f"('{uuid.uuid4()}', NOW(), NOW(), NULL, '{workspace_id}', "
f"'{WEBHOOK_PLANE_URL}', true, '{secret}', true, true, false, false, "
"true, false, 'v1');", bundle_env)
if ins.returncode == 0 and _exists()[0]:
log(f" ✓ workspace-webhook зарегистрирован: {WEBHOOK_PLANE_URL}")
return "ok"
log(" прямая регистрация не удалась — честный manual-step (fail-safe)")
manual_checkpoint(
"Plane: workspace-webhook (CE без API)",
[f"Workspace Settings → Webhooks → Add Webhook: URL {WEBHOOK_PLANE_URL},",
"секрет — значение ORCH_PLANE_WEBHOOK_SECRET из корневого .env,",
"события Issue + Issue Comment (канон — LITE_SETUP §5.4)."],
_exists,
)
return "ok"
def _ensure_venv() -> str:
"""Host-venv для onboard-кирпича (канон ONBOARDING; ensure, TR-9)."""
if not os.path.exists(VENV_PY):
proc = _run([sys.executable, "-m", "venv", VENV_DIR], timeout=300)
if proc.returncode != 0:
raise BootstrapError(f"python3 -m venv отказал: {proc.stderr[-500:]}")
probe = _run([VENV_PY, "-c", "import httpx, pydantic"], timeout=60)
if probe.returncode != 0:
log(" ставлю зависимости onboard-кирпича в .venv (requirements.txt)…")
proc = _run([VENV_PY, "-m", "pip", "install", "-q", "-r", REQUIREMENTS],
timeout=1200)
if proc.returncode != 0:
raise BootstrapError(f"pip install отказал: {proc.stderr[-500:]}")
return VENV_PY
def _onboard_env(ctx: dict) -> dict:
"""Окружение onboard-субпроцесса: host-видимые URL bundle-инсталляции
(pydantic env-переменные перекрывают env_file, D7)."""
bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
plane_p, gitea_p = bundle_ports(bundle_env)[1:]
return {
**os.environ,
"ORCH_PLANE_API_URL": f"http://127.0.0.1:{plane_p}",
"ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}",
"ORCH_PLANE_WORKSPACE_SLUG": root_env.get("ORCH_PLANE_WORKSPACE_SLUG", ""),
"ORCH_PLANE_API_TOKEN": root_env.get("ORCH_PLANE_API_TOKEN", ""),
"ORCH_GITEA_URL": f"http://127.0.0.1:{gitea_p}",
"ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}",
"ORCH_GITEA_OWNER": root_env.get("ORCH_GITEA_OWNER", ""),
"ORCH_GITEA_TOKEN": root_env.get("ORCH_GITEA_TOKEN", ""),
"ORCH_GITEA_WEBHOOK_SECRET": root_env.get("ORCH_GITEA_WEBHOOK_SECRET", ""),
}
def _onboard_args(ctx: dict, mode: str) -> list:
a = ctx["args"]
return [
ONBOARD, mode,
"--name", a.sandbox_name, "--repo", a.sandbox_repo,
"--gitea-owner", ctx["root_env"].get("ORCH_GITEA_OWNER", ""),
"--prefix", a.sandbox_prefix, "--stack", a.sandbox_stack,
"--test-cmd", a.sandbox_test_cmd,
"--prod-port", a.sandbox_prod_port, "--staging-port", a.sandbox_staging_port,
"--webhook-url", WEBHOOK_GITEA_URL,
"--env-file", ROOT_ENV, "--json",
]
def step_onboard(ctx: dict) -> str:
"""BR-6/AC-7: статусы/лейблы/репо/вебхуки — СТРОГО onboard_project.py."""
venv_py = _ensure_venv()
env = _onboard_env(ctx)
proc = _run([venv_py, *_onboard_args(ctx, "apply")], env=env, timeout=900)
if proc.returncode not in (0, 2):
raise BootstrapError(f"onboard apply отказал (rc={proc.returncode}): "
f"{proc.stderr[-800:]}")
try:
report = json.loads(proc.stdout)
except ValueError:
raise BootstrapError("onboard apply вернул непарсимый отчёт")
merged = ""
for instr in report.get("instructions", []):
if isinstance(instr, str) and instr.startswith("ORCH_PROJECTS_JSON="):
merged = instr.split("=", 1)[1]
if merged:
update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, {"ORCH_PROJECTS_JSON": merged})
ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
log(" реестр ORCH_PROJECTS_JSON записан в .env (merged-вывод onboard)")
manual = [s for s in report.get("steps", [])
if s.get("status") == "manual-step"
and s.get("id") not in ("plane.workspace-webhook",)]
if manual:
log(" onboard оставил ручные шаги (см. отчёт): "
+ ", ".join(s.get("id", "?") for s in manual))
verify = _run([venv_py, *_onboard_args(ctx, "verify")], env=env, timeout=300)
if verify.returncode == 1:
raise BootstrapError(f"onboard verify отказал: {verify.stderr[-800:]}")
log(f" onboard verify: exit {verify.returncode} "
f"(0 — чисто; 2 — остались ручные пункты отчёта)")
ctx["onboard_manual"] = bool(manual) or verify.returncode == 2
return "ok"
def step_agent_git(ctx: dict) -> str:
"""D8: клон sandbox-репо token-remote ВНУТРИ контейнера орка (origin —
in-network gitea:3000, агенты наследуют его для push/fetch)."""
repo = ctx["args"].sandbox_repo
owner = ctx["root_env"].get("ORCH_GITEA_OWNER", "")
token = ctx["root_env"].get("ORCH_GITEA_TOKEN", "")
probe = _compose("exec", "-T", "orchestrator", "test", "-d",
f"/repos/{repo}/.git", timeout=30)
if probe.returncode == 0:
log(f" /repos/{repo} уже клонирован — skip")
return "skipped"
url = f"{GITEA_INTERNAL_URL.split('://')[0]}://oauth2:{token}@" \
f"{GITEA_INTERNAL_URL.split('://')[1]}/{owner}/{repo}.git"
proc = _compose("exec", "-T", "orchestrator",
"git", "clone", url, f"/repos/{repo}", timeout=300)
if proc.returncode != 0:
raise BootstrapError(
f"клон {repo} в /repos не удался (лог замаскирован): rc={proc.returncode}")
log(f" ✓ /repos/{repo} клонирован (token-remote, TR-7: права локального каталога)")
return "ok"
def step_orch_env(ctx: dict) -> str:
"""D5 шаг 8: корневой .env (канон Lite 1:1) + .env.watchdog; пересоздание
орка/watchdog для подхвата конфига."""
bundle_env = ctx["bundle_env"]
host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
plane_p, gitea_p = bundle_ports(bundle_env)[1:]
overrides = {
# in-network машинные URL (D4) + публичные от BUNDLE_PUBLIC_HOST
"ORCH_PLANE_API_URL": PLANE_INTERNAL_URL,
"ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}",
"ORCH_GITEA_URL": GITEA_INTERNAL_URL,
"ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}",
# когерентность дублируемых ключей — механически (TR-8)
"ORCH_AGENT_HOME_DIR": bundle_env.get("ORCH_AGENT_HOME_DIR", "/home/orchestrator"),
"ORCH_RUN_UID": bundle_env.get("ORCH_RUN_UID", "1000"),
"ORCH_RUN_GID": bundle_env.get("ORCH_RUN_GID", "1000"),
"ORCH_DOCKER_GID": bundle_env.get("ORCH_DOCKER_GID", "999"),
"ORCH_HOST_REPOS_DIR": os.path.join(BUNDLE_DIR, "repos"),
"ORCH_HOST_CLAUDE_CODE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_CODE_DIR", ""),
"ORCH_HOST_NODE_BIN": bundle_env.get("ORCH_HOST_NODE_BIN", ""),
"ORCH_HOST_CLAUDE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_DIR", ""),
"ORCH_HOST_CLAUDE_JSON": bundle_env.get("ORCH_HOST_CLAUDE_JSON", ""),
# деплой-машинерия нашего хоста в bundle структурно спит (D4)
"ORCH_DEPLOY_SSH_HOST": "",
}
update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides)
ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
if not os.path.isfile(WATCHDOG_ENV):
# Telegram-ключи опциональны: пусто = деградация только нотификаций
update_env_file(WATCHDOG_ENV, WATCHDOG_ENV_EXAMPLE, {})
proc = _compose("up", "-d", "--force-recreate",
"orchestrator", "orchestrator-watchdog", timeout=600)
if proc.returncode != 0:
raise BootstrapError(f"пересоздание орка/watchdog отказало:\n{proc.stderr[-1000:]}")
log(" ✓ орк и watchdog пересозданы с собранным конфигом")
return "ok"
def step_health(ctx: dict) -> str:
orch_p = bundle_ports(ctx["bundle_env"])[0]
failures = []
for path in ("/health", "/queue", "/metrics"):
url = f"http://127.0.0.1:{orch_p}{path}"
status, body = None, ""
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
status, body = _http(url, timeout=5)
if status == 200:
break
time.sleep(3)
ok = status == 200
if path != "/health" and ok:
try:
json.loads(body)
except ValueError:
ok = False
log(f" GET {path}{'PASS' if ok else f'FAIL (HTTP {status})'}")
if not ok:
failures.append(path)
if failures:
raise BootstrapError(f"health-контракты не зелёные: {', '.join(failures)}")
return "ok"
APPLY_STEPS = (
("preflight", step_preflight),
("secrets", step_secrets),
("up", step_up),
("init-gitea", step_init_gitea),
("init-plane", step_init_plane),
("plane-webhook", step_plane_webhook),
("onboard", step_onboard),
("agent-git", step_agent_git),
("orch-env", step_orch_env),
("health", step_health),
)
# --------------------------------------------------------------------------- #
# Режимы
# --------------------------------------------------------------------------- #
def _load_ctx(args: argparse.Namespace) -> dict:
bundle_env = parse_env(open(BUNDLE_ENV, encoding="utf-8").read()) \
if os.path.isfile(BUNDLE_ENV) else {}
root_env = parse_env(open(ROOT_ENV, encoding="utf-8").read()) \
if os.path.isfile(ROOT_ENV) else {}
return {"args": args, "bundle_env": bundle_env, "root_env": root_env,
"results": {}}
def run_plan(ctx: dict) -> int:
log("== bootstrap_bundle: план apply (ноль мутаций) ==")
for i, (name, summary) in enumerate(build_plan(), 1):
log(f" {i}. {name:<14} {summary}")
facts = collect_facts(ctx["bundle_env"])
blockers, warnings, resume = preflight_verdict(facts)
log("\n-- preflight-диагностика (read-only):")
for w in warnings:
log(f"{w}")
for b in blockers:
log(f"{b}")
if resume:
log(" найдены тома/контейнеры orchestrator-bundle: apply пойдёт в ensure-режиме")
if not blockers:
log(" ✓ предусловия хоста выполнены — запускайте: "
"python3 scripts/bootstrap_bundle.py apply")
return EXIT_OK
log(f" итог: {len(blockers)} блокеров — устраните и повторите (канон — {DOC})")
return EXIT_MANUAL
def run_apply(ctx: dict) -> int:
log("== bootstrap_bundle: apply ==")
for name, fn in APPLY_STEPS:
log(f"\n→ шаг {name}")
status = fn(ctx)
ctx["results"][name] = status
log("\n== итоговая сводка ==")
for name, _ in APPLY_STEPS:
log(f" [{ctx['results'].get(name, ''):>8}] {name}")
if ctx.get("onboard_manual"):
log("\n🖐 Остались ручные пункты onboard-отчёта (порядок статусов на доске и т.п.)")
log(" Выполните их и перезапустите verify. Exit 2 (незавершённые шаги).")
return EXIT_MANUAL
log(f"\n✓ Bundled-инсталляция готова. Следующий шаг — smoke: {DOC} §11 "
"(чек-лист REPLICATION.md §4).")
return EXIT_OK
def run_verify(ctx: dict) -> int:
"""Read-only пост-проверка: health-контракты + onboard verify."""
log("== bootstrap_bundle: verify (read-only) ==")
orch_p = bundle_ports(ctx["bundle_env"])[0]
failed = False
for path in ("/health", "/queue", "/metrics"):
status, _ = _http(f"http://127.0.0.1:{orch_p}{path}", timeout=10)
ok = status == 200
failed = failed or not ok
log(f" GET {path}{'PASS' if ok else f'FAIL (HTTP {status})'}")
if os.path.exists(VENV_PY) and ctx["root_env"].get("ORCH_PLANE_API_TOKEN"):
proc = _run([VENV_PY, *_onboard_args(ctx, "verify")],
env=_onboard_env(ctx), timeout=300)
log(f" onboard verify → exit {proc.returncode}")
failed = failed or proc.returncode == 1
if proc.returncode == 2:
return EXIT_MANUAL
else:
log(" onboard verify пропущен (нет .venv или ORCH_PLANE_API_TOKEN) → exit 2")
return EXIT_MANUAL
return EXIT_ERROR if failed else EXIT_OK
def main(argv: list | None = None) -> int:
args = build_arg_parser().parse_args(argv)
ctx = _load_ctx(args)
try:
if args.mode == "plan":
return run_plan(ctx)
if args.mode == "verify":
return run_verify(ctx)
return run_apply(ctx)
except ManualStop as e:
log(f"\n🖐 ОСТАНОВКА (exit {EXIT_MANUAL}): {e}")
log(" Выполните шаг и перезапустите apply — завершённые шаги будут пропущены.")
return EXIT_MANUAL
except BootstrapError as e:
log(f"\n✗ ОШИБКА (exit {EXIT_ERROR}): {e}")
return EXIT_ERROR
except KeyboardInterrupt:
log(f"\n✗ прервано оператором (exit {EXIT_ERROR})")
return EXIT_ERROR
if __name__ == "__main__":
sys.exit(main())