From 7758ebdebbfb7beceb92f2a402c2d6e6662750d9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 12 Aug 2026 23:55:33 +0700 Subject: [PATCH] first commit --- ctfcup2025-school-final | 1 + infra/forcad-local/README.md | 145 ++ .../__pycache__/forcad_local.cpython-312.pyc | Bin 0 -> 72231 bytes .../forcad-local/builders/cpp-make.Dockerfile | 18 + infra/forcad-local/forcad_local.py | 1609 +++++++++++++++++ 5 files changed, 1773 insertions(+) create mode 160000 ctfcup2025-school-final create mode 100644 infra/forcad-local/README.md create mode 100644 infra/forcad-local/__pycache__/forcad_local.cpython-312.pyc create mode 100644 infra/forcad-local/builders/cpp-make.Dockerfile create mode 100644 infra/forcad-local/forcad_local.py diff --git a/ctfcup2025-school-final b/ctfcup2025-school-final new file mode 160000 index 0000000..61bb5d9 --- /dev/null +++ b/ctfcup2025-school-final @@ -0,0 +1 @@ +Subproject commit 61bb5d9a9d5ee62156dbd0ce0fee5acaa0e953c8 diff --git a/infra/forcad-local/README.md b/infra/forcad-local/README.md new file mode 100644 index 0000000..ca40267 --- /dev/null +++ b/infra/forcad-local/README.md @@ -0,0 +1,145 @@ +# forcad-local + +Автоматизированный локальный оркестратор для запуска ForcAD + игровых сервисов + чекеров в контейнерах. + +Инструмент делает: +- auto-discovery сервисов в переданной папке; +- preflight-проверки структуры сервисов и чекеров; +- containerized сборку checker-артефактов (например `make` для `archive`) без хостовых dev-пакетов; +- auto-cleanup хвостов прошлых запусков перед новым `up`; +- prewarm k3d/k3s образов перед деплоем k3d-сервисов; +- retry для k3d-деплоя и rollback при ошибке (настраивается); +- генерацию runtime-конфига ForcAD без правок исходников борды/сервисов; +- запуск `light` smoke-check `check` для каждого сервиса без полного ForcAD стека; +- опциональный `full` режим с полным ForcAD стеком; +- команды `up`, `down`, `status`, `validate`. + +## Требования + +- `docker` + `docker compose` +- Python 3.10+ +- Для Helm/k3d-профилей сервисов: `helm`, `kubectl`, `k3d` (если требуется конкретному сервису) + +## Базовый запуск + +```bash +python3 infra/forcad-local/forcad_local.py validate \ + --services-root /path/to/services \ + --checkers-root /path/to/checkers +``` + +```bash +python3 infra/forcad-local/forcad_local.py up \ + --services-root /path/to/services \ + --checkers-root /path/to/checkers \ + --mode light +``` + +```bash +python3 infra/forcad-local/forcad_local.py status +``` + +```bash +python3 infra/forcad-local/forcad_local.py down +``` + +После `up` борда доступна по `http://127.0.0.1:8080/` (или через `--board-port`). +Это относится только к `--mode full`. В `--mode light` борда не запускается. + +## Какие структуры поддерживаются + +Для сервиса в `services-root//`: + +1. **Compose-профиль** + - должен быть `docker-compose.yml` +2. **Script/Helm-профиль** + - `deploy.sh`, либо + - `redeploy.sh`, либо + - `deploy/chart/values.yaml` (с возможным `build-docker.sh`) + +Для чекера в `checkers-root//`: +- должен быть `checker.py` **или** `*.checker.py` +- если есть `requirements.txt` (или `requirments.txt`) — зависимости автоматически объединяются +- если есть `Makefile` — сборка выполняется внутри builder-контейнера (`cpp-make` профиль по умолчанию) + +## Автопроверки (preflight) + +Команда `validate` проверяет: +- обнаружены ли сервисы; +- есть ли deploy-профиль; +- есть ли checker entrypoint; +- определён ли host-порт для сервиса; +- нет ли конфликтов портов между сервисами и портом борды; +- наличие системных команд (`docker`, а для helm/k3d-профиля — предупреждения про `helm`, `kubectl`, `k3d`). +- валидность конфигурации containerized checker-build профиля. + +## Поведение `up` по умолчанию + +- сервисы сортируются в детерминированном порядке (k3d-сервисы сначала); +- перед запуском выполняется auto-cleanup по обнаруженным сервисам; +- для k3d-сервисов заранее выполняется pull базовых образов; +- если k3d-деплой падает, делается retry (`--k3d-retries`); +- в `strict` режиме падение одного сервиса прерывает запуск и откатывает уже поднятые; +- в `best-effort` режимe проблемные сервисы пропускаются. + +## Runtime-файлы + +Все временные файлы создаются в: +- `infra/forcad-local/.runtime/` + +Кэш сборки checker-артефактов: +- `infra/forcad-local/.cache/checker-builds/` + +Там же сохраняется `state.json`, который используется командами `status` и `down`. + +## Переопределения через manifest + +Если auto-discovery не хватает, в `services-root` можно положить файл `forcad-local.json`: + +```json +{ + "services": { + "warehouse": { + "checker_dir": "warehouse", + "port": 31443, + "checker_timeout": 40, + "checker_type": "pfr", + "places": 2, + "profile": "helm_chart", + "start_cmd": "bash redeploy.sh", + "stop_cmd": "helm uninstall warehouse -n warehouse", + "checker_build": { + "enabled": false + } + }, + "archive": { + "checker_build": { + "enabled": true, + "profile": "cpp-make", + "command": "make", + "artifacts": ["libgen"] + } + } + } +} +``` + +Поля опциональны. Используй их, если сервис нестандартный или порт не удаётся вывести автоматически. + +## Полезные параметры + +- `--service ` — выбрать конкретные сервисы (можно повторять) +- `--board-port 8080` — порт борды +- `--mode light|full` — режим работы (`light` по умолчанию) +- `--failure-policy strict|best-effort` — остановиться при первой ошибке или пропускать упавшие сервисы +- `--rollback-on-failure / --no-rollback-on-failure` — откат уже поднятых сервисов при ошибке в strict режиме +- `--auto-cleanup / --no-auto-cleanup` — очистка прошлых запусков перед новым `up` +- `--prewarm-k3d / --no-prewarm-k3d` — предварительный pull k3d/k3s образов +- `--k3d-retries` — число повторов деплоя для k3d-сервисов +- `--k3d-retry-delay` — задержка между retry для k3d +- `--image-pull-retries` — число дополнительных retry на pull docker-образов +- `--image-pull-retry-delay` — задержка между retry pull +- `--team-count 2` — число команд в runtime-конфиге +- `--workers 2` — число celery worker'ов +- `--cache-dir` — путь к кэшу сборки checker-артефактов +- `--no-fast` — не использовать `docker-compose-fast.yml` diff --git a/infra/forcad-local/__pycache__/forcad_local.cpython-312.pyc b/infra/forcad-local/__pycache__/forcad_local.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a43326bb85c5336a182ae274b841af4d686306e GIT binary patch literal 72231 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 Mlolj}go<9M_P{A!JQ65B7 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 PZ(0JdG-)YhFyj9LsJG)k literal 0 HcmV?d00001 diff --git a/infra/forcad-local/builders/cpp-make.Dockerfile b/infra/forcad-local/builders/cpp-make.Dockerfile new file mode 100644 index 0000000..51753b7 --- /dev/null +++ b/infra/forcad-local/builders/cpp-make.Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + file \ + libboost-serialization-dev \ + libsqlite3-dev \ + make \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /work + +CMD ["bash"] diff --git a/infra/forcad-local/forcad_local.py b/infra/forcad-local/forcad_local.py new file mode 100644 index 0000000..d3e3bcc --- /dev/null +++ b/infra/forcad-local/forcad_local.py @@ -0,0 +1,1609 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import shlex +import shutil +import socket +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + + +LOCAL_HOST_ALIASES = {"127.0.0.1", "localhost", "host.docker.internal", "local", "test-service"} +DEFAULT_ADMIN_USER = "forcad" +DEFAULT_ADMIN_PASSWORD = "forcad_local" + +BUILDER_PROFILES = { + "cpp-make": { + "dockerfile": "builders/cpp-make.Dockerfile", + "image": "forcad-local-builder-cpp-make:1", + }, +} + +K3D_PREWARM_IMAGES = [ + "ghcr.io/k3d-io/k3d-tools:5.9.0", + "rancher/k3s:v1.31.5-k3s1", +] + + +@dataclass +class ServiceSpec: + name: str + service_dir: Path + checker_dir: Path + profile: str + checker_entry: Path + checker_type: str + places: int + checker_timeout: int + protocol: str + host_port: Optional[int] + requirements: List[str] = field(default_factory=list) + checker_build_enabled: bool = False + checker_build_profile: Optional[str] = None + checker_build_command: Optional[str] = None + checker_build_artifacts: List[str] = field(default_factory=list) + start_cmd: Optional[str] = None + stop_cmd: Optional[str] = None + + +@dataclass +class ValidationReport: + errors: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + + def ok(self) -> bool: + return not self.errors + + +def info(message: str) -> None: + print(f"[INFO] {message}") + + +def warn(message: str) -> None: + print(f"[WARN] {message}") + + +def err(message: str) -> None: + print(f"[ERROR] {message}", file=sys.stderr) + + +def run_command(command: Sequence[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess: + printable = " ".join(command) + location = f" (cwd={cwd})" if cwd else "" + info(f"Running: {printable}{location}") + result = subprocess.run(command, cwd=cwd, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"Command failed with exit code {result.returncode}: {printable}") + return result + + +def run_shell(command: str, cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess: + location = f" (cwd={cwd})" if cwd else "" + info(f"Running shell: {command}{location}") + result = subprocess.run(["bash", "-lc", command], cwd=cwd, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"Command failed with exit code {result.returncode}: {command}") + return result + + +def run_command_with_retries( + command: Sequence[str], + *, + cwd: Optional[Path] = None, + retries: int = 0, + retry_delay: int = 5, + purpose: str = "command", +) -> subprocess.CompletedProcess: + attempts = 1 + max(0, retries) + last_exception: Optional[Exception] = None + for attempt in range(1, attempts + 1): + try: + return run_command(command, cwd=cwd, check=True) + except Exception as exc: # noqa: BLE001 + last_exception = exc + if attempt >= attempts: + raise + warn( + f"{purpose} failed ({attempt}/{attempts}): {exc}. " + f"Retrying in {retry_delay}s..." + ) + time.sleep(retry_delay) + if last_exception: + raise last_exception + raise RuntimeError(f"{purpose} failed for unknown reason") + + +def require_command(command: str, report: ValidationReport) -> None: + if shutil.which(command) is None: + report.errors.append(f"Не найдена системная команда `{command}`.") + + +def parse_manifest(services_root: Path) -> Dict[str, dict]: + manifest_path = services_root / "forcad-local.json" + if not manifest_path.exists(): + return {} + try: + data = json.loads(manifest_path.read_text()) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Некорректный JSON в {manifest_path}: {exc}") from exc + services = data.get("services", {}) + if not isinstance(services, dict): + raise RuntimeError(f"`services` в {manifest_path} должен быть объектом.") + return services + + +def detect_profile(service_dir: Path, overrides: dict) -> Optional[str]: + if "profile" in overrides: + return str(overrides["profile"]) + if (service_dir / "docker-compose.yml").exists(): + return "compose" + if (service_dir / "deploy.sh").exists(): + return "deploy_script" + if (service_dir / "redeploy.sh").exists() or (service_dir / "deploy" / "chart" / "values.yaml").exists(): + return "helm_chart" + return None + + +def detect_checker_entry(checker_dir: Path) -> Optional[Path]: + checker_py = checker_dir / "checker.py" + if checker_py.exists(): + return Path("checker.py") + + dotted = sorted(checker_dir.glob("*.checker.py")) + if len(dotted) == 1: + return Path(dotted[0].name) + if len(dotted) > 1: + return Path(dotted[0].name) + + checker_named = sorted(p for p in checker_dir.glob("*.py") if "checker" in p.name.lower()) + if len(checker_named) == 1: + return Path(checker_named[0].name) + return None + + +def checker_protocol(entry_content: str) -> str: + if "https://" in entry_content: + return "https" + if "http://" in entry_content: + return "http" + if ".split(':')" in entry_content or "split(\":\")" in entry_content: + return "tcp" + return "unknown" + + +def checker_type(entry_content: str) -> str: + return "pfr" if "OK_WITH_FLAG_ID" in entry_content else "hackerdom" + + +def checker_places(entry_content: str) -> int: + count = len(re.findall(r"define_vuln", entry_content)) + return max(1, count) + + +def infer_make_artifacts(makefile_path: Path) -> List[str]: + makefile_text = makefile_path.read_text() + artifacts = re.findall(r"-o\s+([A-Za-z0-9_.\-\/]+)", makefile_text) + return sorted(set(artifacts)) + + +def infer_checker_artifacts(checker_dir: Path) -> List[str]: + artifacts: List[str] = [] + for py_file in checker_dir.glob("*.py"): + try: + text = py_file.read_text() + except UnicodeDecodeError: + continue + if "./libgen" in text or "libgen" in text: + artifacts.append("libgen") + return sorted(set(artifacts)) + + +def detect_checker_build(checker_dir: Path, service_override: dict) -> Tuple[bool, Optional[str], Optional[str], List[str]]: + raw_cfg = service_override.get("checker_build") + if isinstance(raw_cfg, dict): + enabled = bool(raw_cfg.get("enabled", True)) + if not enabled: + return False, None, None, [] + profile = str(raw_cfg.get("profile", "cpp-make")) + command = str(raw_cfg.get("command", "make")) + raw_artifacts = raw_cfg.get("artifacts", []) + artifacts = [str(x) for x in raw_artifacts] if isinstance(raw_artifacts, list) else [] + return True, profile, command, artifacts + + makefile = checker_dir / "Makefile" + if makefile.exists(): + artifacts = infer_make_artifacts(makefile) or infer_checker_artifacts(checker_dir) + return True, "cpp-make", "make", artifacts + + return False, None, None, [] + + +def load_requirements(checker_dir: Path) -> List[str]: + requirements: List[str] = [] + candidates = [checker_dir / "requirements.txt", checker_dir / "requirments.txt"] + for candidate in candidates: + if not candidate.exists(): + continue + for line in candidate.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + requirements.append(stripped) + return requirements + + +def discover_host_port(service_dir: Path, profile: str, protocol: str, overrides: dict) -> Optional[int]: + if "port" in overrides: + try: + return int(overrides["port"]) + except (TypeError, ValueError): + return None + + if profile == "compose": + compose_path = service_dir / "docker-compose.yml" + if not compose_path.exists(): + return None + text = compose_path.read_text() + matches = re.findall(r"(\d{2,5})\s*:\s*(\d{2,5})(?:/\w+)?", text) + if not matches: + return None + return int(matches[0][0]) + + values_files = list(service_dir.glob("**/values.yaml")) + node_ports: List[int] = [] + for path in values_files: + text = path.read_text() + for match in re.findall(r"nodePort:\s*(\d{2,5})", text): + node_ports.append(int(match)) + # Some charts (e.g. ingress-nginx values) keep NodePorts as: + # nodePorts: + # http: 31080 + # https: 31443 + if not node_ports: + for match in re.findall(r":\s*(\d{2,5})\b", text): + value = int(match) + if value >= 30000: + node_ports.append(value) + + if node_ports: + if protocol == "http": + return min(node_ports) + return max(node_ports) + + deploy_script = service_dir / "deploy.sh" + if deploy_script.exists(): + script_text = deploy_script.read_text() + direct = re.search(r"localhost:(\d{2,5})", script_text) + if direct: + return int(direct.group(1)) + + return None + + +def discover_services( + services_root: Path, + checkers_root: Path, + selected_services: Optional[set[str]] = None, +) -> Tuple[List[ServiceSpec], ValidationReport]: + report = ValidationReport() + overrides = parse_manifest(services_root) + + if not services_root.exists(): + report.errors.append(f"Папка сервисов не найдена: {services_root}") + return [], report + if not checkers_root.exists(): + report.errors.append(f"Папка чекеров не найдена: {checkers_root}") + return [], report + + specs: List[ServiceSpec] = [] + service_dirs = sorted(p for p in services_root.iterdir() if p.is_dir() and not p.name.startswith(".")) + + for service_dir in service_dirs: + name = service_dir.name + if selected_services and name not in selected_services: + continue + + service_override = overrides.get(name, {}) + profile = detect_profile(service_dir, service_override) + if profile is None: + report.warnings.append(f"Пропускаю `{name}`: не найден поддерживаемый профиль деплоя.") + continue + + checker_name = str(service_override.get("checker_dir", name)) + checker_dir = checkers_root / checker_name + if not checker_dir.exists(): + report.errors.append(f"`{name}`: не найдена папка чекера `{checker_dir}`.") + continue + + entry_override = service_override.get("checker_entry") + if entry_override: + checker_entry = Path(str(entry_override)) + if not (checker_dir / checker_entry).exists(): + report.errors.append(f"`{name}`: не найден checker entry `{checker_entry}` в `{checker_dir}`.") + continue + else: + detected_entry = detect_checker_entry(checker_dir) + if detected_entry is None: + report.errors.append(f"`{name}`: не найден исполняемый checker (`checker.py` или `*.checker.py`).") + continue + checker_entry = detected_entry + + entry_text = (checker_dir / checker_entry).read_text() + protocol = checker_protocol(entry_text) + detected_type = checker_type(entry_text) + detected_places = checker_places(entry_text) + + task_type = str(service_override.get("checker_type", detected_type)) + places = int(service_override.get("places", detected_places)) + timeout = int(service_override.get("checker_timeout", 30)) + host_port = discover_host_port(service_dir, profile, protocol, service_override) + requirements = load_requirements(checker_dir) + build_enabled, build_profile, build_command, build_artifacts = detect_checker_build(checker_dir, service_override) + + spec = ServiceSpec( + name=name, + service_dir=service_dir, + checker_dir=checker_dir, + profile=profile, + checker_entry=checker_entry, + checker_type=task_type, + places=places, + checker_timeout=timeout, + protocol=protocol, + host_port=host_port, + requirements=requirements, + checker_build_enabled=build_enabled, + checker_build_profile=build_profile, + checker_build_command=build_command, + checker_build_artifacts=build_artifacts, + start_cmd=service_override.get("start_cmd"), + stop_cmd=service_override.get("stop_cmd"), + ) + specs.append(spec) + + if not specs: + report.errors.append("Не найдено ни одного валидного сервиса для запуска.") + return specs, report + + +def validate_preflight(specs: List[ServiceSpec], board_port: int) -> ValidationReport: + report = ValidationReport() + require_command("docker", report) + seen_ports: Dict[int, str] = {} + + for spec in specs: + if spec.host_port is None: + report.errors.append(f"`{spec.name}`: не удалось определить host-порт (укажи `port` в `forcad-local.json`).") + else: + if spec.host_port == board_port: + report.errors.append( + f"`{spec.name}`: host-порт {spec.host_port} конфликтует с портом борды {board_port}." + ) + if spec.host_port in seen_ports: + report.errors.append( + f"Конфликт портов: `{spec.name}` и `{seen_ports[spec.host_port]}` используют {spec.host_port}." + ) + seen_ports[spec.host_port] = spec.name + + if spec.profile == "compose" and not (spec.service_dir / "docker-compose.yml").exists(): + report.errors.append(f"`{spec.name}`: профиль compose выбран, но `docker-compose.yml` отсутствует.") + + if spec.profile in {"deploy_script", "helm_chart"}: + for dep in ("helm", "kubectl"): + if shutil.which(dep) is None: + report.warnings.append( + f"`{spec.name}`: команда `{dep}` не найдена; сервис может не подняться." + ) + if (spec.service_dir / "k3d-config.yaml").exists() and shutil.which("k3d") is None: + report.warnings.append(f"`{spec.name}`: найден `k3d-config.yaml`, но команда `k3d` отсутствует.") + + if spec.checker_build_enabled: + if not spec.checker_build_profile: + report.errors.append(f"`{spec.name}`: включена сборка чекера, но не указан checker_build_profile.") + elif spec.checker_build_profile not in BUILDER_PROFILES: + report.errors.append( + f"`{spec.name}`: неизвестный checker build profile `{spec.checker_build_profile}`." + ) + if not spec.checker_build_command: + report.errors.append(f"`{spec.name}`: включена сборка чекера, но не задан checker_build_command.") + if not spec.checker_build_artifacts: + report.warnings.append( + f"`{spec.name}`: не заданы checker build artifacts, кэш сборки для него отключён." + ) + + return report + + +def merge_reports(*reports: ValidationReport) -> ValidationReport: + result = ValidationReport() + for report in reports: + result.errors.extend(report.errors) + result.warnings.extend(report.warnings) + return result + + +def print_report(report: ValidationReport) -> None: + for warning in report.warnings: + warn(warning) + for error_message in report.errors: + err(error_message) + + +def resolve_checkers_root(services_root: Path, checkers_root_arg: Optional[Path]) -> Path: + if checkers_root_arg: + return checkers_root_arg.resolve() + + sibling = services_root.parent / "checkers" + if sibling.exists(): + return sibling.resolve() + + inside = services_root / "checkers" + if inside.exists(): + return inside.resolve() + + raise RuntimeError( + "Не удалось определить папку чекеров автоматически. Укажи `--checkers-root` явно." + ) + + +def safe_service_var(service_name: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9]+", "_", service_name).upper() + return f"AD_TARGET_{normalized}" + + +def ensure_clean_dir(path: Path) -> None: + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) + + +def resolve_builder_image(orchestrator_root: Path, profile: str) -> str: + profile_config = BUILDER_PROFILES.get(profile) + if not profile_config: + raise RuntimeError(f"Unknown builder profile: {profile}") + + image = profile_config["image"] + inspect = run_command(["docker", "image", "inspect", image], check=False) + if inspect.returncode == 0: + return image + + dockerfile = orchestrator_root / profile_config["dockerfile"] + if not dockerfile.exists(): + raise RuntimeError(f"Builder dockerfile not found for profile `{profile}`: {dockerfile}") + + info(f"Building checker builder image `{image}` from `{dockerfile}`.") + run_command( + [ + "docker", + "build", + "-f", + str(dockerfile), + "-t", + image, + str(orchestrator_root), + ] + ) + return image + + +def hash_checker_inputs(checker_dir: Path, build_profile: str, build_command: str) -> str: + digest = hashlib.sha256() + digest.update(build_profile.encode()) + digest.update(b"\0") + digest.update(build_command.encode()) + digest.update(b"\0") + + for item in sorted(checker_dir.rglob("*")): + if not item.is_file(): + continue + rel = item.relative_to(checker_dir).as_posix() + digest.update(rel.encode()) + digest.update(b"\0") + digest.update(item.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def restore_build_cache( + service: ServiceSpec, + checker_runtime_dir: Path, + cache_root: Path, + cache_key: str, +) -> bool: + if not service.checker_build_artifacts: + return False + + entry = cache_root / service.name / cache_key + if not entry.exists(): + return False + + for artifact in service.checker_build_artifacts: + src = entry / artifact + dst = checker_runtime_dir / artifact + if not src.exists(): + return False + if src.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + else: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + info(f"Checker build cache hit for `{service.name}`.") + return True + + +def store_build_cache( + service: ServiceSpec, + checker_runtime_dir: Path, + cache_root: Path, + cache_key: str, +) -> None: + if not service.checker_build_artifacts: + return + + entry = cache_root / service.name / cache_key + if entry.exists(): + shutil.rmtree(entry) + entry.mkdir(parents=True, exist_ok=True) + + for artifact in service.checker_build_artifacts: + src = checker_runtime_dir / artifact + if not src.exists(): + continue + dst = entry / artifact + if src.is_dir(): + shutil.copytree(src, dst) + else: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def create_wrapper(service: ServiceSpec, destination: Path) -> None: + env_var = safe_service_var(service.name) + default_target = f"host.docker.internal:{service.host_port}" + wrapper = f"""#!/usr/bin/env python3 +import os +import subprocess +import sys +from pathlib import Path + +LOCAL_ALIASES = {sorted(LOCAL_HOST_ALIASES)!r} +DEFAULT_TARGET = os.environ.get("{env_var}", "{default_target}") + +def patch_hostname(argv): + if len(argv) < 3: + return argv + action = argv[1].lower() + if action not in {{"check", "put", "get", "test"}}: + return argv + host = argv[2] + if host in LOCAL_ALIASES: + argv[2] = DEFAULT_TARGET + elif ":" not in host and ":" in DEFAULT_TARGET: + argv[2] = f"{{host}}:{{DEFAULT_TARGET.rsplit(':', 1)[1]}}" + return argv + +def main(): + checker_path = Path(__file__).with_name("{service.checker_entry.as_posix()}") + argv = patch_hostname(sys.argv[:]) + completed = subprocess.run([sys.executable, str(checker_path), *argv[1:]], cwd=Path(__file__).parent) + raise SystemExit(completed.returncode) + +if __name__ == "__main__": + main() +""" + destination.write_text(wrapper) + destination.chmod(0o755) + + +def generate_runtime( + runtime_dir: Path, + specs: List[ServiceSpec], + team_count: int, + board_port: int, + round_time: int, + flag_lifetime: int, + start_delay_seconds: int, +) -> dict: + ensure_clean_dir(runtime_dir) + checkers_dir = runtime_dir / "checkers" + env_dir = runtime_dir / "env" + checkers_dir.mkdir(parents=True, exist_ok=True) + env_dir.mkdir(parents=True, exist_ok=True) + + requirements = {"gornilo", "checklib==0.7.0"} + task_chunks: List[str] = [] + + for spec in specs: + target_dir = checkers_dir / spec.name + shutil.copytree(spec.checker_dir, target_dir, dirs_exist_ok=True) + create_wrapper(spec, target_dir / "forcad_wrapper.py") + + for requirement in spec.requirements: + requirements.add(requirement) + + task_chunks.append( + "\n".join( + [ + f" - name: {spec.name}", + f" checker: {spec.name}/forcad_wrapper.py", + f" checker_timeout: {spec.checker_timeout}", + f" checker_type: {spec.checker_type}", + " gets: 1", + " puts: 1", + f" places: {max(1, spec.places)}", + ] + ) + ) + + (checkers_dir / "requirements.txt").write_text("\n".join(sorted(requirements)) + "\n") + + start_time = dt.datetime.now(dt.UTC) + dt.timedelta(seconds=start_delay_seconds) + start_time_text = start_time.strftime("%Y-%m-%d %H:%M:%S") + teams = "\n".join( + [ + "\n".join( + [ + " - ip: host.docker.internal", + f" name: Team {idx + 1}", + f" highlighted: {'true' if idx == 0 else 'false'}", + ] + ) + for idx in range(team_count) + ] + ) + + config_text = "\n".join( + [ + "game:", + " mode: classic", + f" round_time: {round_time}", + f" start_time: '{start_time_text}'", + " timezone: UTC", + " default_score: 2500", + f" flag_lifetime: {flag_lifetime}", + " game_hardness: 10.0", + " inflation: true", + " checkers_path: /checkers/", + " env_path: /checkers/bin/", + "admin:", + f" username: {DEFAULT_ADMIN_USER}", + f" password: {DEFAULT_ADMIN_PASSWORD}", + "tasks:", + *task_chunks, + "teams:", + teams, + "storages:", + " db:", + f" user: {DEFAULT_ADMIN_USER}", + f" password: {DEFAULT_ADMIN_PASSWORD}", + " dbname: forcad", + " host: postgres", + " port: 5432", + " redis:", + f" password: {DEFAULT_ADMIN_PASSWORD}", + " db: 0", + " host: redis", + " port: 6379", + " rabbitmq:", + f" user: {DEFAULT_ADMIN_USER}", + f" password: {DEFAULT_ADMIN_PASSWORD}", + " host: rabbitmq", + " port: 5672", + " vhost: forcad", + "", + ] + ) + config_path = runtime_dir / "config.yml" + config_path.write_text(config_text) + + postgres_env = "\n".join( + [ + "POSTGRES_HOST=postgres", + "POSTGRES_PORT=5432", + f"POSTGRES_USER={DEFAULT_ADMIN_USER}", + f"POSTGRES_PASSWORD={DEFAULT_ADMIN_PASSWORD}", + "POSTGRES_DB=forcad", + "", + ] + ) + redis_env = "\n".join( + [ + "REDIS_HOST=redis", + "REDIS_PORT=6379", + f"REDIS_PASSWORD={DEFAULT_ADMIN_PASSWORD}", + "", + ] + ) + rabbit_env = "\n".join( + [ + "RABBITMQ_HOST=rabbitmq", + "RABBITMQ_PORT=5672", + f"RABBITMQ_DEFAULT_USER={DEFAULT_ADMIN_USER}", + f"RABBITMQ_DEFAULT_PASS={DEFAULT_ADMIN_PASSWORD}", + "RABBITMQ_DEFAULT_VHOST=forcad", + f"BROKER_API_URL=http://{DEFAULT_ADMIN_USER}:{DEFAULT_ADMIN_PASSWORD}@rabbitmq:15672/api/", + "", + ] + ) + admin_env = "\n".join( + [ + f"ADMIN_USERNAME={DEFAULT_ADMIN_USER}", + f"ADMIN_PASSWORD={DEFAULT_ADMIN_PASSWORD}", + "", + ] + ) + + postgres_path = env_dir / "postgres_environment.env" + redis_path = env_dir / "redis_environment.env" + rabbit_path = env_dir / "rabbitmq_environment.env" + admin_path = env_dir / "admin.env" + postgres_path.write_text(postgres_env) + redis_path.write_text(redis_env) + rabbit_path.write_text(rabbit_env) + admin_path.write_text(admin_env) + + target_env_lines = [f" {safe_service_var(spec.name)}: host.docker.internal:{spec.host_port}" for spec in specs] + target_env = "\n".join(target_env_lines) + + forcad_override = "\n".join( + [ + "version: '3.8'", + "services:", + " celery:", + " user: root", + " volumes:", + f" - \"{checkers_dir}:/checkers/\"", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + " environment:", + " TEST: \"\"", + " SERVICE: worker", + target_env, + " extra_hosts:", + " - \"host.docker.internal:host-gateway\"", + " command: bash -lc \"pip install --no-cache-dir -r /checkers/requirements.txt && /entrypoint.sh\"", + "", + " flower:", + " user: root", + " volumes:", + f" - \"{checkers_dir}:/checkers/\"", + " env_file:", + f" - \"{admin_path}\"", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + " environment:", + " TEST: \"\"", + " SERVICE: flower", + target_env, + " extra_hosts:", + " - \"host.docker.internal:host-gateway\"", + " command: bash -lc \"pip install --no-cache-dir -r /checkers/requirements.txt && /entrypoint.sh\"", + "", + " initializer:", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + " volumes:", + f" - \"{config_path}:/config.yml:ro\"", + " environment:", + " TEST: \"\"", + " CONFIG_PATH: /config.yml", + "", + " ticker:", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + "", + " client-api:", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + "", + " admin-api:", + " env_file:", + f" - \"{admin_path}\"", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + "", + " events:", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + "", + " http-receiver:", + " env_file:", + f" - \"{postgres_path}\"", + f" - \"{redis_path}\"", + f" - \"{rabbit_path}\"", + "", + " redis:", + " env_file:", + f" - \"{redis_path}\"", + "", + " rabbitmq:", + " env_file:", + f" - \"{rabbit_path}\"", + "", + " postgres:", + " env_file:", + f" - \"{postgres_path}\"", + "", + " nginx:", + " ports:", + f" - \"{board_port}:80\"", + "", + ] + ) + + override_path = runtime_dir / "forcad.override.yml" + override_path.write_text(forcad_override) + + return { + "runtime_dir": str(runtime_dir), + "config_path": str(config_path), + "checkers_dir": str(checkers_dir), + "override_path": str(override_path), + } + + +def build_checker_artifacts( + runtime_dir: Path, + specs: List[ServiceSpec], + cache_dir: Path, + failure_policy: str = "strict", +) -> Tuple[List[ServiceSpec], Dict[str, str]]: + orchestrator_root = Path(__file__).resolve().parent + cache_root = cache_dir / "checker-builds" + cache_root.mkdir(parents=True, exist_ok=True) + builder_images: Dict[str, str] = {} + ready_specs: List[ServiceSpec] = [] + failed: Dict[str, str] = {} + + for spec in specs: + try: + if not spec.checker_build_enabled: + ready_specs.append(spec) + continue + if not spec.checker_build_profile or not spec.checker_build_command: + raise RuntimeError(f"Invalid checker build config for `{spec.name}`.") + + checker_runtime_dir = runtime_dir / "checkers" / spec.name + cache_key = hash_checker_inputs( + checker_dir=checker_runtime_dir, + build_profile=spec.checker_build_profile, + build_command=spec.checker_build_command, + ) + if restore_build_cache(spec, checker_runtime_dir, cache_root, cache_key): + ready_specs.append(spec) + continue + + if spec.checker_build_profile not in builder_images: + builder_images[spec.checker_build_profile] = resolve_builder_image( + orchestrator_root=orchestrator_root, + profile=spec.checker_build_profile, + ) + image = builder_images[spec.checker_build_profile] + + info(f"Containerized checker build for `{spec.name}` ({spec.checker_build_profile}).") + run_command( + [ + "docker", + "run", + "--rm", + "--add-host", + "host.docker.internal:host-gateway", + "-v", + f"{checker_runtime_dir}:/work", + "-w", + "/work", + image, + "bash", + "-lc", + spec.checker_build_command, + ] + ) + store_build_cache(spec, checker_runtime_dir, cache_root, cache_key) + ready_specs.append(spec) + except Exception as exc: # noqa: BLE001 + message = str(exc) + failed[spec.name] = message + if failure_policy == "strict": + raise + warn(f"Skipping `{spec.name}` due checker build failure: {message}") + + return ready_specs, failed + + +def is_port_open(port: int) -> bool: + with socket.socket() as sock: + sock.settimeout(0.5) + return sock.connect_ex(("127.0.0.1", int(port))) == 0 + + +def ensure_service_port_available(spec: ServiceSpec) -> None: + if spec.host_port is None: + return + if not is_port_open(spec.host_port): + return + warn(f"Port {spec.host_port} is busy before deploying `{spec.name}`, trying cleanup.") + stop_service(spec, include_legacy_compose=True) + time.sleep(1) + if is_port_open(spec.host_port): + raise RuntimeError( + f"Port {spec.host_port} is still busy before deploying `{spec.name}`. " + "Run cleanup and retry." + ) + + +def prewarm_k3d_images( + specs: List[ServiceSpec], + strict: bool, + image_pull_retries: int, + image_pull_retry_delay: int, +) -> None: + if not any(is_k3d_service(spec) for spec in specs): + return + for image in K3D_PREWARM_IMAGES: + try: + run_command_with_retries( + ["docker", "pull", image], + retries=image_pull_retries, + retry_delay=image_pull_retry_delay, + purpose=f"prewarm pull `{image}`", + ) + except Exception as exc: # noqa: BLE001 + message = f"Failed to prewarm image `{image}`: {exc}" + if strict: + raise RuntimeError(message) from exc + warn(message) + + +def auto_cleanup_services(specs: List[ServiceSpec]) -> None: + info("Auto-cleanup previous service runs.") + for spec in reversed(specs): + stop_service(spec, include_legacy_compose=True) + + +def rollback_services(specs: List[ServiceSpec]) -> None: + if not specs: + return + warn("Rolling back already deployed services.") + for spec in reversed(specs): + stop_service(spec, include_legacy_compose=True) + + +def deploy_service_with_retry( + spec: ServiceSpec, + k3d_retries: int, + retry_delay: int, + image_pull_retries: int, + image_pull_retry_delay: int, +) -> None: + attempts = 1 + (k3d_retries if is_k3d_service(spec) else 0) + for attempt in range(1, attempts + 1): + try: + ensure_service_port_available(spec) + deploy_service( + spec, + image_pull_retries=image_pull_retries, + image_pull_retry_delay=image_pull_retry_delay, + ) + return + except Exception as exc: # noqa: BLE001 + if attempt >= attempts: + raise + warn(f"Deploy `{spec.name}` attempt {attempt}/{attempts} failed: {exc}") + stop_service(spec, include_legacy_compose=True) + time.sleep(retry_delay) + + +def compose_project_names(spec: ServiceSpec) -> List[str]: + names: List[str] = [f"forcadsvc_{spec.name}"] + legacy = spec.service_dir.name + if legacy not in names: + names.append(legacy) + return names + + +def is_k3d_service(spec: ServiceSpec) -> bool: + if (spec.service_dir / "k3d-config.yaml").exists(): + return True + deploy_script = spec.service_dir / "deploy.sh" + if deploy_script.exists(): + return "k3d cluster create" in deploy_script.read_text() + return False + + +def service_priority(spec: ServiceSpec) -> Tuple[int, str]: + # Start k3d-heavy services first while Docker daemon is still idle. + if is_k3d_service(spec): + return (0, spec.name) + if spec.profile == "compose": + return (1, spec.name) + return (2, spec.name) + + +def order_services_for_deploy(specs: List[ServiceSpec]) -> List[ServiceSpec]: + return sorted(specs, key=service_priority) + + +def deploy_service( + spec: ServiceSpec, + *, + image_pull_retries: int = 0, + image_pull_retry_delay: int = 5, +) -> None: + if spec.start_cmd: + run_shell(spec.start_cmd, cwd=spec.service_dir) + return + + if spec.profile == "compose": + compose_path = spec.service_dir / "docker-compose.yml" + project = compose_project_names(spec)[0] + # Pull externally referenced images with retries before "up". + run_command_with_retries( + [ + "docker", + "compose", + "-f", + str(compose_path), + "-p", + project, + "pull", + ], + retries=image_pull_retries, + retry_delay=image_pull_retry_delay, + purpose=f"image pull for `{spec.name}`", + ) + run_command( + [ + "docker", + "compose", + "-f", + str(compose_path), + "-p", + project, + "up", + "-d", + "--build", + "--remove-orphans", + ] + ) + return + + deploy_script = spec.service_dir / "deploy.sh" + if deploy_script.exists(): + run_command(["bash", str(deploy_script)], cwd=spec.service_dir) + return + + redeploy_script = spec.service_dir / "redeploy.sh" + if redeploy_script.exists(): + run_command(["bash", str(redeploy_script)], cwd=spec.service_dir) + return + + build_script = spec.service_dir / "build-docker.sh" + chart_dir = spec.service_dir / "deploy" / "chart" + if build_script.exists(): + run_command(["bash", str(build_script)], cwd=spec.service_dir) + if chart_dir.exists(): + run_command( + [ + "helm", + "upgrade", + "--install", + spec.name, + str(chart_dir), + "--create-namespace", + "--namespace", + spec.name, + "--wait", + "--timeout", + "10m", + ], + cwd=spec.service_dir, + ) + return + + raise RuntimeError(f"Не знаю, как поднять сервис `{spec.name}` (profile={spec.profile}).") + + +def stop_service(spec: ServiceSpec, include_legacy_compose: bool = True) -> None: + if spec.stop_cmd: + run_shell(spec.stop_cmd, cwd=spec.service_dir, check=False) + return + + if spec.profile == "compose": + compose_path = spec.service_dir / "docker-compose.yml" + projects = compose_project_names(spec) + if not include_legacy_compose: + projects = projects[:1] + for project in projects: + run_command( + ["docker", "compose", "-f", str(compose_path), "-p", project, "down", "-v", "--remove-orphans"], + check=False, + ) + return + + if (spec.service_dir / "k3d-config.yaml").exists() and shutil.which("k3d"): + config_text = (spec.service_dir / "k3d-config.yaml").read_text() + match = re.search(r"\bname:\s*([A-Za-z0-9_.-]+)", config_text) + cluster_name = match.group(1) if match else f"{spec.name}-cluster" + run_command(["k3d", "cluster", "delete", cluster_name], check=False) + + if shutil.which("helm"): + run_command(["helm", "uninstall", spec.name, "-n", spec.name], check=False) + + +def wait_for_service(spec: ServiceSpec, timeout_seconds: int = 180) -> None: + if spec.host_port is None: + raise RuntimeError(f"У `{spec.name}` не определён host_port.") + info(f"Ожидаю доступность `{spec.name}` на порту {spec.host_port} ...") + deadline = time.time() + timeout_seconds + while time.time() < deadline: + with socket.socket() as sock: + sock.settimeout(2.0) + try: + sock.connect(("127.0.0.1", int(spec.host_port))) + info(f"`{spec.name}` доступен на 127.0.0.1:{spec.host_port}") + return + except OSError: + time.sleep(2) + raise RuntimeError(f"`{spec.name}` не стал доступен на порту {spec.host_port} за {timeout_seconds} сек.") + + +def forcad_compose_files(forcad_root: Path, override_path: Path, fast: bool) -> List[str]: + files = [str(forcad_root / "docker-compose.yml")] + if fast: + files.append(str(forcad_root / "docker-compose-fast.yml")) + files.append(str(override_path)) + return files + + +def run_forcad_up(forcad_root: Path, override_path: Path, fast: bool, workers: int) -> None: + compose_files = forcad_compose_files(forcad_root, override_path, fast) + command = ["docker", "compose"] + for path in compose_files: + command += ["-f", path] + command += ["up", "-d", "--build", "--scale", f"celery={workers}"] + run_command(command, cwd=forcad_root) + + +def run_forcad_down(forcad_root: Path, override_path: Path, fast: bool) -> None: + compose_files = forcad_compose_files(forcad_root, override_path, fast) + command = ["docker", "compose"] + for path in compose_files: + command += ["-f", path] + command += ["down", "-v", "--remove-orphans"] + run_command(command, cwd=forcad_root, check=False) + + +def run_forcad_status(forcad_root: Path, override_path: Path, fast: bool) -> None: + compose_files = forcad_compose_files(forcad_root, override_path, fast) + command = ["docker", "compose"] + for path in compose_files: + command += ["-f", path] + command += ["ps"] + run_command(command, cwd=forcad_root, check=False) + + +def run_smoke_checks(forcad_root: Path, override_path: Path, fast: bool, specs: List[ServiceSpec]) -> None: + compose_files = forcad_compose_files(forcad_root, override_path, fast) + for spec in specs: + command = ["docker", "compose"] + for path in compose_files: + command += ["-f", path] + command += [ + "exec", + "-T", + "celery", + f"/checkers/{spec.name}/forcad_wrapper.py", + "check", + "host.docker.internal", + ] + info(f"Smoke-check `{spec.name}` ...") + result = run_command(command, cwd=forcad_root, check=False) + if result.returncode == 0: + info(f"Smoke-check `{spec.name}`: OK") + else: + warn(f"Smoke-check `{spec.name}` завершился с кодом {result.returncode}.") + + +def run_light_smoke_checks(runtime_dir: Path, specs: List[ServiceSpec]) -> None: + checkers_dir = runtime_dir / "checkers" + quoted_services = " ".join(shlex.quote(spec.name) for spec in specs) + command = ( + "python3 -m pip install --no-cache-dir -r /checkers/requirements.txt >/tmp/pip.log 2>&1 && " + "failed=0; " + f"for svc in {quoted_services}; do " + "echo \"[LIGHT] checker check $svc\"; " + "python3 /checkers/$svc/forcad_wrapper.py check host.docker.internal || failed=1; " + "done; " + "exit $failed" + ) + result = run_command( + [ + "docker", + "run", + "--rm", + "--add-host", + "host.docker.internal:host-gateway", + "-v", + f"{checkers_dir}:/checkers", + "python:3.11-slim", + "bash", + "-lc", + command, + ], + check=False, + ) + if result.returncode == 0: + info("Light smoke-checks completed successfully.") + else: + warn(f"Light smoke-checks completed with non-zero status: {result.returncode}") + + +def save_state(runtime_dir: Path, payload: dict) -> None: + state_path = runtime_dir / "state.json" + state_path.write_text(json.dumps(payload, indent=2)) + + +def load_state(runtime_dir: Path) -> Optional[dict]: + state_path = runtime_dir / "state.json" + if not state_path.exists(): + return None + return json.loads(state_path.read_text()) + + +def load_context_from_args(args: argparse.Namespace, allow_state: bool = False) -> Tuple[Path, Path, Path]: + forcad_root = args.forcad_root.resolve() + runtime_dir = args.runtime_dir.resolve() + + services_root = args.services_root.resolve() if args.services_root else None + checkers_root = args.checkers_root.resolve() if args.checkers_root else None + + if services_root is None and allow_state: + state = load_state(runtime_dir) + if state: + services_root = Path(state["services_root"]) + checkers_root = Path(state["checkers_root"]) + forcad_root = Path(state["forcad_root"]) + + if services_root is None: + raise RuntimeError("Нужно указать `--services-root` или иметь сохранённый state от `up`.") + if checkers_root is None: + checkers_root = resolve_checkers_root(services_root, None) + + return forcad_root, services_root, checkers_root + + +def cmd_validate(args: argparse.Namespace) -> int: + forcad_root, services_root, checkers_root = load_context_from_args(args, allow_state=False) + if not forcad_root.exists(): + err(f"Не найден ForcAD root: {forcad_root}") + return 2 + + selected = set(args.service) if args.service else None + specs, discovery_report = discover_services(services_root, checkers_root, selected) + validation_report = validate_preflight(specs, args.board_port) + report = merge_reports(discovery_report, validation_report) + + print_report(report) + if not report.ok(): + return 2 + + info("Preflight-проверка пройдена.") + for spec in specs: + info( + f"service={spec.name} profile={spec.profile} checker={spec.checker_entry} " + f"type={spec.checker_type} places={spec.places} port={spec.host_port}" + ) + return 0 + + +def cmd_up(args: argparse.Namespace) -> int: + forcad_root, services_root, checkers_root = load_context_from_args(args, allow_state=False) + selected = set(args.service) if args.service else None + specs, discovery_report = discover_services(services_root, checkers_root, selected) + validation_report = validate_preflight(specs, args.board_port) + report = merge_reports(discovery_report, validation_report) + print_report(report) + if not report.ok(): + return 2 + + runtime_dir = args.runtime_dir.resolve() + runtime_meta = generate_runtime( + runtime_dir=runtime_dir, + specs=specs, + team_count=args.team_count, + board_port=args.board_port, + round_time=args.round_time, + flag_lifetime=args.flag_lifetime, + start_delay_seconds=args.start_delay_seconds, + ) + + failure_policy = args.failure_policy.lower() + strict = failure_policy == "strict" + ordered_specs = order_services_for_deploy(specs) + + if args.auto_cleanup: + auto_cleanup_services(ordered_specs) + if args.prewarm_k3d: + prewarm_k3d_images( + ordered_specs, + strict=strict, + image_pull_retries=args.image_pull_retries, + image_pull_retry_delay=args.image_pull_retry_delay, + ) + + ready_specs, build_failed = build_checker_artifacts( + runtime_dir, + ordered_specs, + cache_dir=args.cache_dir.resolve(), + failure_policy=failure_policy, + ) + if build_failed and strict: + raise RuntimeError(f"Checker build failed: {build_failed}") + if build_failed and not strict: + warn(f"Checker build failures (best-effort): {build_failed}") + if not ready_specs: + raise RuntimeError("No services left after checker build stage.") + + deployed_specs: List[ServiceSpec] = [] + deploy_failed: Dict[str, str] = {} + for spec in ready_specs: + try: + deploy_service_with_retry( + spec, + k3d_retries=args.k3d_retries, + retry_delay=args.k3d_retry_delay, + image_pull_retries=args.image_pull_retries, + image_pull_retry_delay=args.image_pull_retry_delay, + ) + wait_for_service(spec, timeout_seconds=args.service_wait_timeout) + deployed_specs.append(spec) + except Exception as exc: # noqa: BLE001 + deploy_failed[spec.name] = str(exc) + if strict: + if args.rollback_on_failure: + rollback_services(deployed_specs + [spec]) + raise + warn(f"Skipping `{spec.name}` after deploy failure: {exc}") + + if not deployed_specs: + raise RuntimeError("No services were deployed successfully.") + if deploy_failed and not strict: + warn(f"Deploy failures (best-effort): {deploy_failed}") + + mode = args.mode.lower() + override_path = Path(runtime_meta["override_path"]) + if mode == "full": + run_forcad_up(forcad_root, override_path, fast=not args.no_fast, workers=args.workers) + info("Ожидание стабилизации ForcAD (20 секунд)...") + time.sleep(20) + run_smoke_checks(forcad_root, override_path, fast=not args.no_fast, specs=deployed_specs) + else: + info("Running in light mode: full ForcAD stack is not started.") + run_light_smoke_checks(runtime_dir, deployed_specs) + + state = { + "forcad_root": str(forcad_root), + "services_root": str(services_root), + "checkers_root": str(checkers_root), + "mode": mode, + "fast": not args.no_fast, + "board_port": args.board_port, + "runtime": runtime_meta, + "failure_policy": failure_policy, + "deploy_failed": deploy_failed, + "build_failed": build_failed, + "services": [ + { + "name": spec.name, + "profile": spec.profile, + "service_dir": str(spec.service_dir), + "checker_dir": str(spec.checker_dir), + "checker_build_enabled": spec.checker_build_enabled, + "checker_build_profile": spec.checker_build_profile, + "checker_build_command": spec.checker_build_command, + "checker_build_artifacts": spec.checker_build_artifacts, + "host_port": spec.host_port, + "start_cmd": spec.start_cmd, + "stop_cmd": spec.stop_cmd, + } + for spec in deployed_specs + ], + } + save_state(runtime_dir, state) + + if mode == "full": + info(f"Борда должна быть доступна по адресу http://127.0.0.1:{args.board_port}/") + else: + info("Light mode complete: services are up and checker liveness checks were executed.") + info("Готово.") + return 0 + + +def cmd_down(args: argparse.Namespace) -> int: + runtime_dir = args.runtime_dir.resolve() + state = load_state(runtime_dir) + if state is None and not args.services_root: + err("Не найден state.json. Укажи `--services-root` и `--checkers-root`.") + return 2 + + if state is not None: + forcad_root = Path(state["forcad_root"]) + override_path = Path(state["runtime"]["override_path"]) + fast = bool(state.get("fast", True)) + mode = str(state.get("mode", "light")).lower() + specs = [ + ServiceSpec( + name=s["name"], + profile=s["profile"], + service_dir=Path(s["service_dir"]), + checker_dir=Path(s["checker_dir"]), + checker_entry=Path("checker.py"), + checker_type="hackerdom", + places=1, + checker_timeout=30, + protocol="unknown", + checker_build_enabled=bool(s.get("checker_build_enabled", False)), + checker_build_profile=s.get("checker_build_profile"), + checker_build_command=s.get("checker_build_command"), + checker_build_artifacts=[str(x) for x in s.get("checker_build_artifacts", [])], + host_port=s.get("host_port"), + start_cmd=s.get("start_cmd"), + stop_cmd=s.get("stop_cmd"), + ) + for s in state["services"] + ] + else: + forcad_root, services_root, checkers_root = load_context_from_args(args, allow_state=False) + discovered, report = discover_services(services_root, checkers_root, set(args.service) if args.service else None) + print_report(report) + if not discovered: + return 2 + specs = discovered + override_path = runtime_dir / "forcad.override.yml" + fast = not args.no_fast + mode = args.mode.lower() + + if mode == "full": + run_forcad_down(forcad_root, override_path, fast=fast) + for spec in specs: + stop_service(spec) + + info("Остановка завершена.") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + runtime_dir = args.runtime_dir.resolve() + state = load_state(runtime_dir) + if state is None: + err("Не найден state.json. Сначала выполни `up`.") + return 2 + + forcad_root = Path(state["forcad_root"]) + override_path = Path(state["runtime"]["override_path"]) + fast = bool(state.get("fast", True)) + mode = str(state.get("mode", "light")).lower() + if mode == "full": + run_forcad_status(forcad_root, override_path, fast=fast) + else: + info("Light mode state: ForcAD stack was not started.") + + for service in state["services"]: + port = service.get("host_port") + if not port: + warn(f"{service['name']}: порт неизвестен.") + continue + with socket.socket() as sock: + sock.settimeout(1.0) + try: + sock.connect(("127.0.0.1", int(port))) + info(f"{service['name']}: 127.0.0.1:{port} открыт") + except OSError: + warn(f"{service['name']}: 127.0.0.1:{port} недоступен") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + default_workspace = Path(__file__).resolve().parents[2] + default_forcad = default_workspace / "ForcAD" + + parser = argparse.ArgumentParser( + description="Локальный оркестратор ForcAD: auto-discovery сервисов, preflight, up/down/status." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + def add_common_flags(subparser: argparse.ArgumentParser, services_required: bool) -> None: + subparser.add_argument("--forcad-root", type=Path, default=default_forcad) + subparser.add_argument("--services-root", type=Path, required=services_required) + subparser.add_argument("--checkers-root", type=Path) + subparser.add_argument("--runtime-dir", type=Path, default=Path(__file__).resolve().parent / ".runtime") + subparser.add_argument("--service", action="append", help="Имя сервиса (можно повторять). Если не задано, берутся все.") + subparser.add_argument("--board-port", type=int, default=8080) + subparser.add_argument("--no-fast", action="store_true", help="Не использовать docker-compose-fast.yml для ForcAD.") + + validate = subparsers.add_parser("validate", help="Проверка структуры сервисов/чекеров без запуска.") + add_common_flags(validate, services_required=True) + validate.set_defaults(handler=cmd_validate) + + up = subparsers.add_parser("up", help="Поднять сервисы, борду и запустить smoke-checks.") + add_common_flags(up, services_required=True) + up.add_argument("--mode", choices=["light", "full"], default="light", help="light: без ForcAD stack; full: с полным ForcAD stack.") + up.add_argument("--cache-dir", type=Path, default=Path(__file__).resolve().parent / ".cache") + up.add_argument("--failure-policy", choices=["strict", "best-effort"], default="strict") + up.add_argument("--rollback-on-failure", action=argparse.BooleanOptionalAction, default=True) + up.add_argument("--auto-cleanup", action=argparse.BooleanOptionalAction, default=True) + up.add_argument("--prewarm-k3d", action=argparse.BooleanOptionalAction, default=True) + up.add_argument("--k3d-retries", type=int, default=1) + up.add_argument("--k3d-retry-delay", type=int, default=5) + up.add_argument( + "--image-pull-retries", + type=int, + default=2, + help="Additional retries for docker image pulls (compose/prewarm).", + ) + up.add_argument( + "--image-pull-retry-delay", + type=int, + default=10, + help="Delay (seconds) between docker pull retries.", + ) + up.add_argument("--team-count", type=int, default=2) + up.add_argument("--workers", type=int, default=2) + up.add_argument("--round-time", type=int, default=120) + up.add_argument("--flag-lifetime", type=int, default=5) + up.add_argument("--start-delay-seconds", type=int, default=120) + up.add_argument("--service-wait-timeout", type=int, default=180) + up.set_defaults(handler=cmd_up) + + down = subparsers.add_parser("down", help="Остановить ForcAD и игровые сервисы.") + add_common_flags(down, services_required=False) + down.add_argument("--mode", choices=["light", "full"], default="light", help="Используется если state.json отсутствует.") + down.set_defaults(handler=cmd_down) + + status = subparsers.add_parser("status", help="Показать статус запущенной инфраструктуры.") + status.add_argument("--runtime-dir", type=Path, default=Path(__file__).resolve().parent / ".runtime") + status.set_defaults(handler=cmd_status) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return int(args.handler(args)) + except KeyboardInterrupt: + err("Прервано пользователем.") + return 130 + except Exception as exc: # noqa: BLE001 + err(str(exc)) + return 1 + + +if __name__ == "__main__": + sys.exit(main())