Загрузка данных
докер файл
RUN chmod +x /app/scripts/entrypoint.sh
# wait-for-db -> проверка таблиц PostgreSQL -> flask run
CMD ["/app/scripts/entrypoint.sh"]
migrations\versions\0001_initial_schema.py
"""create full testops schema if tables are missing
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-08-19 00:00:00.000000
Идемпотентная baseline-миграция: на пустой БД создаёт все таблицы,
на уже существующей схеме ничего не ломает.
downgrade удаляет только таблицы, которые upgrade реально создал.
Таблицы, которые уже были в БД до этой ревизии, не трогает.
"""
from typing import Set
import sqlalchemy as sa
from alembic import op
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
# Служебная таблица: какие объекты создал именно этот upgrade.
CREATED_TABLES_META = "_0001_created_tables"
DROP_ORDER = (
"attachments",
"test_case_tags",
"test_case_suites",
"test_case_steps",
"testrun_results",
"test_suites",
"tags",
"test_cases",
)
def _table_names() -> Set[str]:
"""Возвращает имена таблиц в текущей схеме public."""
return set(sa.inspect(op.get_bind()).get_table_names())
def _remember_created(created: list[str]) -> None:
"""Сохраняет список таблиц, созданных этим upgrade, для безопасного downgrade."""
if not created:
return
op.create_table(
CREATED_TABLES_META,
sa.Column("table_name", sa.String(64), primary_key=True),
)
created_table = sa.table(
CREATED_TABLES_META,
sa.column("table_name", sa.String),
)
op.bulk_insert(
created_table,
[{"table_name": name} for name in created],
)
def upgrade() -> None:
"""Создаёт отсутствующие таблицы схемы TestOps и запоминает, какие именно созданы."""
existing = _table_names()
created: list[str] = []
if "test_cases" not in existing:
op.create_table(
"test_cases",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("preconditions", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("expected_result", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"is_deleted",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.UniqueConstraint("name", "is_deleted", name="uq_testcase_name_active"),
)
op.create_index("ix_test_cases_is_deleted", "test_cases", ["is_deleted"])
created.append("test_cases")
if "tags" not in existing:
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(100), nullable=False, unique=True),
sa.Column(
"is_deleted",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.create_index("ix_tags_is_deleted", "tags", ["is_deleted"])
created.append("tags")
if "test_suites" not in existing:
op.create_table(
"test_suites",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"parent_id",
sa.Integer(),
sa.ForeignKey("test_suites.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"is_deleted",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
created.append("test_suites")
if "test_case_steps" not in existing:
op.create_table(
"test_case_steps",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"test_case_id",
sa.Integer(),
sa.ForeignKey("test_cases.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("expected", sa.Text(), nullable=True),
sa.Column("attachments", sa.Text(), nullable=True),
sa.UniqueConstraint(
"test_case_id", "position", name="uq_steps_per_case_position"
),
)
op.create_index("ix_steps_test_case_id", "test_case_steps", ["test_case_id"])
created.append("test_case_steps")
if "test_case_suites" not in existing:
op.create_table(
"test_case_suites",
sa.Column(
"test_case_id",
sa.Integer(),
sa.ForeignKey("test_cases.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"suite_id",
sa.Integer(),
sa.ForeignKey("test_suites.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("position", sa.Integer(), nullable=True),
)
created.append("test_case_suites")
if "test_case_tags" not in existing:
op.create_table(
"test_case_tags",
sa.Column(
"test_case_id",
sa.Integer(),
sa.ForeignKey("test_cases.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"tag_id",
sa.Integer(),
sa.ForeignKey("tags.id", ondelete="CASCADE"),
primary_key=True,
),
)
created.append("test_case_tags")
if "attachments" not in existing:
op.create_table(
"attachments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"test_case_id",
sa.Integer(),
sa.ForeignKey("test_cases.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("original_filename", sa.String(1024), nullable=False),
sa.Column("object_name", sa.String(2048), nullable=False, unique=True),
sa.Column("bucket", sa.String(255), nullable=False),
sa.Column("content_type", sa.String(255), nullable=True),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_index("ix_attachments_test_case_id", "attachments", ["test_case_id"])
created.append("attachments")
if "testrun_results" not in existing:
op.create_table(
"testrun_results",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("run_name", sa.String(255), nullable=False),
sa.Column("start_date", sa.DateTime(), nullable=True),
sa.Column("end_date", sa.DateTime(), nullable=True),
sa.Column("stand", sa.String(128), nullable=True),
sa.Column("status", sa.String(50), nullable=False),
sa.Column("passed_count", sa.Integer(), nullable=True),
sa.Column("failed_count", sa.Integer(), nullable=True),
sa.Column("broken_count", sa.Integer(), nullable=True),
sa.Column("skipped_count", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("is_deleted", sa.Boolean(), server_default=sa.false()),
)
op.create_index("ix_testrun_results_stand", "testrun_results", ["stand"])
created.append("testrun_results")
_remember_created(created)
def downgrade() -> None:
"""Удаляет только таблицы, созданные upgrade этой ревизии.
Если все таблицы уже были до миграции, служебной метки нет - ничего не дропаем.
"""
existing = _table_names()
if CREATED_TABLES_META not in existing:
return
bind = op.get_bind()
created = {
row[0]
for row in bind.execute(sa.text(f"SELECT table_name FROM {CREATED_TABLES_META}"))
}
for name in DROP_ORDER:
if name in created and name in existing:
op.drop_table(name)
op.drop_table(CREATED_TABLES_META)
scripts\entrypoint.sh
#!/bin/sh
#
# Старт flask-app: дождаться PostgreSQL и проверить, что таблицы схемы на месте.
#
# 1) Цикл до 30 попыток: SELECT 1 через SQLAlchemy.
# Postgres после docker compose up поднимается не мгновенно; без ожидания
# Flask падает на первом запросе к БД.
# 2) python scripts/validate_postgres_tables.py
# Сверяет 8 обязательных таблиц. Если все на месте - upgrade не вызывается.
# Если таблиц нет (новый стенд) - один раз flask db upgrade, затем проверка снова.
# На сервере с уже живой схемой это no-op после первой успешной проверки.
# 3) exec flask run - подменяет shell процессом Flask, чтобы Docker слал
# SIGTERM сразу приложению, а не оболочке.
#
set -eu
echo "Waiting for PostgreSQL..."
i=0
while [ "$i" -lt 30 ]; do
if python -c "
from sqlalchemy import text
from app import create_app, db
app = create_app()
with app.app_context():
with db.engine.connect() as conn:
conn.execute(text('SELECT 1'))
"; then
break
fi
i=$((i + 1))
echo "Database is not ready yet ($i/30), retrying..."
sleep 2
done
if [ "$i" -ge 30 ]; then
echo "PostgreSQL did not become ready in time"
exit 1
fi
echo "Validating PostgreSQL tables..."
if python scripts/validate_postgres_tables.py; then
:
else
echo "Schema incomplete, applying flask db upgrade once..."
flask db upgrade
python scripts/validate_postgres_tables.py
fi
echo "Starting Flask..."
exec flask run --host="${FLASK_RUN_HOST:-0.0.0.0}" --port="${FLASK_RUN_PORT:-5000}"
scripts\rebuild_testruns_from_minio.py
"""
Восстанавливает строки testrun_results из объектов бакета allure-results-bucket.
Скрипт группирует объекты по run_name и создаёт запись в PostgreSQL,
только если такого run_name ещё нет.
Запуск из /app в контейнере приложения:
python scripts/rebuild_testruns_from_minio.py --dry-run
python scripts/rebuild_testruns_from_minio.py
"""
from __future__ import annotations
import argparse
import datetime
import io
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, DefaultDict, Iterable, Mapping, Optional, Protocol, Sequence
from werkzeug.datastructures import FileStorage
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import constants as const
from app import create_app, db
from app.clients import MinioClient
from app.models import TestResult
from helpers import testrun_helpers
class MinioObjectLike(Protocol):
"""Минимальный контракт объекта из MinioClient.list_objects."""
object_name: Optional[str]
last_modified: Optional[datetime.datetime]
def _parse_args() -> argparse.Namespace:
"""Разбирает аргументы CLI."""
parser = argparse.ArgumentParser(
description="Восстановить прогоны TestOps из MinIO allure-results-bucket"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Только показать, что будет вставлено, без записи в БД",
)
return parser.parse_args()
def _to_naive_utc(value: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
"""Приводит datetime к naive UTC: колонка testrun_results.created_at без tz."""
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(datetime.timezone.utc).replace(tzinfo=None)
def _parse_run_datetime(value: Optional[str]) -> Optional[datetime.datetime]:
"""Парсит строку времени прогона формата DB_DATE_FORMAT в datetime."""
if not value:
return None
return datetime.datetime.strptime(value, const.DB_DATE_FORMAT)
def _read_object_bytes(minio_client: MinioClient, object_name: str) -> bytes:
"""Скачивает тело объекта MinIO целиком и закрывает соединение."""
response = minio_client.get_object_stream(
const.ALLURE_RESULTS_BUCKET_NAME, object_name
)
payload = response.read()
response.close()
response.release_conn()
return payload
def _as_file_storage(object_name: str, payload: bytes) -> FileStorage:
"""Оборачивает байты в FileStorage, как при HTTP-загрузке в /upload.
check_all_tests_passed_run ожидает объекты с .filename и файловым потоком.
В имени оставляем только basename: allure-results.tar.gz или *-result.json.
"""
filename = os.path.basename(object_name.replace("\\", "/")) or object_name
return FileStorage(stream=io.BytesIO(payload), filename=filename)
def _group_objects_by_run(
minio_client: MinioClient,
) -> DefaultDict[str, list[MinioObjectLike]]:
"""Группирует объекты бакета по run_name - первому сегменту ключа.
MinIO хранит results как:
run_12_20250801_120000/allure-results.tar.gz
run_12_20250801_120000/environment.properties или .json - это legacy
split("/", 1) один раз режет ключ на prefix и остаток пути.
Ключ без слэша (файл в корне бакета) к прогону не относится - пропускаем.
"""
groups: DefaultDict[str, list[MinioObjectLike]] = defaultdict(list)
for obj in minio_client.list_objects(const.ALLURE_RESULTS_BUCKET_NAME, prefix=""):
name = obj.object_name or ""
if not name or name.endswith("/"):
continue
parts = name.split("/", 1)
if len(parts) < 2:
continue
run_name, _relative_path = parts
groups[run_name].append(obj)
return groups
def _rewind(file_storage: FileStorage) -> None:
"""Ставит курсор потока в начало.
BytesIO/FileStorage после .read() оказываются в EOF. Следующий парсер
иначе прочитает 0 байт. seek(0) - rewind файлового курсора.
"""
file_storage.stream.seek(0)
def _extract_stand(files: Sequence[FileStorage]) -> Optional[str]:
"""Достаёт stand из environment.properties (архив или отдельный legacy-файл)."""
stand: Optional[str] = None
for file_storage in files:
filename = file_storage.filename or ""
_rewind(file_storage)
content = file_storage.stream.read()
_rewind(file_storage)
if testrun_helpers._is_allure_results_archive(filename):
stand = testrun_helpers._extract_stand_from_archive(content) or stand
elif os.path.basename(filename) == "environment.properties":
stand = (
testrun_helpers._extract_stand_value(
"environment.properties", content
)
or stand
)
return stand
def _build_file_storages(
minio_client: MinioClient, objects: Iterable[MinioObjectLike]
) -> list[FileStorage]:
"""Скачивает объекты одного прогона и готовит их к check_all_tests_passed_run."""
files: list[FileStorage] = []
for obj in objects:
if not obj.object_name:
continue
payload = _read_object_bytes(minio_client, obj.object_name)
if not payload:
continue
files.append(_as_file_storage(obj.object_name, payload))
return files
def _created_at_from_objects(objects: Sequence[MinioObjectLike]) -> datetime.datetime:
"""created_at прогона: самый поздний LastModified объектов в префиксе."""
timestamps = [_to_naive_utc(obj.last_modified) for obj in objects]
known = [item for item in timestamps if item is not None]
if not known:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
return max(known)
def _print_plan(
run_name: str,
info: Mapping[str, Any],
stand: Optional[str],
created_at: datetime.datetime,
action: str,
) -> None:
"""Печатает одну строку плана вставки / результата."""
stats = info.get(const.STATUS_STATS_KEY) or {}
print(
f"{action} run_name={run_name} status={info.get(const.STATUS_KEY)} "
f"stand={stand!r} start={info.get(const.START_RUN_KEY)} "
f"stop={info.get(const.STOP_RUN_KEY)} "
f"passed={stats.get(const.STATUS_PASSED, 0)} "
f"failed={stats.get(const.STATUS_FAILED, 0)} "
f"broken={stats.get(const.STATUS_BROKEN, 0)} "
f"skipped={stats.get(const.STATUS_SKIPPED, 0)} "
f"created_at={created_at}"
)
def rebuild(dry_run: bool) -> None:
"""Сканирует MinIO и вставляет отсутствующие прогоны в testrun_results."""
app = create_app()
minio_client = MinioClient()
with app.app_context():
existing = {
name for (name,) in db.session.query(TestResult.run_name).all()
}
groups = _group_objects_by_run(minio_client)
print(f"Найдено префиксов в MinIO: {len(groups)}")
print(f"Уже есть в testrun_results: {len(existing)}")
skipped = 0
inserted = 0
empty = 0
for run_name in sorted(groups):
objects = groups[run_name]
if run_name in existing:
print(f"SKIP already exists run_name={run_name}")
skipped += 1
continue
files = _build_file_storages(minio_client, objects)
if not files:
print(f"SKIP empty prefix run_name={run_name}")
empty += 1
continue
info = testrun_helpers.check_all_tests_passed_run(files)
stand = _extract_stand(files)
created_at = _created_at_from_objects(objects)
stats = info.get(const.STATUS_STATS_KEY) or {}
if dry_run:
_print_plan(run_name, info, stand, created_at, "DRY-RUN insert")
inserted += 1
continue
result = TestResult(
run_name=run_name,
start_date=_parse_run_datetime(info.get(const.START_RUN_KEY)),
end_date=_parse_run_datetime(info.get(const.STOP_RUN_KEY)),
stand=stand,
status=info.get(const.STATUS_KEY),
passed_count=stats.get(const.STATUS_PASSED, 0),
failed_count=stats.get(const.STATUS_FAILED, 0),
broken_count=stats.get(const.STATUS_BROKEN, 0),
skipped_count=stats.get(const.STATUS_SKIPPED, 0),
created_at=created_at,
is_deleted=False,
)
db.session.add(result)
db.session.commit()
_print_plan(run_name, info, stand, created_at, "INSERTED")
inserted += 1
print(
f"Готово. skipped={skipped} empty={empty} "
f"{'would_insert' if dry_run else 'inserted'}={inserted}"
)
def main() -> None:
"""Точка входа CLI."""
args = _parse_args()
rebuild(dry_run=args.dry_run)
if __name__ == "__main__":
main()
scripts\setup_minio_ilm.sh
#!/bin/sh
#
# Разовый скрипт настройки MinIO ILM для allure-reports-bucket.
# Запускается вручную один раз с хоста, где развернут
#
# Зачем:
# HTML-отчёты Allure весят десятки мегабайт. Без TTL бакет allure-reports-bucket
# забивает диск. Правило ILM говорит MinIO самому физически удалять объект,
# когда с момента загрузки прошло 30 дней.
# allure-results-bucket не трогаем: из него отчёт можно сгенерировать снова.
#
# Почему docker run, а не exec в flask_app:
# В образе приложения нет бинаря mc. Одноразовый контейнер minio/mc
# подключается к той же docker-сети, что и сервис minio, и говорит с ним
# по имени хоста "minio". --rm удаляет этот контейнер сразу после выхода.
#
# Что делает по шагам:
# 1. Находит docker-сеть compose (*minio-network), либо берёт MINIO_NETWORK.
# 2. Стартует minio/mc в этой сети.
# 3. mc alias set - сохраняет URL и ключи как профиль "local".
# 4. mc mb --ignore-existing - создаёт бакет, если его ещё нет
# (после инцидента reports-bucket мог исчезнуть).
# 5. mc ilm rule add --expire-days 30 - вешает правило удаления HTML старше 30 дней.
# 6. mc ilm rule ls - печатает правила, чтобы проверить, что запись появилась.
#
# После успеха правило живёт в MinIO. Повторять скрипт не нужно: повторный
# ilm rule add может добавить второе такое же правило.
#
# Запуск:
# sh scripts/setup_minio_ilm.sh
# MINIO_NETWORK=testops_minio-network sh scripts/setup_minio_ilm.sh
#
set -eu
NETWORK="${MINIO_NETWORK:-}"
if [ -z "$NETWORK" ]; then
NETWORK=$(docker network ls --format '{{.Name}}' | grep minio-network | head -n 1)
fi
if [ -z "$NETWORK" ]; then
echo "Не найдена docker-сеть minio-network. Задайте MINIO_NETWORK=..."
echo "Список сетей: docker network ls"
exit 1
fi
MINIO_USER="${MINIO_ROOT_USER:-minioadmin}"
MINIO_PASS="${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_HOST="${MINIO_HOST:-minio:9000}"
echo "Using docker network: $NETWORK"
echo "MinIO endpoint inside network: $MINIO_HOST"
docker run --rm --network "$NETWORK" \
-e MINIO_ROOT_USER="$MINIO_USER" \
-e MINIO_ROOT_PASSWORD="$MINIO_PASS" \
minio/mc sh -c "
mc alias set local http://${MINIO_HOST} \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" &&
mc mb local/allure-reports-bucket --ignore-existing &&
mc ilm rule add local/allure-reports-bucket --expire-days 30 &&
mc ilm rule ls local/allure-reports-bucket
"
scripts\validate_postgres_tables.py
"""Проверяет, что в PostgreSQL есть все таблицы схемы TestOps.
Скрипт не создаёт и не удаляет таблицы. Если чего-то нет - ненулевой код выхода,
чтобы контейнер не поднял Flask на неполной схеме.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Set
from sqlalchemy import inspect, text
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app import create_app, db
REQUIRED_TABLES = (
"attachments",
"tags",
"test_case_steps",
"test_case_suites",
"test_case_tags",
"test_cases",
"test_suites",
"testrun_results",
)
def _existing_tables() -> Set[str]:
"""Возвращает имена таблиц в текущей БД приложения."""
inspector = inspect(db.engine)
return set(inspector.get_table_names())
def validate_postgres_tables() -> None:
"""Падает с кодом 1, если нет соединения или не хватает обязательных таблиц."""
app = create_app()
with app.app_context():
with db.engine.connect() as conn:
conn.execute(text("SELECT 1"))
existing = _existing_tables()
missing = [name for name in REQUIRED_TABLES if name not in existing]
if missing:
print("PostgreSQL schema is incomplete, missing tables:")
for name in missing:
print(f" - {name}")
print("Run once: flask db upgrade")
sys.exit(1)
print("PostgreSQL schema OK: all required tables are present")
def main() -> None:
"""Точка входа CLI."""
validate_postgres_tables()
if __name__ == "__main__":
main()