#!/usr/bin/env python3 """onboard_project.py — операторский turnkey-CLI онбординга нового проекта (ORCH-009). Разворачивает все слои подключения нового проекта к оркестратору одним проходом: Plane-проект (22 статуса с точными каноническими именами + лейблы) → Gitea-репо (+per-repo webhook) → материализация onboarding-kit (рендер плейсхолдеров + live-copy канона docs/_templates|_standards) + initial push ТОЛЬКО в свежесозданный пустой репо → merged-вывод ``ORCH_PROJECTS_JSON`` (round-trip через фактический парсер реестра). Полный процесс — runbook ``docs/operations/ONBOARDING.md``. Режимы (ADR-001 D11 ORCH-009): plan — дефолт; только GET-пробы + полный план; НИ ОДНОЙ мутации (ни сети, ни диска). apply — идемпотентный ensure: существующее → ``skipped(exists)``; delete-операций НЕТ. verify — GET-пробы + локальные проверки (реестр, статусы, лейблы, webhook, полнота kit). Гарантии безопасности (NFR-2/NFR-3): * прод-контейнер оркестратора НИКОГДА не трогается (ни рестартов, ни остановок); * ``.env`` НИКОГДА не правится (читается read-only); регистрация = операторский шаг; * push возможен ТОЛЬКО в свежесозданный/пустой репо — в существующие никогда; * ничего не удаляется; секреты в отчёте маскируются. Запуск — из корня чекаута репо orchestrator (см. runbook): python3 scripts/onboard_project.py plan --name "My Project" --repo my-project \\ --prefix MP --stack "Python 3.12 + FastAPI" --test-cmd "pytest -q" \\ --prod-port 8600 --staging-port 8601 --webhook-url https:///webhook/gitea Exit-коды: 0 — чисто; 2 — есть manual-step / gap в verify; 1 — ошибка. """ import argparse import dataclasses import json import logging import os import re import secrets as _secrets import subprocess import sys import tempfile import urllib.parse # Запуск из корня чекаута (паттерн scripts/staging_check.py): добавляем корень в # sys.path, чтобы работали read-only импорты из src. _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) # ── Закрытый список read-only импортов из src (ORCH-009 ADR-001 D4) ────────── # Любой новый импорт из src — ТОЛЬКО через обновление ADR (контроль — # tests/test_onboarding_invariants.py::test_tc21_cli_src_imports_stay_in_closed_list). from src.config import settings # noqa: E402 # имена лейблов, URL/токены, реестр from src.plane_sync import _PLANE_NAME_TO_KEY # noqa: E402 # точные имена 22 статусов (нулевой дрейф) from src.projects import _parse_projects_json # noqa: E402 # round-trip валидация реестра try: # httpx — существующая зависимость проекта; нужен только реальным клиентам import httpx except Exception: # pragma: no cover - тесты инжектируют фейки, httpx не обязателен httpx = None logger = logging.getLogger("onboard_project") ONBOARDING_DIR = os.path.join(_REPO_ROOT, "onboarding") SKELETON_DIR = os.path.join(ONBOARDING_DIR, "repo-skeleton") PLACEHOLDERS_PATH = os.path.join(ONBOARDING_DIR, "placeholders.json") RUNBOOK = "docs/operations/ONBOARDING.md" # Live-copy канона (BR-2/D3): копируются из чекаута орка, в kit НЕ хранятся. LIVE_COPY_DIRS = ("docs/_templates", "docs/_standards") # Per-repo Gitea-webhook (формат docs/operations/SETUP_WEBHOOKS.md). WEBHOOK_EVENTS = ["push", "pull_request", "status"] # Синтаксис плейсхолдеров (D2): {{NAME}}, верхний регистр. PLACEHOLDER_RE = re.compile(r"\{\{[A-Z][A-Z0-9_]*\}\}") # Канонические группы статусов Plane (ADR-001 D5 ORCH-009). Код-критичные # констрейнты: STOP — группа `cancelled` (fail-closed ветка отмены, ORCH-090); # в терминальных группах (`completed`/`cancelled`) ТОЛЬКО Done/Cancelled/STOP — # иначе terminal-detection (ORCH-068, {uuid→group}) ложно сочтёт живую задачу # терминальной. Имена ключей — байт-в-байт из plane_sync._PLANE_NAME_TO_KEY. STATE_GROUPS: dict[str, str] = { "Backlog": "backlog", "Todo": "unstarted", "To Analyse": "unstarted", "In Progress": "started", "Analysis": "started", "Architecture": "started", "Development": "started", "Code-Review": "started", "Review": "started", "Testing": "started", "Awaiting Deploy": "started", "Deploying": "started", "Monitoring after Deploy": "started", "Needs Input": "started", "In Review": "started", "Blocked": "started", "Approved": "started", "Confirm Deploy": "started", "Rejected": "started", # reject = rework-петля, задача жива → НЕ cancelled "Done": "completed", "Cancelled": "cancelled", "STOP": "cancelled", } # Статусы шагов отчёта (D11). PLANNED = "planned" CREATED = "created" SKIPPED = "skipped(exists)" MANUAL = "manual-step" ERROR = "error" OK = "ok" GAP = "gap" class ManualStep(Exception): """API-шаг недоступен (403/404/405/501/не реализовано в CE) → ручной пункт runbook.""" def label_names() -> list[str]: """Имена лейблов авто-режимов/багфикс-трека — из конфига (D4), не литералы.""" return [ settings.auto_approve_label, settings.auto_deploy_label, settings.bug_fast_track_label, ] # --------------------------------------------------------------------------- # # Отчёт # --------------------------------------------------------------------------- # @dataclasses.dataclass class Step: """Один шаг плана/исполнения/верификации.""" id: str description: str status: str detail: str = "" @dataclasses.dataclass class Report: """Итоговый отчёт прогона: шаги + операторские инструкции + exit-код.""" mode: str steps: list = dataclasses.field(default_factory=list) instructions: list = dataclasses.field(default_factory=list) def add(self, step_id: str, description: str, status: str, detail: str = "") -> Step: step = Step(step_id, description, status, detail) self.steps.append(step) logger.info("[%s] %s — %s%s", self.mode, step_id, status, f" ({detail})" if detail else "") return step @property def exit_code(self) -> int: statuses = {s.status for s in self.steps} if ERROR in statuses: return 1 if MANUAL in statuses or GAP in statuses: return 2 return 0 def to_dict(self) -> dict: totals = {"created": 0, "skipped": 0, "manual": 0, "planned": 0, "error": 0, "ok": 0, "gap": 0} key_by_status = { CREATED: "created", SKIPPED: "skipped", MANUAL: "manual", PLANNED: "planned", ERROR: "error", OK: "ok", GAP: "gap", } for s in self.steps: totals[key_by_status.get(s.status, "error")] += 1 return { "mode": self.mode, "steps": [dataclasses.asdict(s) for s in self.steps], "totals": totals, "instructions": list(self.instructions), "exit_code": self.exit_code, } def render_text(self) -> str: lines = [f"== onboarding report ({self.mode}) =="] for s in self.steps: lines.append(f" [{s.status:>15}] {s.id:<28} {s.description}") if s.detail: lines.append(f" {'':>17} ↳ {s.detail}") t = self.to_dict()["totals"] lines.append( f"-- totals: created={t['created']} skipped={t['skipped']} " f"manual={t['manual']} planned={t['planned']} ok={t['ok']} " f"gap={t['gap']} error={t['error']}" ) if self.instructions: lines.append("-- операторские шаги:") for i, instr in enumerate(self.instructions, 1): lines.append(f" {i}. {instr}") lines.append(f"-- exit code: {self.exit_code}") return "\n".join(lines) # --------------------------------------------------------------------------- # # Чистое ядро: словарь, рендер, kit # --------------------------------------------------------------------------- # def load_placeholders() -> dict: """Словарь плейсхолдеров onboarding/placeholders.json (single source of truth).""" with open(PLACEHOLDERS_PATH, encoding="utf-8") as f: return json.load(f) def render(text: str, params: dict) -> str: """Тупой проход str.replace по словарю (D2) — без шаблонизаторов и логики.""" for name, value in params.items(): text = text.replace("{{" + name + "}}", str(value)) return text def find_unresolved(text: str) -> list[str]: """Все неразрешённые {{PLACEHOLDER}} в тексте (обязательный пост-скан, D2).""" return sorted(set(PLACEHOLDER_RE.findall(text))) def iter_skeleton_files() -> list[str]: """Относительные пути всех файлов repo-skeleton (отсортированы, детерминизм).""" out: list[str] = [] for root, _dirs, files in os.walk(SKELETON_DIR): for name in files: full = os.path.join(root, name) out.append(os.path.relpath(full, SKELETON_DIR)) return sorted(out) def render_kit_in_memory(params: dict) -> dict: """Рендер всех файлов kit В ПАМЯТИ (plan-режим: ноль записей на диск, AC-8).""" rendered: dict[str, str] = {} for rel in iter_skeleton_files(): with open(os.path.join(SKELETON_DIR, rel), encoding="utf-8") as f: rendered[rel.replace(os.sep, "/")] = render(f.read(), params) return rendered def materialize_kit(params: dict, dest_dir: str) -> list[str]: """Материализовать kit на диск: рендер скелета + live-copy канона (apply-only). Возвращает список записанных относительных путей. Неразрешённые плейсхолдеры после рендера → ValueError (PASS-условие AC-5). Существующие файлы в dest_dir не перезаписываются (идемпотентность BR-9 при повторной материализации). """ written: list[str] = [] rendered = render_kit_in_memory(params) problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))} if problems: raise ValueError(f"unresolved placeholders after render: {problems}") for rel, content in rendered.items(): target = os.path.join(dest_dir, *rel.split("/")) if os.path.exists(target): continue # не перезаписываем существующее (BR-9) os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, "w", encoding="utf-8") as f: f.write(content) written.append(rel) # Live-copy канона байт-в-байт из чекаута орка (BR-2/D3). for rel_dir in LIVE_COPY_DIRS: src_dir = os.path.join(_REPO_ROOT, *rel_dir.split("/")) if not os.path.isdir(src_dir): raise FileNotFoundError(f"live canon dir missing in checkout: {rel_dir}") for root, _dirs, files in os.walk(src_dir): for name in files: src_file = os.path.join(root, name) rel = os.path.join(rel_dir, os.path.relpath(src_file, src_dir)).replace(os.sep, "/") target = os.path.join(dest_dir, *rel.split("/")) if os.path.exists(target): continue os.makedirs(os.path.dirname(target), exist_ok=True) with open(src_file, encoding="utf-8") as fsrc, open(target, "w", encoding="utf-8") as fdst: fdst.write(fsrc.read()) written.append(rel) return written # --------------------------------------------------------------------------- # # Реестр (D7) # --------------------------------------------------------------------------- # def build_registry_entry(params: dict) -> dict: """Запись реестра нового проекта (контракт src/projects.py::ProjectConfig).""" return { "plane_project_id": str(params["PLANE_PROJECT_ID"]), "repo": str(params["REPO"]), "work_item_prefix": str(params["WORK_ITEM_PREFIX"]), "name": str(params["PROJECT_NAME"]), } def merged_projects_json(entry: dict, existing_raw: str) -> tuple[str, str]: """(standalone-запись, полный merged-массив) с round-trip через фактический парсер. D7: существующие записи verbatim + новая в конец; уже зарегистрированный проект (тот же plane_project_id или repo) не дублируется. Несоответствие round-trip → RuntimeError (никогда не отдаём оператору строку, которую парсер отвергнет). """ try: existing = json.loads(existing_raw) if existing_raw and existing_raw.strip() else [] except (ValueError, TypeError): existing = [] if not isinstance(existing, list): existing = [] already = any( isinstance(item, dict) and ( item.get("plane_project_id") == entry["plane_project_id"] or item.get("repo") == entry["repo"] ) for item in existing ) merged_list = list(existing) if already else list(existing) + [entry] merged = json.dumps(merged_list, ensure_ascii=False) parsed = _parse_projects_json(merged) if parsed is None: raise RuntimeError("merged ORCH_PROJECTS_JSON failed the registry parser round-trip") wanted = [p for p in parsed if p.plane_project_id == entry["plane_project_id"] or p.repo == entry["repo"]] if not already: if not wanted: raise RuntimeError("new registry entry lost in the round-trip") got = wanted[0] if ( got.repo != entry["repo"] or got.work_item_prefix != entry["work_item_prefix"] or got.name != entry["name"] ): raise RuntimeError("registry round-trip distorted the new entry fields") if len(parsed) < len([i for i in existing if isinstance(i, dict)]): raise RuntimeError("registry round-trip lost existing entries") return json.dumps(entry, ensure_ascii=False), merged def read_existing_registry(env_file: str | None = None) -> str: """Существующие записи реестра: env (settings) > --env-file/.env (read-only).""" raw = getattr(settings, "projects_json", "") or "" if raw.strip(): return raw path = env_file or os.path.join(_REPO_ROOT, ".env") if os.path.isfile(path): try: with open(path, encoding="utf-8") as f: # read-only: .env НИКОГДА не пишем (NFR-2) for line in f: line = line.strip() if line.startswith("ORCH_PROJECTS_JSON="): return line.split("=", 1)[1] except OSError as e: logger.warning("cannot read %s: %s", path, e) return "" # --------------------------------------------------------------------------- # # Тонкие клиенты — единственные точки сети (инжектируются, в тестах фейки) # --------------------------------------------------------------------------- # class PlaneClient: """Тонкий клиент Plane API (паттерн URL — src/plane_sync.py).""" def __init__(self, base_url: str, token: str, workspace: str): self.base = f"{base_url.rstrip('/')}/api/v1" self.headers = {"X-API-Key": token} self.ws = workspace def _get(self, url: str): resp = httpx.get(url, headers=self.headers, timeout=15) if resp.status_code == 200: return resp.json() if resp.status_code in (403, 404, 405, 501): return None resp.raise_for_status() return None def _post(self, url: str, payload: dict) -> dict: resp = httpx.post(url, headers=self.headers, json=payload, timeout=20) if resp.status_code in (200, 201): return resp.json() if resp.status_code in (401, 403, 404, 405, 501): raise ManualStep(f"Plane API refused POST ({resp.status_code}): {url}") resp.raise_for_status() raise ManualStep(f"Plane API unexpected status {resp.status_code}") def get_project(self, project_id: str): if not project_id: return None return self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/") def find_project_by_identifier(self, identifier: str): data = self._get(f"{self.base}/workspaces/{self.ws}/projects/") results = data.get("results", data) if isinstance(data, dict) else data if not isinstance(results, list): return None for item in results: if isinstance(item, dict) and item.get("identifier") == identifier: return item return None def list_states(self, project_id: str): data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/") if data is None: return None return data.get("results", data) if isinstance(data, dict) else data def list_labels(self, project_id: str): data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/") if data is None: return None return data.get("results", data) if isinstance(data, dict) else data def create_project(self, name: str, identifier: str) -> dict: return self._post( f"{self.base}/workspaces/{self.ws}/projects/", {"name": name, "identifier": identifier}, ) def create_state(self, project_id: str, name: str, group: str) -> dict: return self._post( f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/", {"name": name, "group": group}, ) def create_label(self, project_id: str, name: str) -> dict: return self._post( f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/", {"name": name}, ) class GiteaClient: """Тонкий клиент Gitea API (паттерн URL — src/gitea.py, src/merge_gate.py).""" def __init__(self, base_url: str, token: str): self.base = f"{base_url.rstrip('/')}/api/v1" self.headers = {"Authorization": f"token {token}"} def _get(self, url: str): resp = httpx.get(url, headers=self.headers, timeout=15) if resp.status_code == 200: return resp.json() if resp.status_code == 404: return None if resp.status_code in (401, 403, 405, 501): return None resp.raise_for_status() return None def _post(self, url: str, payload: dict) -> dict: resp = httpx.post(url, headers=self.headers, json=payload, timeout=20) if resp.status_code in (200, 201): return resp.json() if resp.status_code in (401, 403, 404, 405, 409, 501): raise ManualStep(f"Gitea API refused POST ({resp.status_code}): {url}") resp.raise_for_status() raise ManualStep(f"Gitea API unexpected status {resp.status_code}") def get_repo(self, owner: str, repo: str): return self._get(f"{self.base}/repos/{owner}/{repo}") def list_hooks(self, owner: str, repo: str): return self._get(f"{self.base}/repos/{owner}/{repo}/hooks") or [] def create_repo(self, owner: str, name: str, description: str = "") -> dict: # auto_init=False: репо рождается ПУСТЫМ; `main` создаёт initial push (D6). payload = {"name": name, "description": description, "auto_init": False, "private": False} try: return self._post(f"{self.base}/orgs/{owner}/repos", payload) except ManualStep: # owner — не организация → личный namespace токена / admin-маршрут. return self._post(f"{self.base}/user/repos", payload) def create_hook(self, owner: str, repo: str, url: str, secret: str, events: list) -> dict: return self._post( f"{self.base}/repos/{owner}/{repo}/hooks", { "type": "gitea", "active": True, "branch_filter": "*", "events": list(events), "config": {"url": url, "content_type": "json", "secret": secret}, }, ) def get_file_text(self, owner: str, repo: str, path: str): data = self._get( f"{self.base}/repos/{owner}/{repo}/raw/{urllib.parse.quote(path)}?ref=main" ) return data if isinstance(data, str) else None def list_dir(self, owner: str, repo: str, path: str): data = self._get( f"{self.base}/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}?ref=main" ) if not isinstance(data, list): return None return sorted(item.get("name", "") for item in data if isinstance(item, dict)) # --------------------------------------------------------------------------- # # git: initial push ТОЛЬКО в свежесозданный пустой репо (D6) # --------------------------------------------------------------------------- # def default_git_runner(cmd: list, cwd: str) -> int: """Единственная точка subprocess: git в каталоге материализации kit.""" masked = [re.sub(r"://[^@/]+@", "://***@", part) for part in cmd] logger.info("git: %s (cwd=%s)", " ".join(masked), cwd) return subprocess.run(cmd, cwd=cwd, check=False).returncode # noqa: S603 def _push_url(gitea_url: str, token: str, owner: str, repo: str) -> str: parts = urllib.parse.urlsplit(gitea_url) netloc = f"oauth2:{token}@{parts.netloc}" if token else parts.netloc return urllib.parse.urlunsplit( (parts.scheme, netloc, f"{parts.path.rstrip('/')}/{owner}/{repo}.git", "", "") ) def initial_push(workdir: str, owner: str, repo: str, git_runner) -> bool: """git init/commit/push материализованного kit в пустой репо. True = успех.""" url = _push_url(settings.gitea_url, settings.gitea_token, owner, repo) commands = [ ["git", "init", "-q", "-b", "main"], ["git", "add", "-A"], [ "git", "-c", "user.email=onboarding@orchestrator.local", "-c", "user.name=orchestrator-onboarding", "commit", "-q", "-m", "feat: onboarding skeleton (ORCH-009 kit)", ], ["git", "remote", "add", "origin", url], ["git", "push", "-q", "-u", "origin", "main"], ] for cmd in commands: rc = git_runner(cmd, workdir) if rc not in (0, None): logger.error("git step failed (rc=%s): %s", rc, cmd[0:2]) return False return True # --------------------------------------------------------------------------- # # Наблюдение текущего состояния (GET-only) и чистый план # --------------------------------------------------------------------------- # @dataclasses.dataclass class Observed: """Снимок текущего состояния внешних систем (только GET-пробы).""" project: dict | None = None states: list | None = None labels: list | None = None repo: dict | None = None hooks: list = dataclasses.field(default_factory=list) def observe(params: dict, plane, gitea) -> Observed: """GET-пробы Plane/Gitea; ошибки чтения → None (план пометит шаг).""" obs = Observed() pid = str(params.get("PLANE_PROJECT_ID") or "") try: if pid and not pid.startswith("<"): obs.project = plane.get_project(pid) if obs.project is None: obs.project = plane.find_project_by_identifier(params["WORK_ITEM_PREFIX"]) except Exception as e: # GET-проба не должна валить прогон logger.warning("plane project probe failed: %s", e) project_id = (obs.project or {}).get("id") or pid if obs.project is not None and project_id: try: obs.states = plane.list_states(project_id) except Exception as e: logger.warning("plane states probe failed: %s", e) try: obs.labels = plane.list_labels(project_id) except Exception as e: logger.warning("plane labels probe failed: %s", e) try: obs.repo = gitea.get_repo(params["GITEA_OWNER"], params["REPO"]) if obs.repo is not None: obs.hooks = gitea.list_hooks(params["GITEA_OWNER"], params["REPO"]) or [] except Exception as e: logger.warning("gitea probe failed: %s", e) return obs def _existing_names(items, key: str = "name") -> set: return { str(item.get(key, "")).strip() for item in (items or []) if isinstance(item, dict) } def _hook_exists(hooks: list, webhook_url: str) -> bool: for hook in hooks or []: config = hook.get("config", {}) if isinstance(hook, dict) else {} if config.get("url") == webhook_url: return True return False def build_plan(params: dict, observed: Observed, webhook_url: str) -> list: """Чистая функция: упорядоченный план шагов закрытого списка BR-1 (без I/O).""" steps: list[Step] = [] def add(step_id, description, status, detail=""): steps.append(Step(step_id, description, status, detail)) # 1. Plane: проект if observed.project is not None: add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED, f"id={observed.project.get('id', '?')}") else: add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", PLANNED, f"identifier={params['WORK_ITEM_PREFIX']}") # 2. Plane: 22 статуса с точными именами (источник — _PLANE_NAME_TO_KEY, D5) existing_states = _existing_names(observed.states) for name in _PLANE_NAME_TO_KEY: # порядок словаря = канонический порядок кода group = STATE_GROUPS[name] if name in existing_states: add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}") else: add(f"plane.state:{name}", f"статус «{name}»", PLANNED, f"group={group}") # 3. Plane: лейблы (имена из конфига, D4) existing_labels = _existing_names(observed.labels) for label in label_names(): status = SKIPPED if label in existing_labels else PLANNED add(f"plane.label:{label}", f"лейбл «{label}»", status) # Заведомо ручные шаги Plane (D5): UI-only / уже существующее. add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL, f"UI-only шаг — см. {RUNBOOK}") add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL, f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}") # 4. Gitea: репо if observed.repo is not None: add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED, f"empty={observed.repo.get('empty')}") else: add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", PLANNED, "auto_init=false (пустой; main создаст initial push)") # 5. Gitea: per-repo webhook if _hook_exists(observed.hooks, webhook_url): add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}") else: add("gitea.webhook", "per-repo webhook", PLANNED, f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; " "HMAC secret — глобальный из env, в гит не попадает") # 6. Kit: материализация + initial push (только пустой/свежий репо, D6) repo_exists = observed.repo is not None repo_empty = bool((observed.repo or {}).get("empty")) if not repo_exists or repo_empty: add("kit.materialize", "материализация kit (рендер + live-copy канона)", PLANNED, f"{len(iter_skeleton_files())} файлов скелета + {'/'.join(LIVE_COPY_DIRS)}") add("kit.push", "initial push kit в свежесозданный пустой репо", PLANNED, "git init/commit/push -> main (единственный разрешённый push)") else: add("kit.materialize", "материализация kit", MANUAL, f"репо непустой — kit-файлы НИКОГДА не пушатся поверх существующего контента; см. {RUNBOOK}") add("kit.push", "initial push kit", MANUAL, f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}") # 7. Реестр: merged-вывод (применение env + рестарт — операторский шаг, D7) add("registry.emit", "запись реестра ORCH_PROJECTS_JSON (merged-вывод)", PLANNED, "применение строки в .env + управляемый рестарт — ОПЕРАТОРСКИЙ шаг") return steps # --------------------------------------------------------------------------- # # Режимы # --------------------------------------------------------------------------- # def _registry_instructions(report: Report, params: dict, env_file: str | None) -> None: """Standalone + merged вывод реестра в инструкции отчёта (D7).""" try: entry = build_registry_entry(params) standalone, merged = merged_projects_json(entry, read_existing_registry(env_file)) report.instructions.append( "Добавь/обнови строку в .env оркестратора (полный merged-массив, атомарно): " f"ORCH_PROJECTS_JSON={merged}" ) report.instructions.append(f"Standalone-запись нового проекта (справочно): {standalone}") report.instructions.append( "После правки .env выполни УПРАВЛЯЕМЫЙ рестарт оркестратора (операторский шаг, " f"групповое окно для всех проектов) — см. {RUNBOOK}." ) except (RuntimeError, KeyError, ValueError) as e: report.add("registry.emit", "запись реестра", ERROR, str(e)) def run_plan(params: dict, plane, gitea, webhook_url: str, webhook_secret: str | None = None) -> Report: """Режим plan: GET-пробы + полный план; НИ ОДНОЙ мутации (сеть/диск) — AC-8.""" report = Report(mode="plan") observed = observe(params, plane, gitea) for step in build_plan(params, observed, webhook_url): report.steps.append(step) # Рендер-проверка строго в памяти: ловим неразрешённые плейсхолдеры ДО apply. rendered = render_kit_in_memory(params) problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))} if problems: report.add("kit.render-check", "скан неразрешённых плейсхолдеров", ERROR, str(problems)) else: report.add("kit.render-check", "скан неразрешённых плейсхолдеров", OK, f"{len(rendered)} файлов отрендерено в памяти, чисто") _registry_instructions(report, params, None) if webhook_secret is None and not settings.gitea_webhook_secret: report.instructions.append( "ORCH_GITEA_WEBHOOK_SECRET отсутствует в env — apply сгенерирует и выведет его " "для .env (секрет в гит не попадает)." ) return report def run_apply( params: dict, plane, gitea, webhook_url: str, git_runner=default_git_runner, workdir: str | None = None, webhook_secret: str | None = None, env_file: str | None = None, ) -> Report: """Режим apply: идемпотентный ensure (BR-9). Существующее → skipped; delete нет.""" report = Report(mode="apply") observed = observe(params, plane, gitea) # 1. Plane: проект project = observed.project if project is not None: report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED, f"id={project.get('id', '?')}") else: try: project = plane.create_project(params["PROJECT_NAME"], params["WORK_ITEM_PREFIX"]) report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", CREATED, f"id={project.get('id', '?')}") except ManualStep as e: report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", MANUAL, f"{e}; создай проект в UI и перезапусти apply с --plane-project-id; см. {RUNBOOK}") except Exception as e: report.add("plane.project", "Plane-проект", ERROR, str(e)) project_id = (project or {}).get("id") or str(params.get("PLANE_PROJECT_ID") or "") if project_id and not str(project_id).startswith("<"): params = dict(params, PLANE_PROJECT_ID=str(project_id)) # 2. Plane: статусы (точные имена + группы, D5) existing_states = _existing_names(observed.states) if project is None: for name in _PLANE_NAME_TO_KEY: report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL, f"нет проекта — создай статусы вручную по таблице runbook; см. {RUNBOOK}") else: for name in _PLANE_NAME_TO_KEY: group = STATE_GROUPS[name] if name in existing_states: report.add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}") continue try: plane.create_state(project_id, name, group) report.add(f"plane.state:{name}", f"статус «{name}»", CREATED, f"group={group}") except ManualStep as e: report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL, f"{e}; создай вручную (group={group}); см. {RUNBOOK}") except Exception as e: report.add(f"plane.state:{name}", f"статус «{name}»", ERROR, str(e)) # 3. Plane: лейблы existing_labels = _existing_names(observed.labels) if project is None: for label in label_names(): report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL, f"нет проекта — создай лейбл вручную; см. {RUNBOOK}") else: for label in label_names(): if label in existing_labels: report.add(f"plane.label:{label}", f"лейбл «{label}»", SKIPPED) continue try: plane.create_label(project_id, label) report.add(f"plane.label:{label}", f"лейбл «{label}»", CREATED) except ManualStep as e: report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL, f"{e}; создай вручную; см. {RUNBOOK}") except Exception as e: report.add(f"plane.label:{label}", f"лейбл «{label}»", ERROR, str(e)) # Заведомо ручные шаги Plane (D5). report.add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL, f"UI-only шаг — см. {RUNBOOK}") report.add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL, f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}") # 4. Gitea: репо repo = observed.repo freshly_created = False if repo is not None: report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED, f"empty={repo.get('empty')}") else: try: repo = gitea.create_repo(params["GITEA_OWNER"], params["REPO"], str(params.get("PROJECT_DESCRIPTION", ""))) freshly_created = True report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", CREATED, "auto_init=false (пустой)") except ManualStep as e: report.add("gitea.repo", "Gitea-репо", MANUAL, f"{e}; см. {RUNBOOK}") except Exception as e: report.add("gitea.repo", "Gitea-репо", ERROR, str(e)) # 5. Gitea: webhook (секрет переиспользуется из env — D6; в отчёте маскируется) secret = webhook_secret if webhook_secret is not None else settings.gitea_webhook_secret generated = False if not secret: secret = _secrets.token_hex(20) generated = True if repo is None: report.add("gitea.webhook", "per-repo webhook", MANUAL, f"нет репо — создай webhook вручную; см. {RUNBOOK}") elif _hook_exists(observed.hooks, webhook_url): report.add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}") else: try: gitea.create_hook(params["GITEA_OWNER"], params["REPO"], webhook_url, secret, WEBHOOK_EVENTS) report.add("gitea.webhook", "per-repo webhook", CREATED, f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; secret=***") except ManualStep as e: report.add("gitea.webhook", "per-repo webhook", MANUAL, f"{e}; см. {RUNBOOK}") except Exception as e: report.add("gitea.webhook", "per-repo webhook", ERROR, str(e)) if generated: report.instructions.append( "Сгенерирован новый HMAC-секрет webhook — добавь в .env оркестратора строку: " f"ORCH_GITEA_WEBHOOK_SECRET={secret} (в гит НЕ коммитить)." ) # 6. Kit: материализация + initial push ТОЛЬКО в пустой/свежесозданный репо (D6) repo_empty = freshly_created or bool((repo or {}).get("empty")) if repo is not None and repo_empty: try: dest = workdir or tempfile.mkdtemp(prefix="onboard-kit-") written = materialize_kit(params, dest) report.add("kit.materialize", "материализация kit", CREATED, f"{len(written)} файлов -> {dest}") if initial_push(dest, params["GITEA_OWNER"], params["REPO"], git_runner): report.add("kit.push", "initial push kit в пустой репо", CREATED, "git push origin main (единственный разрешённый push)") else: report.add("kit.push", "initial push kit", ERROR, f"git-команда вернула ненулевой код; материализованный kit остался в {dest}") except (ValueError, FileNotFoundError) as e: report.add("kit.materialize", "материализация kit", ERROR, str(e)) elif repo is not None: report.add("kit.materialize", "материализация kit", MANUAL, f"репо непустой — kit поверх существующего контента не пушится (BR-9); см. {RUNBOOK}") report.add("kit.push", "initial push kit", MANUAL, f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}") else: report.add("kit.materialize", "материализация kit", MANUAL, f"нет репо — повтори apply после создания репо; см. {RUNBOOK}") report.add("kit.push", "initial push kit", MANUAL, f"нет репо; см. {RUNBOOK}") # 7. Реестр (вывод инструкций; .env скрипт НЕ правит — NFR-2) report.add("registry.emit", "запись реестра ORCH_PROJECTS_JSON", CREATED, "merged-массив выведен в инструкции; применение env + рестарт — операторский шаг") _registry_instructions(report, params, env_file) return report # Полнота kit в verify: ключевые файлы, обязанные лежать в репо (FR-5). VERIFY_KIT_FILES = ( ".openclaw/agents/analyst.md", ".openclaw/agents/architect.md", ".openclaw/agents/developer.md", ".openclaw/agents/reviewer.md", ".openclaw/agents/tester.md", ".openclaw/agents/deployer.md", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md", "README.md", "CHANGELOG.md", ".env.example", "docs/operations/INFRA.md", ) def run_verify( params: dict, plane, gitea, webhook_url: str, projects_raw: str | None = None, env_file: str | None = None, ) -> Report: """Режим verify: GET-пробы + локальные проверки (FR-5). Ничего не мутирует.""" report = Report(mode="verify") observed = observe(params, plane, gitea) # 1. Реестр: round-trip фактическим парсером + наличие записи проекта. raw = projects_raw if projects_raw is not None else read_existing_registry(env_file) parsed = _parse_projects_json(raw or "") entry = None for p in parsed or []: if p.repo == params["REPO"] or p.plane_project_id == str(params.get("PLANE_PROJECT_ID")): entry = p break if entry is None: report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", GAP, "проект НЕ зарегистрирован (создано, но не зарегистрировано — см. runbook: " "env + управляемый рестарт)") elif ( entry.work_item_prefix != params["WORK_ITEM_PREFIX"] or entry.repo != params["REPO"] ): report.add("verify.registry", "запись реестра", GAP, f"поля записи расходятся с параметрами: {entry}") else: report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", OK, f"prefix={entry.work_item_prefix}, repo={entry.repo}") # 2. Статусы: все 22 канонических имени (включая fail-closed Confirm Deploy/STOP). if observed.project is None or observed.states is None: report.add("verify.plane.states", "статусы Plane-проекта", GAP, "проект/статусы не читаются через API") else: existing = _existing_names(observed.states) missing = [name for name in _PLANE_NAME_TO_KEY if name not in existing] if missing: report.add("verify.plane.states", "статусы Plane-проекта", GAP, f"отсутствуют: {', '.join(missing)}") else: report.add("verify.plane.states", "статусы Plane-проекта", OK, f"все {len(_PLANE_NAME_TO_KEY)} канонических имени на месте") # 3. Лейблы. if observed.labels is None: report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, "лейблы не читаются") else: existing = _existing_names(observed.labels) missing = [label for label in label_names() if label not in existing] if missing: report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, f"отсутствуют: {', '.join(missing)}") else: report.add("verify.plane.labels", "лейблы Plane-проекта", OK, ", ".join(label_names())) # 4. Gitea-webhook. if observed.repo is None: report.add("verify.gitea.repo", "Gitea-репо", GAP, "репо не найден") report.add("verify.gitea.webhook", "per-repo webhook", GAP, "репо не найден") else: report.add("verify.gitea.repo", "Gitea-репо", OK, f"{params['GITEA_OWNER']}/{params['REPO']}") active_hook = any( isinstance(h, dict) and h.get("config", {}).get("url") == webhook_url and h.get("active", False) for h in observed.hooks ) if active_hook: report.add("verify.gitea.webhook", "per-repo webhook", OK, f"url={webhook_url}, active") else: report.add("verify.gitea.webhook", "per-repo webhook", GAP, f"активный webhook с url={webhook_url} не найден") # 5. Полнота kit в репо + скан неразрешённых плейсхолдеров. if observed.repo is not None: missing_files = [] unresolved: dict[str, list] = {} for rel in VERIFY_KIT_FILES: content = gitea.get_file_text(params["GITEA_OWNER"], params["REPO"], rel) if content is None: missing_files.append(rel) continue bad = find_unresolved(content) if bad: unresolved[rel] = bad if missing_files: report.add("verify.kit.files", "kit-файлы в репо", GAP, f"отсутствуют: {', '.join(missing_files)}") elif unresolved: report.add("verify.kit.files", "kit-файлы в репо", GAP, f"неразрешённые плейсхолдеры: {unresolved}") else: report.add("verify.kit.files", "kit-файлы в репо", OK, f"{len(VERIFY_KIT_FILES)} ключевых файлов на месте, плейсхолдеров нет") templates = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_templates") or [] standards = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_standards") or [] if len(templates) >= 16 and len(standards) >= 3: report.add("verify.kit.canon", "live-copy канона в репо", OK, f"_templates={len(templates)}, _standards={len(standards)}") else: report.add("verify.kit.canon", "live-copy канона в репо", GAP, f"_templates={len(templates)} (>=16?), _standards={len(standards)} (>=3?)") # 6. Workspace-webhook Plane: только команда проверки (Ф-6 — существует, не создаём). report.add("verify.plane.workspace-webhook", "workspace-webhook Plane", MANUAL, f"проверь вручную командой из {RUNBOOK} (создан на уровне workspace)") return report # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def build_params(args: argparse.Namespace) -> dict: """Параметры рендера из аргументов CLI (ключи — словарь placeholders.json).""" return { "PROJECT_NAME": args.name, "PROJECT_DESCRIPTION": args.description, "REPO": args.repo, "GITEA_OWNER": args.gitea_owner, "WORK_ITEM_PREFIX": args.prefix, "PLANE_PROJECT_ID": args.plane_project_id or "", "STACK": args.stack, "TEST_CMD": args.test_cmd, "PROD_PORT": str(args.prod_port), "STAGING_PORT": str(args.staging_port), } def main(argv: list | None = None) -> int: parser = argparse.ArgumentParser( description="Turnkey-онбординг нового проекта в оркестратор (ORCH-009). " f"Полный процесс — {RUNBOOK}.", ) parser.add_argument("mode", nargs="?", default="plan", choices=("plan", "apply", "verify"), help="режим: plan (дефолт, dry-run) / apply / verify") parser.add_argument("--name", required=True, help="человекочитаемое имя проекта") parser.add_argument("--description", default="", help="1–2 фразы «зачем проект»") parser.add_argument("--repo", required=True, help="имя Gitea-репо") parser.add_argument("--gitea-owner", default=getattr(settings, "gitea_owner", "admin"), help="owner/org репо в Gitea") parser.add_argument("--prefix", required=True, help="префикс work-item (identifier Plane)") parser.add_argument("--plane-project-id", default="", help="UUID существующего Plane-проекта (если уже создан)") parser.add_argument("--stack", required=True, help="стек проекта (описательно)") parser.add_argument("--test-cmd", required=True, help="команда тестов проекта") parser.add_argument("--prod-port", required=True, help="порт прод-контура проекта") parser.add_argument("--staging-port", required=True, help="порт staging-контура проекта") parser.add_argument("--webhook-url", required=True, help="внешний URL приёмника Gitea-webhook оркестратора " "(https:///webhook/gitea)") parser.add_argument("--env-file", default=None, help="файл .env с текущим ORCH_PROJECTS_JSON (read-only; дефолт .env корня)") parser.add_argument("--json", action="store_true", help="печатать отчёт в JSON") args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") params = build_params(args) plane = PlaneClient(settings.plane_api_url, settings.plane_api_token, settings.plane_workspace_slug) gitea = GiteaClient(settings.gitea_url, settings.gitea_token) if args.mode == "plan": report = run_plan(params, plane, gitea, webhook_url=args.webhook_url) elif args.mode == "apply": report = run_apply(params, plane, gitea, webhook_url=args.webhook_url, env_file=args.env_file) else: report = run_verify(params, plane, gitea, webhook_url=args.webhook_url, env_file=args.env_file) print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2) if args.json else report.render_text()) return report.exit_code if __name__ == "__main__": sys.exit(main())