feat(bot): модель данных, миграции Alembic, FSM в БД, TTL-чистка
- app/db/models.py: 13 таблиц (ARCHITECTURE §2.10); перечисления — VARCHAR + CHECK, JSON → JSONB на Postgres, BIGINT/INTEGER-ключи для SQLite - миграция 0001 (автогенерация + ручная чистка: импорт sa.Text, дубли CHECK) - миграции применяются при старте бота программно (app.db.migrate), script_location из пакета - SqlStorage: FSM aiogram поверх fsm_state — состояние переживает рестарт (перенесено из #4) - cleanup_loop: истёкшие черновики, снимки контейнеров и попытки входа старше суток - тесты: upgrade/downgrade, дрейф моделей и миграций, CHECK, CASCADE, FSM, чистка Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""Alembic environment. Runs on a connection handed over by app.db.migrate (or the CLI via alembic.ini)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from app.config import DatabaseSettings
|
||||
from app.db.models import Base
|
||||
from app.db.session import make_engine
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _run(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=connection.dialect.name == "sqlite", # SQLite can't ALTER most things in place
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def _run_cli() -> None:
|
||||
engine = make_engine(DatabaseSettings().url)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_run)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
connection = context.config.attributes.get("connection")
|
||||
if connection is not None:
|
||||
_run(connection)
|
||||
else:
|
||||
asyncio.run(_run_cli())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,311 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-10-14 17:41:05.672912
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"access_attempts",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("tg_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ok", sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_attempts")),
|
||||
)
|
||||
with op.batch_alter_table("access_attempts", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_access_attempts_tg_id_at", ["tg_id", "at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"alerts",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column(
|
||||
"kind",
|
||||
sa.Enum(
|
||||
"crash",
|
||||
"oom",
|
||||
"restart_loop",
|
||||
"unhealthy",
|
||||
"overload",
|
||||
"deploy_refused",
|
||||
"cd_failed",
|
||||
"disk_low",
|
||||
"dependency_down",
|
||||
"agent_attack",
|
||||
name="alert_kind",
|
||||
native_enum=False,
|
||||
create_constraint=True,
|
||||
length=20,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("service", sa.String(length=100), nullable=True),
|
||||
sa.Column(
|
||||
"payload", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
|
||||
),
|
||||
sa.Column("count", sa.Integer(), nullable=False),
|
||||
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_alerts")),
|
||||
)
|
||||
with op.batch_alter_table("alerts", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_alerts_kind_service_resolved_at", ["kind", "service", "resolved_at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"container_snapshots",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("container", sa.String(length=100), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("restart_count", sa.Integer(), nullable=False),
|
||||
sa.Column("cpu_pct", sa.Float(), nullable=False),
|
||||
sa.Column("mem_pct", sa.Float(), nullable=False),
|
||||
sa.Column("health", sa.String(length=20), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_container_snapshots")),
|
||||
)
|
||||
with op.batch_alter_table("container_snapshots", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_container_snapshots_container_at", ["container", "at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"fsm_state",
|
||||
sa.Column("key", sa.String(length=255), nullable=False),
|
||||
sa.Column("state", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"data", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
|
||||
),
|
||||
sa.PrimaryKeyConstraint("key", name=op.f("pk_fsm_state")),
|
||||
)
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("tg_id", sa.BigInteger(), autoincrement=False, nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=True),
|
||||
sa.Column("full_name", sa.String(length=256), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum("member", "admin", name="role", native_enum=False, create_constraint=True, length=20),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("invited_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["invited_by"], ["users.tg_id"], name=op.f("fk_users_invited_by_users")),
|
||||
sa.PrimaryKeyConstraint("tg_id", name=op.f("pk_users")),
|
||||
)
|
||||
op.create_table(
|
||||
"agent_requests",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"verdict",
|
||||
sa.Enum("safe", "attack", "error", name="verdict", native_enum=False, create_constraint=True, length=20),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"log_query", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=True
|
||||
),
|
||||
sa.Column("tokens", sa.Integer(), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_agent_requests_user_id_users")),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_agent_requests")),
|
||||
)
|
||||
op.create_table(
|
||||
"alert_deliveries",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("alert_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["alert_id"], ["alerts.id"], name=op.f("fk_alert_deliveries_alert_id_alerts"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_alert_deliveries")),
|
||||
sa.UniqueConstraint("alert_id", "chat_id", name=op.f("uq_alert_deliveries_alert_id")),
|
||||
)
|
||||
op.create_table(
|
||||
"drafts",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("base_sha", sa.String(length=40), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_drafts_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_drafts")),
|
||||
sa.UniqueConstraint("user_id", name=op.f("uq_drafts_user_id")),
|
||||
)
|
||||
op.create_table(
|
||||
"invites",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.tg_id"], name=op.f("fk_invites_created_by_users")),
|
||||
sa.ForeignKeyConstraint(["used_by"], ["users.tg_id"], name=op.f("fk_invites_used_by_users")),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_invites")),
|
||||
sa.UniqueConstraint("token_hash", name=op.f("uq_invites_token_hash")),
|
||||
)
|
||||
op.create_table(
|
||||
"log_queries",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("container", sa.String(length=100), nullable=False),
|
||||
sa.Column(
|
||||
"mode",
|
||||
sa.Enum("tail", "grep", "agent", name="log_mode", native_enum=False, create_constraint=True, length=20),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"query", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
|
||||
),
|
||||
sa.Column("lines", sa.Integer(), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_log_queries_user_id_users")),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_log_queries")),
|
||||
)
|
||||
op.create_table(
|
||||
"pushes",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("sha", sa.String(length=40), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"paths", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
|
||||
),
|
||||
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column(
|
||||
"ci_status",
|
||||
sa.Enum(
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"timeout",
|
||||
"skipped",
|
||||
name="ci_status",
|
||||
native_enum=False,
|
||||
create_constraint=True,
|
||||
length=20,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"cd_status",
|
||||
sa.Enum(
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"timeout",
|
||||
"skipped",
|
||||
name="cd_status",
|
||||
native_enum=False,
|
||||
create_constraint=True,
|
||||
length=20,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_pushes_user_id_users")),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_pushes")),
|
||||
sa.UniqueConstraint("sha", name=op.f("uq_pushes_sha")),
|
||||
)
|
||||
op.create_table(
|
||||
"draft_changes",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("draft_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("path", sa.String(length=500), nullable=False),
|
||||
sa.Column(
|
||||
"op",
|
||||
sa.Enum("add", "modify", "delete", name="change_op", native_enum=False, create_constraint=True, length=20),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("content", sa.LargeBinary(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["draft_id"], ["drafts.id"], name=op.f("fk_draft_changes_draft_id_drafts"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_draft_changes")),
|
||||
sa.UniqueConstraint("draft_id", "path", name=op.f("uq_draft_changes_draft_id")),
|
||||
)
|
||||
op.create_table(
|
||||
"pipeline_runs",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("push_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column(
|
||||
"stage",
|
||||
sa.Enum("ci", "cd", name="stage", native_enum=False, create_constraint=True, length=20),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("run_slug", sa.String(length=50), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum(
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"timeout",
|
||||
"skipped",
|
||||
name="run_status",
|
||||
native_enum=False,
|
||||
create_constraint=True,
|
||||
length=20,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("failed_cube", sa.String(length=100), nullable=True),
|
||||
sa.Column("url", sa.String(length=500), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["push_id"], ["pushes.id"], name=op.f("fk_pipeline_runs_push_id_pushes"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_pipeline_runs")),
|
||||
sa.UniqueConstraint("push_id", "stage", name=op.f("uq_pipeline_runs_push_id")),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("pipeline_runs")
|
||||
op.drop_table("draft_changes")
|
||||
op.drop_table("pushes")
|
||||
op.drop_table("log_queries")
|
||||
op.drop_table("invites")
|
||||
op.drop_table("drafts")
|
||||
op.drop_table("alert_deliveries")
|
||||
op.drop_table("agent_requests")
|
||||
op.drop_table("users")
|
||||
op.drop_table("fsm_state")
|
||||
with op.batch_alter_table("container_snapshots", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_container_snapshots_container_at")
|
||||
|
||||
op.drop_table("container_snapshots")
|
||||
with op.batch_alter_table("alerts", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_alerts_kind_service_resolved_at")
|
||||
|
||||
op.drop_table("alerts")
|
||||
with op.batch_alter_table("access_attempts", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_access_attempts_tg_id_at")
|
||||
|
||||
op.drop_table("access_attempts")
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user