Initial commit
This commit is contained in:
commit
08e551c8bc
5 files changed
+964
No files matched your search
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Игнорируем временные файлы
|
||||||
|
downloads/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Игнорируем состояние (история отправленных релизов)
|
||||||
|
state.json
|
||||||
|
|
||||||
|
# Игнорируем логи
|
||||||
|
sandbox.log
|
||||||
|
debug.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Системные
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Интеграция GitFlic → PT Sandbox
|
||||||
|
|
||||||
|
PoC-скрипт для автоматической выгрузки релизов из **GitFlic** в **PT Sandbox** на анализ.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Как это работает](#как-это-работает)
|
||||||
|
- [Подготовка: данные и ключи](#подготовка-данные-и-ключи)
|
||||||
|
- [GitFlic: создание API-токена](#gitflic-создание-api-токена)
|
||||||
|
- [PT Sandbox: получение API-ключа](#pt-sandbox-получение-api-ключа)
|
||||||
|
- [Установка и запуск](#установка-и-запуск)
|
||||||
|
- [Зависимости](#зависимости)
|
||||||
|
- [Настройка config.yaml](#настройка-configyaml)
|
||||||
|
- [Запуск](#запуск)
|
||||||
|
- [Конфигурация (справочник)](#конфигурация-справочник)
|
||||||
|
- [Файл состояния state.json](#файл-состояния-statejson)
|
||||||
|
- [Примеры запуска](#примеры-запуска)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
Скрипт выполняет один проход (или бесконечный цикл):
|
||||||
|
|
||||||
|
1. Загружает конфигурацию из `config.yaml`
|
||||||
|
2. Загружает историю отправленных релизов из `state.json`
|
||||||
|
3. Авторизуется в GitFlic по **API-токену** пользователя
|
||||||
|
4. Получает список проектов (все / по списку / компании / команды)
|
||||||
|
5. Для каждого проекта получает релизы
|
||||||
|
6. Фильтрует уже отправленные (ключ: `project_id + release_id + tag + version`)
|
||||||
|
7. Для каждого нового релиза:
|
||||||
|
- Скачивает **архив исходного кода** по тегу релиза
|
||||||
|
- Скачивает **прикреплённые файлы** (assets), если они есть
|
||||||
|
- Каждый файл отправляет в **PT Sandbox** отдельной задачей
|
||||||
|
- Помечает релиз как отправленный в `state.json`
|
||||||
|
8. Логирует всё в консоль и/или файл
|
||||||
|
9. Если включён цикл — ждёт заданный интервал и повторяет
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подготовка: данные и ключи
|
||||||
|
|
||||||
|
### GitFlic: создание API-токена
|
||||||
|
|
||||||
|
Скрипт использует **персональный API-токен** пользователя GitFlic с правами на чтение.
|
||||||
|
|
||||||
|
**Как создать:**
|
||||||
|
|
||||||
|
1. Войдите в GitFlic под своей учётной записью.
|
||||||
|
2. Перейдите в **Профиль → Настройки профиля → Ключи → API токены**.
|
||||||
|
_(или откройте ссылку: https://gitflic.ru/settings/tokens)_
|
||||||
|
3. Нажмите **«Создать токен»**.
|
||||||
|
4. Задайте имя, например: `sandbox-integration`
|
||||||
|
5. В разделе **Scopes (области)** отметьте:
|
||||||
|
- ✅ **api** — полный доступ к API (требуется для чтения проектов и релизов)
|
||||||
|
- ✅ **read_repository** — чтение репозиториев (для скачивания архива)
|
||||||
|
6. Нажмите **«Создать»**.
|
||||||
|
7. **Скопируйте токен сразу** — после закрытия страницы он будет недоступен.
|
||||||
|
|
||||||
|
> **Важно:** токен даёт доступ ко всем проектам, в которых ваш пользователь является участником. Храните его в безопасном месте, не добавляйте в публичные репозитории.
|
||||||
|
|
||||||
|
В `config.yaml` токен указывается в поле:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gitflic:
|
||||||
|
api_token: "glpat-XXXXXXXXXXXXXXXXXXXX"
|
||||||
|
|
||||||
|
### PT Sandbox: получение API-ключа
|
||||||
|
|
||||||
|
Способ получения API-ключа зависит от версии PT Sandbox и способа развёртывания.
|
||||||
|
|
||||||
|
**Вариант 1. Веб-интерфейс Sandbox**
|
||||||
|
|
||||||
|
1. Войдите в веб-интерфейс PT Sandbox.
|
||||||
|
2. Перейдите в **Настройки → API → Ключи доступа**.
|
||||||
|
3. Нажмите **«Создать ключ»**, скопируйте сгенерированный ключ.
|
||||||
|
|
||||||
|
**Вариант 2. Конфигурационный файл сервера**
|
||||||
|
|
||||||
|
Найдите файл конфигурации Sandbox (обычно `config.yml`, `settings.py` или `.env`).
|
||||||
|
В нём может быть определён параметр наподобие:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api_keys:
|
||||||
|
- "your-api-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 3. Обратиться к администратору**
|
||||||
|
|
||||||
|
Если Sandbox развёрнут вашей командой — уточните у DevOps/администратора, какой заголовок и значение используются для авторизации.
|
||||||
|
|
||||||
|
**Типовые параметры авторизации (могут отличаться):**
|
||||||
|
|
||||||
|
| Параметр | Наиболее частое значение |
|
||||||
|
|---|---|
|
||||||
|
| Заголовок (`auth_header`) | `X-API-Key` или `Authorization` |
|
||||||
|
| Передача | Просто ключ в заголовке или `Bearer <key>` |
|
||||||
|
|
||||||
|
В `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pt_sandbox:
|
||||||
|
api_key: "ваш-ключ"
|
||||||
|
auth_header: "X-API-Key" # или "Authorization"
|
||||||
|
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- Установка зависимостей:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройка config.yaml
|
||||||
|
|
||||||
|
Скопируйте шаблон из `config.yaml` и заполните обязательные поля:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gitflic:
|
||||||
|
base_url: "https://gitflic.ru" # или ваш self-hosted URL
|
||||||
|
api_token: "glpat-..." # API-токен пользователя (обязательно)
|
||||||
|
|
||||||
|
pt_sandbox:
|
||||||
|
base_url: "http://192.168.1.100:8080" # URL вашей инсталляции Sandbox
|
||||||
|
api_key: "..." # API-ключ (обязательно)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
Однократный проход:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Циклический запуск (каждый час):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
loop:
|
||||||
|
enabled: true
|
||||||
|
interval_seconds: 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py # работает до Ctrl+C
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Конфигурация (справочник)
|
||||||
|
|
||||||
|
### `gitflic` — подключение к GitFlic
|
||||||
|
|
||||||
|
| Поле | Тип | Обяз. | По умолч. | Описание |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `base_url` | строка | да | `https://gitflic.ru` | Базовый URL GitFlic |
|
||||||
|
| `api_token` | строка | **да** | — | API-токен пользователя |
|
||||||
|
| `project_source` | строка | нет | `all` | `all` / `list` / `company` / `team` |
|
||||||
|
| `project_list` | массив | при list | `[]` | `["namespace/project"]` |
|
||||||
|
| `project_ids` | массив | при list | `[]` | `[123, 456]` |
|
||||||
|
| `company_id` | число | при company | `null` | ID компании |
|
||||||
|
| `team_id` | число | при team | `null` | ID команды |
|
||||||
|
| `scan_mode` | строка | нет | `latest` | `latest` или `all` |
|
||||||
|
| `per_page` | число | нет | `50` | Страница пагинации |
|
||||||
|
| `download_chunk_size` | число | нет | `8388608` | Чанк скачивания (8 МБ) |
|
||||||
|
|
||||||
|
### `pt_sandbox` — подключение к PT Sandbox
|
||||||
|
|
||||||
|
| Поле | Тип | Обяз. | По умолч. | Описание |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `base_url` | строка | да | `http://localhost:8080` | URL Sandbox |
|
||||||
|
| `upload_endpoint` | строка | нет | `/api/v1/files/upload` | Путь загрузки файла |
|
||||||
|
| `auth_header` | строка | нет | `X-API-Key` | Заголовок авторизации |
|
||||||
|
| `api_key` | строка | **да** | — | Ключ API |
|
||||||
|
| `file_field_name` | строка | нет | `file` | Имя поля multipart |
|
||||||
|
| `extra_fields` | словарь | нет | `{source: gitflic}` | Доп. поля запроса |
|
||||||
|
| `timeout_connect` | число | нет | `30` | Таймаут соединения (с) |
|
||||||
|
| `timeout_read` | число | нет | `300` | Таймаут чтения (с) |
|
||||||
|
|
||||||
|
### `state`, `logging`, `loop`
|
||||||
|
|
||||||
|
| Раздел | Поле | Тип | По умолч. | Описание |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `state` | `file_path` | строка | `state.json` | Файл состояния |
|
||||||
|
| `logging` | `level` | строка | `INFO` | DEBUG, INFO, WARNING, ERROR |
|
||||||
|
| `logging` | `format` | строка | `%(asctime)s...` | Формат лога |
|
||||||
|
| `logging` | `file` | строка | — | Файл лога (иначе только консоль) |
|
||||||
|
| `loop` | `enabled` | bool | `false` | Вкл. бесконечный цикл |
|
||||||
|
| `loop` | `interval_seconds` | число | `3600` | Интервал (с) |
|
||||||
|
|
||||||
|
|
||||||
|
## Файл состояния `state.json`
|
||||||
|
|
||||||
|
Автоматически создаётся при первом запуске и хранит историю отправленных релизов.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sent_releases": {
|
||||||
|
"1:42:v1.0:v1.0": {
|
||||||
|
"project_id": 1,
|
||||||
|
"project_name": "org/my-project",
|
||||||
|
"release_id": 42,
|
||||||
|
"release_tag": "v1.0",
|
||||||
|
"release_version": "v1.0",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": "source_org_my-project_v1.0.zip",
|
||||||
|
"type": "source_archive",
|
||||||
|
"sandbox_task_id": "task-uuid-12345"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sent_at": "2026-09-16T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы **переотправить** релиз — удалите соответствующую запись из `state.json` и перезапустите скрипт.
|
||||||
|
|
||||||
|
|
||||||
|
## Примеры запуска
|
||||||
|
|
||||||
|
**1. Однократно, только последний релиз:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. В цикле, проверка каждые 30 минут:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
loop:
|
||||||
|
enabled: true
|
||||||
|
interval_seconds: 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py # Ctrl+C для остановки
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Только проекты компании ID=5:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gitflic:
|
||||||
|
project_source: "company"
|
||||||
|
company_id: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Конкретные проекты по имени:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gitflic:
|
||||||
|
project_source: "list"
|
||||||
|
project_list:
|
||||||
|
- "my-namespace/app-frontend"
|
||||||
|
- "my-namespace/app-backend"
|
||||||
|
project_ids: [101, 203]
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Детальное логирование:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
level: "DEBUG"
|
||||||
|
file: "debug.log"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
```
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Конфигурация интеграции GitFlic → PT Sandbox
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
gitflic:
|
||||||
|
# Базовый URL GitFlic (облачный или self-hosted)
|
||||||
|
base_url: "https://gitflic.ru"
|
||||||
|
# API-токен пользователя (read-only, scope: api, read_repository)
|
||||||
|
api_token: "" # required
|
||||||
|
|
||||||
|
# Как получать проекты:
|
||||||
|
# "all" — все доступные токену проекты
|
||||||
|
# "list" — только из списка project_ids/namespace+name ниже
|
||||||
|
# "company" — проекты внутри указанной компании (company_id)
|
||||||
|
# "team" — проекты внутри указанной команды (team_id)
|
||||||
|
project_source: "all"
|
||||||
|
|
||||||
|
# Список проектов, если project_source = "list"
|
||||||
|
# Указывать в формате: ["namespace/project-name"] или ["owner/project-name"]
|
||||||
|
# Либо project_ids: [123, 456]
|
||||||
|
project_list: []
|
||||||
|
project_ids: []
|
||||||
|
|
||||||
|
# ID компании или команды (если project_source = "company" или "team")
|
||||||
|
company_id: null
|
||||||
|
team_id: null
|
||||||
|
|
||||||
|
# Режим сканирования релизов:
|
||||||
|
# "latest" — только последний (по дате создания) неотправленный релиз
|
||||||
|
# "all" — все неотправленные релизы (с пагинацией)
|
||||||
|
scan_mode: "latest"
|
||||||
|
|
||||||
|
# Макисмальное количество релизов на страницу (пагинация)
|
||||||
|
per_page: 50
|
||||||
|
|
||||||
|
# Размер чанка для скачивания файлов (в байтах)
|
||||||
|
download_chunk_size: 8388608 # 8 MB
|
||||||
|
|
||||||
|
|
||||||
|
pt_sandbox:
|
||||||
|
# Базовый URL PT Sandbox (локальная инсталляция)
|
||||||
|
base_url: "http://localhost:8080"
|
||||||
|
|
||||||
|
# Endpoint для загрузки файла на анализ
|
||||||
|
upload_endpoint: "/api/v1/files/upload"
|
||||||
|
|
||||||
|
# Заголовок и значение для авторизации в Sandbox
|
||||||
|
auth_header: "X-API-Key"
|
||||||
|
api_key: "" # required
|
||||||
|
|
||||||
|
# Формат multipart-поля с файлом
|
||||||
|
file_field_name: "file"
|
||||||
|
|
||||||
|
# Дополнительные поля, передаваемые с файлом (опционально)
|
||||||
|
extra_fields:
|
||||||
|
# Например: source: "gitflic"
|
||||||
|
source: "gitflic"
|
||||||
|
|
||||||
|
# Таймауты (секунды)
|
||||||
|
timeout_connect: 30
|
||||||
|
timeout_read: 300 # 5 минут на передачу большого файла
|
||||||
|
|
||||||
|
|
||||||
|
state:
|
||||||
|
# Путь к файлу состояния (хранит историю отправленных релизов)
|
||||||
|
file_path: "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
logging:
|
||||||
|
# Уровень: DEBUG, INFO, WARNING, ERROR
|
||||||
|
level: "INFO"
|
||||||
|
# Формат лога
|
||||||
|
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
# Вывод в файл (если указан) + в консоль
|
||||||
|
file: "sandbox.log"
|
||||||
|
|
||||||
|
|
||||||
|
# Главный цикл (периодический запуск)
|
||||||
|
loop:
|
||||||
|
# Включить бесконечный цикл
|
||||||
|
enabled: false
|
||||||
|
# Интервал между итерациями (секунды). Если enabled: false — однократный проход.
|
||||||
|
interval_seconds: 3600 # 1 час
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Интеграция GitFlic → PT Sandbox
|
||||||
|
=================================
|
||||||
|
PoC-скрипт для автоматической выгрузки релизов из GitFlic
|
||||||
|
в PT Sandbox на анализ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger("sandbox_gitflic")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(cfg: dict) -> None:
|
||||||
|
"""Настройка логирования из конфигурации."""
|
||||||
|
log_cfg = cfg.get("logging", {})
|
||||||
|
level_name = log_cfg.get("level", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
fmt = log_cfg.get("format", "%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
|
handlers: List[logging.Handler] = [logging.StreamHandler(sys.stdout)]
|
||||||
|
log_file = log_cfg.get("file")
|
||||||
|
if log_file:
|
||||||
|
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
||||||
|
logging.basicConfig(level=level, format=fmt, handlers=handlers)
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str = "config.yaml") -> dict:
|
||||||
|
"""Загрузить YAML-конфигурацию."""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
logger.error("Файл конфигурации не найден: %s", path.absolute())
|
||||||
|
sys.exit(1)
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
cfg = yaml.safe_load(fh)
|
||||||
|
logger.info("Конфигурация загружена из %s", path)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Управление состоянием
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_state(path: str) -> dict:
|
||||||
|
"""
|
||||||
|
Загрузить состояние из JSON-файла.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
{
|
||||||
|
"sent_releases": {
|
||||||
|
"<project_id>:<release_id>:<tag>:<version>": {
|
||||||
|
"project_id": int,
|
||||||
|
"project_name": str,
|
||||||
|
"release_id": int,
|
||||||
|
"release_tag": str,
|
||||||
|
"release_version": str,
|
||||||
|
"files": [{"filename": str, "sandbox_task_id": str|null}],
|
||||||
|
"sent_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
path_obj = Path(path)
|
||||||
|
if not path_obj.exists():
|
||||||
|
logger.info("Файл состояния не найден. Начинаем с пустого.")
|
||||||
|
return {"sent_releases": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path_obj, "r", encoding="utf-8") as fh:
|
||||||
|
state = json.load(fh)
|
||||||
|
logger.info("Состояние загружено из %s (%d релизов отправлено)",
|
||||||
|
path, len(state.get("sent_releases", {})))
|
||||||
|
return state
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.error("Ошибка загрузки состояния из %s: %s. Начинаем с пустого.",
|
||||||
|
path, exc)
|
||||||
|
return {"sent_releases": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict, path: str) -> None:
|
||||||
|
"""Сохранить состояние в JSON-файл (атомарно через rename)."""
|
||||||
|
path_obj = Path(path)
|
||||||
|
tmp_path = path_obj.with_suffix(".json.tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(state, fh, ensure_ascii=False, indent=2)
|
||||||
|
tmp_path.replace(path_obj)
|
||||||
|
logger.debug("Состояние сохранено в %s", path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Ошибка сохранения состояния в %s: %s", path, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def make_release_key(project_id: int, release_id: int,
|
||||||
|
tag: str, version: str) -> str:
|
||||||
|
"""Сформировать ключ для идентификации релиза."""
|
||||||
|
return f"{project_id}:{release_id}:{tag}:{version}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_release_sent(state: dict, project_id: int, release_id: int,
|
||||||
|
tag: str, version: str) -> bool:
|
||||||
|
"""Проверить, был ли релиз уже отправлен."""
|
||||||
|
key = make_release_key(project_id, release_id, tag, version)
|
||||||
|
return key in state.get("sent_releases", {})
|
||||||
|
|
||||||
|
|
||||||
|
def mark_release_sent(state: dict, project_id: int, project_name: str,
|
||||||
|
release_id: int, tag: str, version: str,
|
||||||
|
files: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Пометить релиз как отправленный."""
|
||||||
|
key = make_release_key(project_id, release_id, tag, version)
|
||||||
|
if "sent_releases" not in state:
|
||||||
|
state["sent_releases"] = {}
|
||||||
|
|
||||||
|
state["sent_releases"][key] = {
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": project_name,
|
||||||
|
"release_id": release_id,
|
||||||
|
"release_tag": tag,
|
||||||
|
"release_version": version,
|
||||||
|
"files": files,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
logger.info("Релиз помечен как отправленный: %s", key)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# API GitFlic
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GitFlicClient:
|
||||||
|
"""Клиент для REST API GitFlic."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict):
|
||||||
|
gf_cfg = cfg["gitflic"]
|
||||||
|
self.base_url = gf_cfg["base_url"].rstrip("/")
|
||||||
|
self.api_token = gf_cfg["api_token"]
|
||||||
|
self.per_page = gf_cfg.get("per_page", 50)
|
||||||
|
self.chunk_size = gf_cfg.get("download_chunk_size", 8 * 1024 * 1024)
|
||||||
|
|
||||||
|
if not self.api_token:
|
||||||
|
logger.error("API-токен GitFlic не задан в конфигурации!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Authorization": f"Bearer {self.api_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "Sandbox-GitFlic-PoC/1.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Выполнить HTTP-запрос к API GitFlic."""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
try:
|
||||||
|
resp = self._session.request(method, url, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("Тайм-аут при запросе %s %s", method, url)
|
||||||
|
raise
|
||||||
|
except requests.exceptions.HTTPError as exc:
|
||||||
|
logger.error("HTTP-ошибка %s: %s - %s",
|
||||||
|
resp.status_code, resp.reason, resp.text[:500])
|
||||||
|
raise
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
logger.error("Ошибка запроса %s %s: %s", method, url, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _paginated_get(self, path: str, params: Optional[dict] = None) -> List[dict]:
|
||||||
|
"""Получить все страницы пагинированного GET-запроса."""
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
params.setdefault("per_page", self.per_page)
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
all_items: List[dict] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params["page"] = page
|
||||||
|
resp = self._request("GET", path, params=params)
|
||||||
|
items = resp.json()
|
||||||
|
if not isinstance(items, list):
|
||||||
|
all_items = items
|
||||||
|
break
|
||||||
|
|
||||||
|
all_items.extend(items)
|
||||||
|
|
||||||
|
total_pages = resp.headers.get("X-Total-Pages")
|
||||||
|
if total_pages is not None:
|
||||||
|
if page >= int(total_pages):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
def get_projects(self) -> List[dict]:
|
||||||
|
"""GET /api/v1/projects."""
|
||||||
|
return self._paginated_get("/api/v1/projects", {"membership": True})
|
||||||
|
|
||||||
|
def get_company_projects(self, company_id: int) -> List[dict]:
|
||||||
|
"""GET /api/v1/companies/{id}/projects."""
|
||||||
|
return self._paginated_get(f"/api/v1/companies/{company_id}/projects")
|
||||||
|
|
||||||
|
def resolve_projects(self, cfg: dict) -> List[dict]:
|
||||||
|
"""Определить список проектов для сканирования."""
|
||||||
|
gf_cfg = cfg["gitflic"]
|
||||||
|
source = gf_cfg.get("project_source", "all")
|
||||||
|
|
||||||
|
if source == "list":
|
||||||
|
names = gf_cfg.get("project_list", [])
|
||||||
|
ids = gf_cfg.get("project_ids", [])
|
||||||
|
projects = []
|
||||||
|
for name in names:
|
||||||
|
proj = self.get_project_by_path(name)
|
||||||
|
if proj:
|
||||||
|
projects.append(proj)
|
||||||
|
else:
|
||||||
|
logger.warning("Проект не найден: %s", name)
|
||||||
|
for pid in ids:
|
||||||
|
proj = self.get_project(pid)
|
||||||
|
if proj:
|
||||||
|
projects.append(proj)
|
||||||
|
else:
|
||||||
|
logger.warning("Проект не найден по ID: %d", pid)
|
||||||
|
return projects
|
||||||
|
|
||||||
|
if source == "company":
|
||||||
|
cid = gf_cfg.get("company_id")
|
||||||
|
if not cid:
|
||||||
|
logger.error("Не указан company_id при project_source=company")
|
||||||
|
return []
|
||||||
|
return self.get_company_projects(cid)
|
||||||
|
|
||||||
|
if source == "team":
|
||||||
|
tid = gf_cfg.get("team_id")
|
||||||
|
if not tid:
|
||||||
|
logger.error("Не указан team_id при project_source=team")
|
||||||
|
return []
|
||||||
|
return self.get_team_projects(tid)
|
||||||
|
|
||||||
|
return self.get_projects()
|
||||||
|
|
||||||
|
def get_project(self, project_id: int) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
resp = self._request("GET", f"/api/v1/projects/{project_id}")
|
||||||
|
return resp.json()
|
||||||
|
except requests.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_project_by_path(self, path: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
resp = self._request("GET", f"/api/v1/projects/{path}")
|
||||||
|
return resp.json()
|
||||||
|
except requests.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_releases(self, project_id: int) -> List[dict]:
|
||||||
|
"""GET /api/v1/projects/{id}/releases."""
|
||||||
|
return self._paginated_get(
|
||||||
|
f"/api/v1/projects/{project_id}/releases",
|
||||||
|
{"per_page": self.per_page},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_release_assets(self, project_id: int, release_id: int) -> List[dict]:
|
||||||
|
"""Получить assets релиза."""
|
||||||
|
try:
|
||||||
|
resp = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/api/v1/projects/{project_id}/releases/{release_id}/assets",
|
||||||
|
)
|
||||||
|
items = resp.json()
|
||||||
|
if isinstance(items, list):
|
||||||
|
return items
|
||||||
|
if isinstance(items, dict):
|
||||||
|
return items.get("assets", items.get("links", []))
|
||||||
|
return []
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.debug("Assets не получены: %s/%s", project_id, release_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def download_file(self, url: str, local_path: Path) -> Path:
|
||||||
|
"""Скачать файл по ссылке."""
|
||||||
|
if not url.startswith("http"):
|
||||||
|
url = f"{self.base_url}{url}"
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if url.startswith(self.base_url):
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||||
|
|
||||||
|
logger.info("Скачивание: %s -> %s", url, local_path)
|
||||||
|
resp = self._session.get(url, headers=headers, stream=True,
|
||||||
|
timeout=(30, 300))
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(local_path, "wb") as fh:
|
||||||
|
for chunk in resp.iter_content(chunk_size=self.chunk_size):
|
||||||
|
if chunk:
|
||||||
|
fh.write(chunk)
|
||||||
|
logger.debug("Скачан файл: %s (%d bytes)",
|
||||||
|
local_path, local_path.stat().st_size)
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
def get_release_source_archive_url(self, project_id: int,
|
||||||
|
tag_name: str) -> str:
|
||||||
|
"""URL архива исходного кода по тегу релиза."""
|
||||||
|
return (f"{self.base_url}/api/v1/projects/{project_id}"
|
||||||
|
f"/repository/archive.zip?ref={tag_name}")
|
||||||
|
|
||||||
|
def get_release_asset_download_url(self, project_id: int,
|
||||||
|
release_id: int,
|
||||||
|
asset_filename: str) -> str:
|
||||||
|
"""URL для скачивания прикреплённого файла (asset)."""
|
||||||
|
return (f"{self.base_url}/api/v1/projects/{project_id}"
|
||||||
|
f"/releases/{release_id}/downloads/{asset_filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Клиент PT Sandbox
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PTSandboxClient:
|
||||||
|
"""Клиент для REST API PT Sandbox."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict):
|
||||||
|
sb_cfg = cfg["pt_sandbox"]
|
||||||
|
self.base_url = sb_cfg["base_url"].rstrip("/")
|
||||||
|
self.upload_endpoint = sb_cfg.get("upload_endpoint", "/api/v1/files/upload")
|
||||||
|
self.auth_header = sb_cfg.get("auth_header", "X-API-Key")
|
||||||
|
self.api_key = sb_cfg.get("api_key", "")
|
||||||
|
self.file_field_name = sb_cfg.get("file_field_name", "file")
|
||||||
|
self.extra_fields = sb_cfg.get("extra_fields", {})
|
||||||
|
self.timeout_connect = sb_cfg.get("timeout_connect", 30)
|
||||||
|
self.timeout_read = sb_cfg.get("timeout_read", 300)
|
||||||
|
|
||||||
|
if not self.api_key:
|
||||||
|
logger.error("API-ключ PT Sandbox не задан в конфигурации!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
self.auth_header: self.api_key,
|
||||||
|
"User-Agent": "Sandbox-GitFlic-PoC/1.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
def upload_file(self, file_path: Path) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Отправить файл в PT Sandbox на анализ.
|
||||||
|
Возвращает ID задачи (task_id) или None при ошибке.
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}{self.upload_endpoint}"
|
||||||
|
logger.info("Отправка файла в Sandbox: %s (%d bytes)",
|
||||||
|
file_path.name, file_path.stat().st_size)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
files = {self.file_field_name: (file_path.name, fh)}
|
||||||
|
fields = dict(self.extra_fields)
|
||||||
|
|
||||||
|
resp = self._session.post(
|
||||||
|
url, files=files, data=fields,
|
||||||
|
timeout=(self.timeout_connect, self.timeout_read),
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code in (200, 201, 202):
|
||||||
|
result = resp.json()
|
||||||
|
task_id = (result.get("task_id") or result.get("id")
|
||||||
|
or result.get("uuid") or str(result))
|
||||||
|
logger.info("Файл %s принят Sandbox. task_id=%s",
|
||||||
|
file_path.name, task_id)
|
||||||
|
return str(task_id)
|
||||||
|
else:
|
||||||
|
logger.error("Sandbox вернул %s: %s",
|
||||||
|
resp.status_code, resp.text[:500])
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("Тайм-аут при отправке файла %s в Sandbox",
|
||||||
|
file_path.name)
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
logger.error("Ошибка отправки файла %s в Sandbox: %s",
|
||||||
|
file_path.name, exc)
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Ошибка чтения файла %s: %s", file_path.name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Вспомогательные функции
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_temp_dir() -> Path:
|
||||||
|
"""Получить временную директорию для скачанных файлов."""
|
||||||
|
tmp = Path("downloads")
|
||||||
|
tmp.mkdir(parents=True, exist_ok=True)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Основная логика сканирования
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
|
||||||
|
state: dict, cfg: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Основная функция:
|
||||||
|
1. Получить проекты
|
||||||
|
2. Для каждого проекта релизы
|
||||||
|
3. Отфильтровать отправленные
|
||||||
|
4. Скачать файлы (архив + assets)
|
||||||
|
5. Отправить в Sandbox
|
||||||
|
6. Обновить состояние
|
||||||
|
"""
|
||||||
|
projects = gf.resolve_projects(cfg)
|
||||||
|
if not projects:
|
||||||
|
logger.warning("Нет проектов для сканирования.")
|
||||||
|
return state
|
||||||
|
|
||||||
|
scan_mode = cfg["gitflic"].get("scan_mode", "latest")
|
||||||
|
tmp_dir = get_temp_dir()
|
||||||
|
state = deepcopy(state)
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
pid = project.get("id")
|
||||||
|
pname = project.get("path_with_namespace") or project.get("name", str(pid))
|
||||||
|
logger.info("Обработка проекта: %s (id=%s)", pname, pid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
releases = gf.get_releases(pid)
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.error("Пропускаем проект %s - ошибка получения релизов", pname)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not releases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
releases.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||||
|
|
||||||
|
if scan_mode == "latest":
|
||||||
|
rel = releases[0]
|
||||||
|
rel_id = rel.get("id")
|
||||||
|
rel_tag = rel.get("tag_name", "")
|
||||||
|
rel_version = rel.get("name", "")
|
||||||
|
|
||||||
|
if is_release_sent(state, pid, rel_id, rel_tag, rel_version):
|
||||||
|
logger.info("Последний релиз уже отправлен. Пропускаем.")
|
||||||
|
continue
|
||||||
|
releases_to_process = [rel]
|
||||||
|
else:
|
||||||
|
releases_to_process = [
|
||||||
|
r for r in releases
|
||||||
|
if not is_release_sent(state, pid, r.get("id"),
|
||||||
|
r.get("tag_name", ""), r.get("name", ""))
|
||||||
|
]
|
||||||
|
if not releases_to_process:
|
||||||
|
logger.info("Нет новых релизов в проекте %s", pname)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for rel in releases_to_process:
|
||||||
|
rel_id = rel.get("id")
|
||||||
|
rel_tag = rel.get("tag_name", "")
|
||||||
|
rel_version = rel.get("name", "")
|
||||||
|
rel_key = make_release_key(pid, rel_id, rel_tag, rel_version)
|
||||||
|
logger.info("Новый релиз: %s (tag=%s, version=%s)",
|
||||||
|
rel_key, rel_tag, rel_version)
|
||||||
|
|
||||||
|
# Собираем файлы для скачивания
|
||||||
|
files_to_download: List[Dict[str, Any]] = []
|
||||||
|
local_files: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 1. Архив исходного кода по тегу
|
||||||
|
source_url = gf.get_release_source_archive_url(pid, rel_tag)
|
||||||
|
sf = f"source_{pname.replace('/', '_')}_{rel_tag}.zip"
|
||||||
|
files_to_download.append({"type": "source_archive", "url": source_url, "filename": sf})
|
||||||
|
|
||||||
|
# 2. Assets (прикреплённые файлы)
|
||||||
|
try:
|
||||||
|
assets = gf.get_release_assets(pid, rel_id)
|
||||||
|
for asset in assets:
|
||||||
|
aname = asset.get("name") or asset.get("filename", "")
|
||||||
|
if not aname:
|
||||||
|
continue
|
||||||
|
aurl = gf.get_release_asset_download_url(pid, rel_id, aname)
|
||||||
|
files_to_download.append({"type": "asset", "url": aurl, "filename": aname})
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.debug("Assets для %s не получены", rel_key)
|
||||||
|
|
||||||
|
# Скачиваем все файлы
|
||||||
|
for fi in files_to_download:
|
||||||
|
safe = fi["filename"].replace("/", "_").replace("\\", "_")
|
||||||
|
lpath = tmp_dir / f"{pid}_{rel_id}_{safe}"
|
||||||
|
try:
|
||||||
|
gf.download_file(fi["url"], lpath)
|
||||||
|
local_files.append({"filename": fi["filename"], "type": fi["type"], "local_path": str(lpath)})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("Ошибка скачивания %s: %s", fi["filename"], exc)
|
||||||
|
|
||||||
|
# Отправляем каждый файл в Sandbox
|
||||||
|
sent_files: List[Dict[str, Any]] = []
|
||||||
|
for lf in local_files:
|
||||||
|
lpath = Path(lf["local_path"])
|
||||||
|
if not lpath.exists():
|
||||||
|
logger.warning("Файл %s не найден", lpath)
|
||||||
|
continue
|
||||||
|
task_id = sb.upload_file(lpath)
|
||||||
|
sent_files.append({"filename": lf["filename"], "type": lf["type"], "sandbox_task_id": task_id})
|
||||||
|
try:
|
||||||
|
lpath.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Не удалось удалить %s: %s", lpath, exc)
|
||||||
|
|
||||||
|
mark_release_sent(state, pid, pname, rel_id, rel_tag, rel_version, sent_files)
|
||||||
|
logger.info("Релиз %s обработан: %d файлов отправлено", rel_key, len(sent_files))
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Точка входа
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Главная функция."""
|
||||||
|
cfg = load_config()
|
||||||
|
setup_logging(cfg)
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Запуск интеграции GitFlic -> PT Sandbox")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
gf = GitFlicClient(cfg)
|
||||||
|
sb = PTSandboxClient(cfg)
|
||||||
|
|
||||||
|
loop_cfg = cfg.get("loop", {})
|
||||||
|
loop_enabled = loop_cfg.get("enabled", False)
|
||||||
|
interval = loop_cfg.get("interval_seconds", 3600)
|
||||||
|
state_path = cfg.get("state", {}).get("file_path", "state.json")
|
||||||
|
|
||||||
|
iteration = 0
|
||||||
|
while True:
|
||||||
|
iteration += 1
|
||||||
|
logger.info("Итерация #%d", iteration)
|
||||||
|
|
||||||
|
state = load_state(state_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_state = scan_new_releases(gf, sb, state, cfg)
|
||||||
|
save_state(new_state, state_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Критическая ошибка на итерации #%d: %s",
|
||||||
|
iteration, exc)
|
||||||
|
try:
|
||||||
|
save_state(state, state_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not loop_enabled:
|
||||||
|
logger.info("Однократный проход завершён.")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info("Ожидание %d секунд до следующей итерации...", interval)
|
||||||
|
try:
|
||||||
|
time.sleep(interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Получен сигнал прерывания. Завершение.")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info("Работа завершена.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests>=2.28.0
|
||||||
|
PyYAML>=6.0
|
||||||
Reference in new issue
Block a user