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:
2026-10-14 17:47:22 +03:00
co-authored by Claude Opus 5.5
parent 15f354d06b
commit f022731a5d
24 changed files with 1071 additions and 16 deletions
View File
+39
View File
@@ -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())
+25
View File
@@ -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 ###