Add SQLite database integration with SQLAlchemy async, migration tooling, and comprehensive CRUD operations for hosts, tasks, schedules, and logs

This commit is contained in:
2025-12-04 21:37:55 -05:00
parent 28276f326b
commit ad3a8a5639
38 changed files with 2503 additions and 388 deletions
+13
View File
@@ -0,0 +1,13 @@
from .host import HostRepository
from .bootstrap_status import BootstrapStatusRepository
from .task import TaskRepository
from .schedule import ScheduleRepository
from .log import LogRepository
__all__ = [
"HostRepository",
"BootstrapStatusRepository",
"TaskRepository",
"ScheduleRepository",
"LogRepository",
]
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.bootstrap_status import BootstrapStatus
class BootstrapStatusRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list_for_host(self, host_id: str) -> list[BootstrapStatus]:
stmt = select(BootstrapStatus).where(BootstrapStatus.host_id == host_id).order_by(BootstrapStatus.created_at.desc())
result = await self.session.execute(stmt)
return result.scalars().all()
async def latest_for_host(self, host_id: str) -> Optional[BootstrapStatus]:
stmt = (
select(BootstrapStatus)
.where(BootstrapStatus.host_id == host_id)
.order_by(BootstrapStatus.created_at.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create(self, *, host_id: str, status: str, automation_user: Optional[str] = None,
last_attempt=None, error_message: Optional[str] = None) -> BootstrapStatus:
record = BootstrapStatus(
host_id=host_id,
status=status,
automation_user=automation_user,
last_attempt=last_attempt,
error_message=error_message,
)
self.session.add(record)
await self.session.flush()
return record
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Iterable, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.host import Host
class HostRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list(self, limit: int = 100, offset: int = 0, include_deleted: bool = False) -> list[Host]:
stmt = select(Host).order_by(Host.created_at.desc()).offset(offset).limit(limit)
if not include_deleted:
stmt = stmt.where(Host.deleted_at.is_(None))
result = await self.session.execute(stmt)
return result.scalars().all()
async def get(self, host_id: str, include_deleted: bool = False) -> Optional[Host]:
stmt = select(Host).where(Host.id == host_id).options(selectinload(Host.bootstrap_statuses))
if not include_deleted:
stmt = stmt.where(Host.deleted_at.is_(None))
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_ip(self, ip_address: str, include_deleted: bool = False) -> Optional[Host]:
stmt = select(Host).where(Host.ip_address == ip_address)
if not include_deleted:
stmt = stmt.where(Host.deleted_at.is_(None))
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create(self, *, id: str, name: str, ip_address: str, ansible_group: Optional[str] = None,
status: str = "unknown", reachable: bool = False, last_seen: Optional[datetime] = None) -> Host:
host = Host(
id=id,
name=name,
ip_address=ip_address,
ansible_group=ansible_group,
status=status,
reachable=reachable,
last_seen=last_seen,
)
self.session.add(host)
await self.session.flush()
return host
async def update(self, host: Host, **fields) -> Host:
for key, value in fields.items():
if value is not None:
setattr(host, key, value)
await self.session.flush()
return host
async def soft_delete(self, host_id: str) -> bool:
stmt = (
update(Host)
.where(Host.id == host_id, Host.deleted_at.is_(None))
.values(deleted_at=datetime.now(timezone.utc))
)
result = await self.session.execute(stmt)
return result.rowcount > 0
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.log import Log
class LogRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list(self, limit: int = 100, offset: int = 0, level: Optional[str] = None, source: Optional[str] = None) -> list[Log]:
stmt = select(Log).order_by(Log.created_at.desc()).offset(offset).limit(limit)
if level:
stmt = stmt.where(Log.level == level)
if source:
stmt = stmt.where(Log.source == source)
result = await self.session.execute(stmt)
return result.scalars().all()
async def create(self, **fields) -> Log:
log = Log(**fields)
self.session.add(log)
await self.session.flush()
return log
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.schedule import Schedule
from models.schedule_run import ScheduleRun
class ScheduleRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list(self, limit: int = 100, offset: int = 0, include_deleted: bool = False) -> list[Schedule]:
stmt = select(Schedule).order_by(Schedule.created_at.desc()).offset(offset).limit(limit)
if not include_deleted:
stmt = stmt.where(Schedule.deleted_at.is_(None))
result = await self.session.execute(stmt)
return result.scalars().all()
async def get(self, schedule_id: str, include_deleted: bool = False) -> Optional[Schedule]:
stmt = select(Schedule).where(Schedule.id == schedule_id).options(
selectinload(Schedule.runs)
)
if not include_deleted:
stmt = stmt.where(Schedule.deleted_at.is_(None))
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create(self, **fields) -> Schedule:
schedule = Schedule(**fields)
self.session.add(schedule)
await self.session.flush()
return schedule
async def update(self, schedule: Schedule, **fields) -> Schedule:
for key, value in fields.items():
if value is not None:
setattr(schedule, key, value)
await self.session.flush()
return schedule
async def soft_delete(self, schedule_id: str) -> bool:
stmt = (
update(Schedule)
.where(Schedule.id == schedule_id, Schedule.deleted_at.is_(None))
.values(deleted_at=datetime.now(timezone.utc))
)
result = await self.session.execute(stmt)
return result.rowcount > 0
async def add_run(self, **fields) -> ScheduleRun:
run = ScheduleRun(**fields)
self.session.add(run)
await self.session.flush()
return run
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.schedule_run import ScheduleRun
class ScheduleRunRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, run_id: int) -> Optional[ScheduleRun]:
stmt = select(ScheduleRun).where(ScheduleRun.id == run_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_schedule(self, schedule_id: str, limit: int = 100, offset: int = 0) -> list[ScheduleRun]:
stmt = (
select(ScheduleRun)
.where(ScheduleRun.schedule_id == schedule_id)
.order_by(ScheduleRun.started_at.desc())
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def create(self, **fields) -> ScheduleRun:
run = ScheduleRun(**fields)
self.session.add(run)
await self.session.flush()
return run
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.task import Task
class TaskRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list(self, limit: int = 100, offset: int = 0) -> list[Task]:
stmt = select(Task).order_by(Task.created_at.desc()).offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def get(self, task_id: str) -> Optional[Task]:
stmt = select(Task).where(Task.id == task_id).options(selectinload(Task.schedule_runs))
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create(self, *, id: str, action: str, target: str, playbook: Optional[str] = None,
status: str = "pending") -> Task:
task = Task(
id=id,
action=action,
target=target,
playbook=playbook,
status=status,
)
self.session.add(task)
await self.session.flush()
return task
async def update(self, task: Task, **fields) -> Task:
for key, value in fields.items():
if value is not None:
setattr(task, key, value)
await self.session.flush()
return task