From d4dba9748d02c92d90e3d1791e28323dd7881542 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Tue, 13 Oct 2026 23:59:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(infra):=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D0=BA?= =?UTF-8?q?=20tgit-uploads=20=E2=80=94=20CI/CD,=20=D1=81=D0=BB=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=B1=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=B4=D0=B5=D0=BC=D0=BE-=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .sourcecraft/ci.yaml: push в main → check (compile · ruff мягкий · bandit · pytest по сервисам) → deploy - .tgit/changed_services.py: изменённые services// (без родителя — все сервисы) - .tgit/run_tests.py: pytest каждого сервиса со своими requirements.txt - deploy пока заглушка со списком сервисов — tgit-runner в #13 - services/hello: демо-сервис с тестом; tgit.yaml — формат настроек сервиса Refs #7 Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +- infra/README.md | 2 +- infra/uploads-template/.sourcecraft/ci.yaml | 45 +++++++++++++++++ .../.tgit/changed_services.py | 48 +++++++++++++++++++ infra/uploads-template/.tgit/run_tests.py | 34 +++++++++++++ infra/uploads-template/README.md | 32 +++++++++++++ infra/uploads-template/ruff.toml | 7 +++ infra/uploads-template/services/hello/main.py | 22 +++++++++ .../services/hello/test_main.py | 5 ++ .../uploads-template/services/hello/tgit.yaml | 3 ++ 10 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 infra/uploads-template/.sourcecraft/ci.yaml create mode 100644 infra/uploads-template/.tgit/changed_services.py create mode 100644 infra/uploads-template/.tgit/run_tests.py create mode 100644 infra/uploads-template/README.md create mode 100644 infra/uploads-template/ruff.toml create mode 100644 infra/uploads-template/services/hello/main.py create mode 100644 infra/uploads-template/services/hello/test_main.py create mode 100644 infra/uploads-template/services/hello/tgit.yaml diff --git a/.env.example b/.env.example index 5c89b1f..744b6f8 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,9 @@ SOURCECRAFT_TOKEN= # (secret) персональный токен AP UPLOADS_REPO=rogachevegorushka/tgit-uploads # --- Git: коммиты в репозиторий загрузок (#7, #9) --- -GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only) +GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only); + # публичная часть — deploy key с правом записи в tgit-uploads +UPLOADS_GIT_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git # --- LLM (#17) --- LLM_PROVIDER=mock # mock | openai_compat diff --git a/infra/README.md b/infra/README.md index 483b24e..76c571e 100644 --- a/infra/README.md +++ b/infra/README.md @@ -4,4 +4,4 @@ |---|---|---| | `deploy/` | `deploy.sh`, `docker-compose.server.yml` — деплой бота на сервер | #12 | | `runner/` | `tgit-runner` — песочница и запуск сервисов на хосте | #13 | -| `uploads-template/` | шаблон репозитория загрузок: `.sourcecraft/ci.yaml`, `ruff.toml`, README | #7 | +| [`uploads-template/`](uploads-template/) | шаблон [`tgit-uploads`](https://sourcecraft.dev/rogachevegorushka/tgit-uploads): CI/CD, служебные скрипты `.tgit/`, `ruff.toml`, демо-сервис `hello` — правки вносить здесь и переносить в репозиторий загрузок | #7 | diff --git a/infra/uploads-template/.sourcecraft/ci.yaml b/infra/uploads-template/.sourcecraft/ci.yaml new file mode 100644 index 0000000..00a08a3 --- /dev/null +++ b/infra/uploads-template/.sourcecraft/ci.yaml @@ -0,0 +1,45 @@ +# CI/CD репозитория загрузок tgit. Шаблон живёт в HSE/tgit: infra/uploads-template/ — правки делать там. +# Бот не даёт менять .sourcecraft/ и .tgit/ из Telegram (SPEC C-16). +on: + push: + - workflows: [check-and-deploy] + filter: + branches: ["main"] + +workflows: + check-and-deploy: + settings: + max_cube_duration: 10m + tasks: + - name: check + cubes: + - name: compile + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim + script: + - python -m compileall -q -x '(^|/)\.' . + - name: lint + needs: ["-"] + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim + script: + - uvx ruff@0.13 check . + - name: security + needs: ["-"] + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim + script: + - uvx bandit@1.8 -q -r services -ll + - name: tests + needs: ["-"] + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim + script: + - python .tgit/run_tests.py + - name: deploy + needs: [check] + cubes: + - name: deploy + # full (non-slim) image: has git for diffing the pushed commit + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm + script: + - python .tgit/changed_services.py > changed.txt + - cat changed.txt + # Запуск через tgit-runner по SSH — #13. До этого — только список сервисов к развёртыванию. + - 'echo "deploy stub: $(wc -l < changed.txt) service(s) to deploy"' diff --git a/infra/uploads-template/.tgit/changed_services.py b/infra/uploads-template/.tgit/changed_services.py new file mode 100644 index 0000000..59cbba0 --- /dev/null +++ b/infra/uploads-template/.tgit/changed_services.py @@ -0,0 +1,48 @@ +"""Print services (`services//`) changed by the pushed commit, one per line. + +Falls back to every service when the parent commit is unavailable (first commit or shallow clone), +so a deploy never silently skips a service. +""" + +import re +import subprocess +import sys +from pathlib import Path + +NAME = re.compile(r"^[a-z][a-z0-9-]{1,30}$") + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def all_services() -> list[str]: + root = Path("services") + return sorted(p.name for p in root.iterdir() if p.is_dir() and NAME.match(p.name)) if root.is_dir() else [] + + +def changed_paths() -> list[str] | None: + try: + git("rev-parse", "--verify", "HEAD~1") + except subprocess.CalledProcessError: + try: + git("fetch", "--deepen=1", "-q") + git("rev-parse", "--verify", "HEAD~1") + except subprocess.CalledProcessError: + return None + return git("diff", "--name-only", "HEAD~1", "HEAD").splitlines() + + +def main() -> None: + paths = changed_paths() + if paths is None: + print("no parent commit — deploying all services", file=sys.stderr) + services = all_services() + else: + existing = set(all_services()) + services = sorted({p.split("/")[1] for p in paths if p.startswith("services/") and p.count("/") >= 2} & existing) + print("\n".join(services)) + + +if __name__ == "__main__": + main() diff --git a/infra/uploads-template/.tgit/run_tests.py b/infra/uploads-template/.tgit/run_tests.py new file mode 100644 index 0000000..1151fdf --- /dev/null +++ b/infra/uploads-template/.tgit/run_tests.py @@ -0,0 +1,34 @@ +"""Run pytest for every service that has tests, each in its own env with its own requirements.txt.""" + +import subprocess +import sys +from pathlib import Path + + +def has_tests(service: Path) -> bool: + return any(service.rglob("test_*.py")) or any(service.rglob("*_test.py")) + + +def main() -> int: + failed: list[str] = [] + services = sorted(p for p in Path("services").glob("*/") if p.is_dir()) if Path("services").is_dir() else [] + for service in services: + if not has_tests(service): + print(f"[{service.name}] no tests — skipped") + continue + cmd = ["uv", "run", "--no-project", "--with", "pytest"] + req = service / "requirements.txt" + if req.is_file(): + cmd += ["--with-requirements", str(req)] + cmd += ["pytest", "-q", str(service)] + print(f"[{service.name}] {' '.join(cmd)}", flush=True) + if subprocess.run(cmd).returncode != 0: + failed.append(service.name) + if failed: + print(f"tests failed: {', '.join(failed)}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/uploads-template/README.md b/infra/uploads-template/README.md new file mode 100644 index 0000000..f8d8c87 --- /dev/null +++ b/infra/uploads-template/README.md @@ -0,0 +1,32 @@ +# tgit-uploads + +Общий репозиторий команды [tgit](https://gitea.24studbank.ru/HSE/tgit): бот коммитит сюда изменения из Telegram +(раздел «📝 Коммит»), каждый push в `main` запускает CI, а изменённые сервисы разворачиваются на хосте (CD). + +## Структура + +``` +services// сервис = папка; имя — ^[a-z][a-z0-9-]{1,30}$ + main.py точка входа (python main.py) + requirements.txt зависимости (необязательно) + test_*.py тесты pytest (необязательно, запускаются в CI) + tgit.yaml настройки запуска: health_url, network (необязательно) +.sourcecraft/ci.yaml CI/CD — из Telegram не редактируется +.tgit/ служебные скрипты CI — из Telegram не редактируются +ruff.toml мягкий набор правил линтера +``` + +## CI/CD (`check-and-deploy`, push в `main`) + +| Шаг | Что делает | Падает, если | +|---|---|---| +| `compile` | `python -m compileall` | синтаксическая ошибка | +| `lint` | `ruff check` (мягкие правила) | неопределённые имена, неиспользуемые импорты, опасные значения по умолчанию | +| `security` | `bandit -ll` по `services/` | найдена уязвимость уровня medium+ | +| `tests` | `pytest` для каждого сервиса с тестами, со своими `requirements.txt` | упал тест | +| `deploy` | список изменённых сервисов → `tgit-runner` на хосте | сервис не поднялся (healthy) за 60 с | + +## Развернуть заново + +Шаблон — в репозитории бота: `infra/uploads-template/`. Новый пустой репозиторий → скопировать содержимое шаблона → +push в `main` → в настройках добавить deploy key бота с правом записи. diff --git a/infra/uploads-template/ruff.toml b/infra/uploads-template/ruff.toml new file mode 100644 index 0000000..f599a06 --- /dev/null +++ b/infra/uploads-template/ruff.toml @@ -0,0 +1,7 @@ +# Мягкий набор для кода участников: ловим то, что точно сломается при запуске, +# а не стиль — иначе CI будет красным на каждом коммите из Telegram. +target-version = "py312" +extend-exclude = [".tgit"] + +[lint] +select = ["E9", "F63", "F7", "F82", "F401", "B006", "B008"] diff --git a/infra/uploads-template/services/hello/main.py b/infra/uploads-template/services/hello/main.py new file mode 100644 index 0000000..3831d06 --- /dev/null +++ b/infra/uploads-template/services/hello/main.py @@ -0,0 +1,22 @@ +"""Demo service: logs a heartbeat line every 10 seconds.""" + +import logging +import time + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + +def greeting(n: int) -> str: + return f"hello #{n}" + + +def main() -> None: + n = 0 + while True: + n += 1 + logging.info(greeting(n)) + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/infra/uploads-template/services/hello/test_main.py b/infra/uploads-template/services/hello/test_main.py new file mode 100644 index 0000000..8f5a0ea --- /dev/null +++ b/infra/uploads-template/services/hello/test_main.py @@ -0,0 +1,5 @@ +from main import greeting + + +def test_greeting() -> None: + assert greeting(3) == "hello #3" diff --git a/infra/uploads-template/services/hello/tgit.yaml b/infra/uploads-template/services/hello/tgit.yaml new file mode 100644 index 0000000..7c72e69 --- /dev/null +++ b/infra/uploads-template/services/hello/tgit.yaml @@ -0,0 +1,3 @@ +# Настройки сервиса для tgit-runner (#13). Все поля необязательны. +# health_url: http://localhost:8080/health # HTTP-проба «Статуса»; без неё health = контейнер жив +# network: false # доступ в интернет; включает только администратор