Add SQLite database integration with SQLAlchemy async, migration tooling, and comprehensive CRUD operations for hosts, tasks, schedules, and logs
This commit is contained in:
+736
-372
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
from .database import Base
|
||||
from .host import Host
|
||||
from .bootstrap_status import BootstrapStatus
|
||||
from .task import Task
|
||||
from .schedule import Schedule
|
||||
from .schedule_run import ScheduleRun
|
||||
from .log import Log
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Host",
|
||||
"BootstrapStatus",
|
||||
"Task",
|
||||
"Schedule",
|
||||
"ScheduleRun",
|
||||
"Log",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class BootstrapStatus(Base):
|
||||
__tablename__ = "bootstrap_status"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||
automation_user: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
last_attempt: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
host: Mapped["Host"] = relationship("Host", back_populates="bootstrap_statuses")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<BootstrapStatus id={self.id} host_id={self.host_id} status={self.status}>"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Database configuration and session management for Homelab Automation.
|
||||
Uses SQLAlchemy 2.x async engine with SQLite + aiosqlite driver.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import event, MetaData
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
# Naming convention to keep Alembic happy with constraints
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
metadata_obj = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
Base = declarative_base(metadata=metadata_obj)
|
||||
|
||||
# Resolve base path (project root)
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DB_PATH = Path(os.environ.get("DB_PATH") or (ROOT_DIR / "data" / "homelab.db"))
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}")
|
||||
|
||||
# Ensure SQLite directory exists even if DATABASE_URL overrides DB_PATH
|
||||
def _ensure_sqlite_dir(db_url: str) -> None:
|
||||
if not db_url.startswith("sqlite"):
|
||||
return
|
||||
parsed = urlparse(db_url.replace("sqlite+aiosqlite", "sqlite"))
|
||||
if parsed.scheme != "sqlite":
|
||||
return
|
||||
db_path = Path(parsed.path)
|
||||
if db_path.parent:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_ensure_sqlite_dir(DATABASE_URL)
|
||||
|
||||
|
||||
def _debug_db_paths() -> None:
|
||||
try:
|
||||
print(
|
||||
"[DB] DATABASE_URL=%s, DEFAULT_DB_PATH=%s, parent_exists=%s, parent=%s"
|
||||
% (
|
||||
DATABASE_URL,
|
||||
DEFAULT_DB_PATH,
|
||||
DEFAULT_DB_PATH.parent.exists(),
|
||||
DEFAULT_DB_PATH.parent,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Debug logging should never break startup
|
||||
pass
|
||||
|
||||
|
||||
_debug_db_paths()
|
||||
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Ensure SQLite pragmas (WAL + FK) when using SQLite
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record): # type: ignore[override]
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=engine,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an AsyncSession with automatic rollback on error."""
|
||||
async with async_session_maker() as session: # type: AsyncSession
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (mostly for dev/tests; migrations should be handled by Alembic)."""
|
||||
from . import host, task, schedule, schedule_run, log # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Host(Base):
|
||||
__tablename__ = "hosts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
ip_address: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'unknown'"))
|
||||
ansible_group: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
reachable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
|
||||
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
logs: Mapped[List["Log"]] = relationship("Log", back_populates="host")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Log(Base):
|
||||
__tablename__ = "logs"
|
||||
__table_args__ = (
|
||||
Index("idx_logs_created_at", "created_at"),
|
||||
Index("idx_logs_level", "level"),
|
||||
Index("idx_logs_source", "source"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
level: Mapped[str] = mapped_column(String, nullable=False)
|
||||
source: Mapped[Optional[str]] = mapped_column(String)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
details: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
host_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("hosts.id", ondelete="SET NULL"))
|
||||
task_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"))
|
||||
schedule_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("schedules.id", ondelete="SET NULL"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
host: Mapped[Optional["Host"]] = relationship("Host", back_populates="logs")
|
||||
task: Mapped[Optional["Task"]] = relationship("Task", back_populates="logs")
|
||||
schedule: Mapped[Optional["Schedule"]] = relationship("Schedule", back_populates="logs")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<Log id={self.id} level={self.level} source={self.source}>"
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Schedule(Base):
|
||||
__tablename__ = "schedules"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
playbook: Mapped[str] = mapped_column(String, nullable=False)
|
||||
target: Mapped[str] = mapped_column(String, nullable=False)
|
||||
schedule_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
schedule_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
recurrence_type: Mapped[Optional[str]] = mapped_column(String)
|
||||
recurrence_time: Mapped[Optional[str]] = mapped_column(String)
|
||||
recurrence_days: Mapped[Optional[str]] = mapped_column(Text)
|
||||
cron_expression: Mapped[Optional[str]] = mapped_column(String)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
tags: Mapped[Optional[str]] = mapped_column(Text)
|
||||
next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
runs: Mapped[List["ScheduleRun"]] = relationship(
|
||||
"ScheduleRun", back_populates="schedule", cascade="all, delete-orphan"
|
||||
)
|
||||
logs: Mapped[List["Log"]] = relationship("Log", back_populates="schedule")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<Schedule id={self.id} name={self.name} target={self.target}>"
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class ScheduleRun(Base):
|
||||
__tablename__ = "schedule_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
schedule_id: Mapped[str] = mapped_column(String, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False)
|
||||
task_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"))
|
||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
duration: Mapped[Optional[float]] = mapped_column(Float)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
output: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
schedule: Mapped["Schedule"] = relationship("Schedule", back_populates="runs")
|
||||
task: Mapped[Optional["Task"]] = relationship("Task", back_populates="schedule_runs")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<ScheduleRun id={self.id} schedule_id={self.schedule_id} status={self.status}>"
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, JSON, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
action: Mapped[str] = mapped_column(String, nullable=False)
|
||||
target: Mapped[str] = mapped_column(String, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
|
||||
playbook: Mapped[Optional[str]] = mapped_column(String)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text)
|
||||
result_data: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
schedule_runs: Mapped[list["ScheduleRun"]] = relationship("ScheduleRun", back_populates="task")
|
||||
logs: Mapped[list["Log"]] = relationship("Log", back_populates="task")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<Task id={self.id} action={self.action} target={self.target} status={self.status}>"
|
||||
@@ -10,4 +10,9 @@ requests>=2.32.0
|
||||
httpx>=0.28.0
|
||||
apscheduler>=3.10.0
|
||||
croniter>=2.0.0
|
||||
pytz>=2024.1
|
||||
pytz>=2024.1
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
aiosqlite>=0.19.0
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
@@ -0,0 +1,21 @@
|
||||
from .host import HostCreate, HostUpdate, HostOut
|
||||
from .bootstrap_status import BootstrapStatusOut
|
||||
from .task import TaskCreate, TaskUpdate, TaskOut
|
||||
from .schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut, ScheduleRunOut
|
||||
from .log import LogCreate, LogOut
|
||||
|
||||
__all__ = [
|
||||
"HostCreate",
|
||||
"HostUpdate",
|
||||
"HostOut",
|
||||
"BootstrapStatusOut",
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"TaskOut",
|
||||
"ScheduleCreate",
|
||||
"ScheduleUpdate",
|
||||
"ScheduleOut",
|
||||
"ScheduleRunOut",
|
||||
"LogCreate",
|
||||
"LogOut",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BootstrapStatusOut(BaseModel):
|
||||
id: int
|
||||
host_id: str
|
||||
status: str
|
||||
automation_user: Optional[str] = None
|
||||
last_attempt: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class HostBase(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
ip_address: str = Field(..., min_length=3)
|
||||
ansible_group: Optional[str] = None
|
||||
status: Optional[str] = Field(default="unknown")
|
||||
reachable: Optional[bool] = False
|
||||
last_seen: Optional[datetime] = None
|
||||
|
||||
|
||||
class HostCreate(HostBase):
|
||||
pass
|
||||
|
||||
|
||||
class HostUpdate(BaseModel):
|
||||
ansible_group: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
reachable: Optional[bool] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class HostOut(HostBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class LogCreate(BaseModel):
|
||||
level: str = Field(..., description="Log level: info/warning/error/debug")
|
||||
source: Optional[str] = None
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
host_id: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
schedule_id: Optional[str] = None
|
||||
|
||||
|
||||
class LogOut(LogCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ScheduleBase(BaseModel):
|
||||
name: str
|
||||
playbook: str
|
||||
target: str
|
||||
schedule_type: str
|
||||
schedule_time: Optional[datetime] = None
|
||||
recurrence_type: Optional[str] = None
|
||||
recurrence_time: Optional[str] = None
|
||||
recurrence_days: Optional[List[int]] = None
|
||||
cron_expression: Optional[str] = None
|
||||
enabled: bool = True
|
||||
tags: Optional[List[str]] = None
|
||||
next_run: Optional[datetime] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
class ScheduleCreate(ScheduleBase):
|
||||
pass
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
playbook: Optional[str] = None
|
||||
target: Optional[str] = None
|
||||
schedule_type: Optional[str] = None
|
||||
schedule_time: Optional[datetime] = None
|
||||
recurrence_type: Optional[str] = None
|
||||
recurrence_time: Optional[str] = None
|
||||
recurrence_days: Optional[List[int]] = None
|
||||
cron_expression: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
tags: Optional[List[str]] = None
|
||||
next_run: Optional[datetime] = None
|
||||
last_run: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ScheduleOut(ScheduleBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ScheduleRunOut(BaseModel):
|
||||
id: int
|
||||
schedule_id: str
|
||||
task_id: Optional[str] = None
|
||||
status: str
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
duration: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
output: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
action: str
|
||||
target: str
|
||||
playbook: Optional[str] = None
|
||||
status: str = Field(default="pending")
|
||||
result_data: Optional[Dict[str, Any]] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
pass
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
result_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class TaskOut(TaskBase):
|
||||
id: str
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
Reference in New Issue
Block a user