selfrepair / webapp /database.py
ruslanmv
Admin panel (Users/System/Logs) + protected root superuser
69ca3d6
Raw
History Blame Contribute Delete
11 kB
"""Database models and session management for the SelfRepair webapp.
Prefers a configured Postgres ``DATABASE_URL`` (e.g. Neon) and gracefully falls
back to ephemeral SQLite on ``/tmp`` if the database is unreachable, so the
Space stays up even when outbound Postgres egress is blocked.
"""
from __future__ import annotations
import logging
import os as _os
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
create_engine,
text,
)
from sqlalchemy.orm import DeclarativeBase, Session, relationship, sessionmaker
logger = logging.getLogger(__name__)
_DB_DIR = _os.environ.get("SELFREPAIR_DB_DIR", "/tmp/selfrepair")
_SQLITE_URL = f"sqlite:///{_DB_DIR}/selfrepair.db"
DATABASE_URL = _os.environ.get("DATABASE_URL", _SQLITE_URL)
def _normalize(url: str) -> str:
# SQLAlchemy needs an explicit driver for psycopg2.
if url.startswith("postgresql://"):
return "postgresql+psycopg2://" + url[len("postgresql://"):]
if url.startswith("postgres://"):
return "postgresql+psycopg2://" + url[len("postgres://"):]
return url
def _build_engine(url: str):
if url.startswith("sqlite"):
_os.makedirs(_DB_DIR, exist_ok=True)
return create_engine(url, connect_args={"check_same_thread": False})
# Postgres: pre-ping + recycle for serverless/pooled backends like Neon.
return create_engine(
_normalize(url),
pool_pre_ping=True,
pool_recycle=300,
pool_size=5,
max_overflow=5,
connect_args={"connect_timeout": 10},
)
def _make_engine():
"""Use DATABASE_URL; fall back to SQLite if the DB can't be reached."""
url = DATABASE_URL
try:
eng = _build_engine(url)
with eng.connect() as conn:
conn.execute(text("SELECT 1"))
if not url.startswith("sqlite"):
logger.info("SelfRepair DB: connected to Postgres backend.")
return eng
except Exception as exc: # pragma: no cover - depends on runtime egress
if url.startswith("sqlite"):
raise
logger.warning(
"SelfRepair DB: Postgres unreachable (%s); falling back to SQLite.",
type(exc).__name__,
)
_os.makedirs(_DB_DIR, exist_ok=True)
return _build_engine(_SQLITE_URL)
engine = _make_engine()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def _uuid() -> str:
return uuid.uuid4().hex
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(String(32), primary_key=True, default=_uuid)
email = Column(String(255), unique=True, nullable=False, index=True)
username = Column(String(100), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
display_name = Column(String(200), nullable=True)
role = Column(String(20), default="user") # user | admin
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False, nullable=False)
# The single bootstrap superuser, created on first run. Protected: it can
# never be demoted, deactivated, or deleted. It grants admin to others.
is_root = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=_utcnow)
last_login = Column(DateTime, nullable=True)
# Platform tokens (encrypted at rest in production)
github_token = Column(Text, nullable=True)
gitlab_token = Column(Text, nullable=True)
hf_token = Column(Text, nullable=True)
scans = relationship("ScanRun", back_populates="user", cascade="all, delete-orphan")
class ScanRun(Base):
__tablename__ = "scan_runs"
id = Column(String(32), primary_key=True, default=_uuid)
user_id = Column(String(32), ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True)
status = Column(String(20), default="running") # running | completed | failed
total_repos = Column(Integer, default=0)
healthy = Column(Integer, default=0)
degraded = Column(Integer, default=0)
down = Column(Integer, default=0)
repaired = Column(Integer, default=0)
report_json = Column(Text, nullable=True)
trigger = Column(String(50), default="manual") # manual | scheduled
user = relationship("User", back_populates="scans")
reports = relationship("RepoReport", back_populates="scan", cascade="all, delete-orphan")
class RepoReport(Base):
__tablename__ = "repo_reports"
id = Column(String(32), primary_key=True, default=_uuid)
scan_id = Column(String(32), ForeignKey("scan_runs.id"), nullable=False)
repo_name = Column(String(300), nullable=False)
platform = Column(String(20), default="github")
kind = Column(String(20), default="code")
status = Column(String(20), default="unknown")
makefile_ok = Column(Boolean, default=False)
pyproject_ok = Column(Boolean, default=False)
health_test_ok = Column(Boolean, default=False)
python311_ok = Column(Boolean, default=False)
install_ok = Column(Boolean, default=False)
test_ok = Column(Boolean, default=False)
start_ok = Column(Boolean, default=False)
fix_attempts = Column(Integer, default=0)
changed_files = Column(Text, default="")
notes = Column(Text, default="")
pr_url = Column(String(500), nullable=True)
duration_seconds = Column(Float, default=0.0)
created_at = Column(DateTime, default=_utcnow)
scan = relationship("ScanRun", back_populates="reports")
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(String(32), primary_key=True, default=_uuid)
user_id = Column(String(32), ForeignKey("users.id"), nullable=True)
action = Column(String(100), nullable=False)
details = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime, default=_utcnow)
class Connection(Base):
"""A configured connection to a SelfRepair service provider.
provider is one of: ollabridge | gitpilot | matrixlab.
The secret (OllaBridge ob_* key or service token) is stored
Fernet-encrypted in ``api_key_enc`` — never in plaintext.
"""
__tablename__ = "connections"
id = Column(String(32), primary_key=True, default=_uuid)
user_id = Column(String(32), ForeignKey("users.id"), nullable=False, index=True)
provider = Column(String(40), nullable=False)
base_url = Column(String(500), nullable=False, default="")
api_key_enc = Column(Text, nullable=True)
status = Column(String(20), default="unknown") # unknown | ok | error
detail = Column(Text, default="")
last_checked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (UniqueConstraint("user_id", "provider", name="uq_user_provider"),)
class Message(Base):
"""An incoming maintenance/repair request from a client (machine or admin).
Recorded as the system-of-record intake row. Deduplicated in code via
``idempotency_key`` (we index it rather than relying on a partial unique
constraint, which SQLite cannot express for NULLs portably).
"""
__tablename__ = "messages"
id = Column(String(32), primary_key=True, default=_uuid)
client_id = Column(String(200), nullable=False)
requested_by = Column(String(200), nullable=True)
type = Column(String(40), default="maintenance_request") # maintenance_request | repair_plan
repo_url = Column(String(500), nullable=False)
branch = Column(String(200), default="main")
mode = Column(String(40), default="dry_run")
message = Column(Text, nullable=True)
payload = Column(Text, nullable=True) # JSON-encoded plan/extra
idempotency_key = Column(String(200), nullable=True, index=True)
status = Column(String(20), default="queued") # queued | running | done | failed
job_id = Column(String(32), nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
class Job(Base):
"""A real repo health-check job produced from a Message."""
__tablename__ = "jobs"
id = Column(String(32), primary_key=True, default=_uuid)
message_id = Column(String(32), nullable=True, index=True)
repo_url = Column(String(500), nullable=False)
branch = Column(String(200), default="main")
status = Column(String(20), default="queued") # queued | running | done | failed
health_score = Column(Integer, nullable=True)
report_json = Column(Text, nullable=True)
error = Column(Text, nullable=True)
created_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True)
class Notification(Base):
"""An operator-facing notification surfaced in the console bell/inbox."""
__tablename__ = "notifications"
id = Column(String(32), primary_key=True, default=_uuid)
source = Column(String(200), default="system") # client_id or "system"
kind = Column(String(40), default="request_received") # request_received | report_ready | action_needed
title = Column(String(400), nullable=False)
body = Column(Text, nullable=True)
link = Column(String(200), nullable=True) # job id
read = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=_utcnow)
def _ensure_columns() -> None:
"""Lightweight in-place migration for columns added after first deploy."""
insp_bind = engine
try:
from sqlalchemy import inspect as _inspect
insp = _inspect(insp_bind)
if "users" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("users")}
bool_default = "0" if engine.dialect.name == "sqlite" else "false"
for col in ("email_verified", "is_root"):
if col not in cols:
with engine.begin() as conn:
conn.execute(
text(
f"ALTER TABLE users ADD COLUMN {col} BOOLEAN "
f"NOT NULL DEFAULT {bool_default}"
)
)
logger.info("SelfRepair DB: added users.%s column.", col)
except Exception as exc: # pragma: no cover
logger.warning("SelfRepair DB: column check skipped (%s).", type(exc).__name__)
def init_db() -> None:
"""Create all tables and apply lightweight migrations."""
Base.metadata.create_all(bind=engine)
_ensure_columns()
def get_db() -> Session:
db = SessionLocal()
try:
return db
except Exception:
db.close()
raise