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
+736 -372
View File
File diff suppressed because it is too large Load Diff
+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
+17
View File
@@ -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",
]
+27
View File
@@ -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}>"
+106
View File
@@ -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)
+33
View File
@@ -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}>"
+36
View File
@@ -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}>"
+40
View File
@@ -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}>"
+31
View File
@@ -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}>"
+31
View File
@@ -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}>"
+6 -1
View File
@@ -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
+21
View File
@@ -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",
]
+18
View File
@@ -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)
+36
View File
@@ -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)
+23
View File
@@ -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)
+67
View File
@@ -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)
+36
View File
@@ -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)