Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup
Backend: config/db/security/logging core, SQLAlchemy models (Server, Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh, cau, audit, job_runner), REST API (auth, servers, updates, audit), Socket.io WebSocket layer. Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers, Updates, Audit, Login), axios + socket.io-client, nginx prod config.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Insight Updater backend application package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""API routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import audit, auth, servers, updates
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Shared API dependencies: current user extraction from JWT."""
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import ForbiddenError, UnauthorizedError
|
||||
from app.core.security import decode_token
|
||||
from app.models.user import User
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if credentials is None:
|
||||
raise UnauthorizedError("Authorization header fehlt")
|
||||
payload = decode_token(credentials.credentials)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise UnauthorizedError("Token enthält keinen Benutzer")
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise UnauthorizedError("Benutzer unbekannt oder deaktiviert")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise ForbiddenError("Administratorrechte erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Audit log routes (read-only, paginated)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditLogPage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogPage)
|
||||
async def list_audit_logs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
action: str | None = None,
|
||||
username: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> AuditLogPage:
|
||||
stmt = select(AuditLog).order_by(AuditLog.id.desc())
|
||||
count_stmt = select(func.count(AuditLog.id))
|
||||
|
||||
if action:
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
count_stmt = count_stmt.where(AuditLog.action == action)
|
||||
if username:
|
||||
stmt = stmt.where(AuditLog.username == username)
|
||||
count_stmt = count_stmt.where(AuditLog.username == username)
|
||||
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
return AuditLogPage(
|
||||
items=list(result.scalars().all()),
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Auth routes: login (local user or LDAP stub)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import UnauthorizedError
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse
|
||||
from app.services.audit import AuditService
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
audit = AuditService(db)
|
||||
result = await db.execute(select(User).where(User.username == payload.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.password_hash and verify_password(payload.password, user.password_hash):
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await audit.log(
|
||||
username=user.username,
|
||||
action="auth.login",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
token = create_access_token(
|
||||
subject=user.username,
|
||||
extra_claims={"is_admin": user.is_admin},
|
||||
)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
# LDAP stub: when enabled, attempt bind + search here (not yet implemented)
|
||||
await audit.log(
|
||||
username=payload.username,
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
raise UnauthorizedError("Benutzername oder Passwort falsch")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Server inventory routes."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip, get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models.credential import Credential
|
||||
from app.models.server import Server, ServerType
|
||||
from app.models.user import User
|
||||
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
|
||||
from app.services.audit import AuditService
|
||||
from app.services.cau import CAUService
|
||||
from app.services.job_runner import JobRunner
|
||||
from app.services.ssh import SSHService
|
||||
from app.services.winrm import WinRMService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ServerRead])
|
||||
async def list_servers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[Server]:
|
||||
result = await db.execute(select(Server).order_by(Server.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=ServerRead, status_code=201)
|
||||
async def create_server(
|
||||
payload: ServerCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = Server(**payload.model_dump())
|
||||
db.add(server)
|
||||
await db.flush()
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.create",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerRead)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
return server
|
||||
|
||||
|
||||
@router.patch("/{server_id}", response_model=ServerRead)
|
||||
async def update_server(
|
||||
server_id: int,
|
||||
payload: ServerUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(server, field, value)
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.update",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=204)
|
||||
async def delete_server(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.delete",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.delete(server)
|
||||
|
||||
|
||||
@router.get("/{server_id}/health", response_model=HealthCheckResult)
|
||||
async def check_server_health(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> HealthCheckResult:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
|
||||
|
||||
if server.type == ServerType.LINUX:
|
||||
service = SSHService(
|
||||
server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._ssh_creds(credential),
|
||||
)
|
||||
elif server.type == ServerType.CAU_CLUSTER:
|
||||
cau = CAUService(
|
||||
server.hostname,
|
||||
access_node=server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._winrm_creds(credential),
|
||||
)
|
||||
ok, message = await cau.test_cluster()
|
||||
server.last_health_at = datetime.now(UTC)
|
||||
server.last_health_ok = ok
|
||||
return HealthCheckResult(
|
||||
server_id=server.id,
|
||||
ok=ok,
|
||||
message=message,
|
||||
checked_at=server.last_health_at,
|
||||
)
|
||||
else:
|
||||
service = WinRMService(
|
||||
server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._winrm_creds(credential),
|
||||
)
|
||||
|
||||
started = datetime.now(UTC)
|
||||
ok, message = await service.test_connection()
|
||||
latency_ms = (datetime.now(UTC) - started).total_seconds() * 1000
|
||||
|
||||
server.last_health_at = datetime.now(UTC)
|
||||
server.last_health_ok = ok
|
||||
|
||||
return HealthCheckResult(
|
||||
server_id=server.id,
|
||||
ok=ok,
|
||||
latency_ms=round(latency_ms, 1),
|
||||
message=message,
|
||||
checked_at=server.last_health_at,
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Update job routes: trigger, list, logs, cancel."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip, get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import JobNotCancellableError, NotFoundError
|
||||
from app.models.server import Server
|
||||
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
||||
from app.models.user import User
|
||||
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
||||
from app.services.audit import AuditService
|
||||
from app.services.job_runner import job_runner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=UpdateJobRead, status_code=201)
|
||||
async def trigger_update(
|
||||
payload: JobTriggerRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
server = await db.get(Server, payload.server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
job = UpdateJob(
|
||||
server_id=server.id,
|
||||
type=payload.type,
|
||||
started_by=user.username,
|
||||
)
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.trigger",
|
||||
target=server.name,
|
||||
details={"job_id": job.id, "type": payload.type.value},
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await job_runner.start(job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("", response_model=list[UpdateJobRead])
|
||||
async def list_jobs(
|
||||
status: JobStatus | None = None,
|
||||
limit: int = Query(default=50, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[UpdateJob]:
|
||||
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
|
||||
if status:
|
||||
stmt = stmt.where(UpdateJob.status == status)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=UpdateJobRead)
|
||||
async def get_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/{job_id}/logs", response_model=list[UpdateLogRead])
|
||||
async def get_job_logs(
|
||||
job_id: int,
|
||||
after_id: int = 0,
|
||||
limit: int = Query(default=500, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[UpdateLog]:
|
||||
stmt = (
|
||||
select(UpdateLog)
|
||||
.where(UpdateLog.job_id == job_id, UpdateLog.id > after_id)
|
||||
.order_by(UpdateLog.id)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=UpdateJobRead)
|
||||
async def cancel_job(
|
||||
job_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
|
||||
raise JobNotCancellableError()
|
||||
|
||||
cancelled = await job_runner.cancel(job_id)
|
||||
if not cancelled:
|
||||
job.status = JobStatus.CANCELLED
|
||||
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.cancel",
|
||||
target=f"job:{job_id}",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def job_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
total = await db.scalar(select(func.count(UpdateJob.id)))
|
||||
running = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
|
||||
)
|
||||
failed = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
|
||||
)
|
||||
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core package: config, database, security, logging, exceptions."""
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Application configuration via pydantic-settings.
|
||||
|
||||
All values are read from environment variables / .env file.
|
||||
See .env.example for the full list.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Central application settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Core
|
||||
app_env: str = "development"
|
||||
app_name: str = "Insight Updater"
|
||||
secret_key: str = "dev-secret-change-me-32-chars-min"
|
||||
encryption_key: str = ""
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "json"
|
||||
|
||||
# JWT
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_private_key_path: str = "keys/private.pem"
|
||||
jwt_public_key_path: str = "keys/public.pem"
|
||||
jwt_access_token_expire_minutes: int = 30
|
||||
jwt_refresh_token_expire_days: int = 7
|
||||
|
||||
# Database / Redis
|
||||
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# WinRM
|
||||
winrm_transport: str = "ntlm"
|
||||
winrm_cert_validation: str = "ignore"
|
||||
winrm_operation_timeout: int = 60
|
||||
winrm_read_timeout: int = 120
|
||||
winrm_kerberos_delegation: bool = True
|
||||
|
||||
# SSH
|
||||
ssh_timeout: int = 30
|
||||
|
||||
# LDAP (stub)
|
||||
ldap_enabled: bool = False
|
||||
ldap_uri: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
ldap_user_search_base: str = ""
|
||||
ldap_user_filter: str = "(sAMAccountName={username})"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Database setup: async engine, session factory, declarative base."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.log_level.upper() == "DEBUG",
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency yielding an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (scaffold mode; Alembic migrations come later)."""
|
||||
# Import models so they register on the metadata
|
||||
from app import models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Custom application exceptions, mapped to HTTP responses in main.py."""
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base class for application errors."""
|
||||
|
||||
status_code: int = 500
|
||||
detail: str = "Internal server error"
|
||||
|
||||
def __init__(self, detail: str | None = None) -> None:
|
||||
super().__init__(detail or self.detail)
|
||||
if detail:
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
detail = "Resource not found"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
detail = "Resource conflict"
|
||||
|
||||
|
||||
class UnauthorizedError(AppError):
|
||||
status_code = 401
|
||||
detail = "Authentication required"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code = 403
|
||||
detail = "Permission denied"
|
||||
|
||||
|
||||
class InvalidTokenError(UnauthorizedError):
|
||||
detail = "Token is invalid or expired"
|
||||
|
||||
|
||||
class CredentialDecryptionError(AppError):
|
||||
status_code = 500
|
||||
detail = "Stored credential cannot be decrypted"
|
||||
|
||||
|
||||
class ConnectionTestError(AppError):
|
||||
status_code = 502
|
||||
detail = "Connection test failed"
|
||||
|
||||
|
||||
class WinRMError(AppError):
|
||||
status_code = 502
|
||||
detail = "WinRM operation failed"
|
||||
|
||||
|
||||
class SSHError(AppError):
|
||||
status_code = 502
|
||||
detail = "SSH operation failed"
|
||||
|
||||
|
||||
class CAUError(AppError):
|
||||
status_code = 502
|
||||
detail = "Cluster-Aware Updating operation failed"
|
||||
|
||||
|
||||
class JobNotCancellableError(ConflictError):
|
||||
detail = "Job cannot be cancelled in its current state"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Structured logging setup with structlog."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=level,
|
||||
)
|
||||
|
||||
processors: list = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
if settings.log_format == "json":
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
processors.append(structlog.dev.ConsoleRenderer())
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name) # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fernet encryption for stored credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
key = settings.encryption_key
|
||||
if not key:
|
||||
# Dev fallback: derive a valid fernet key from SECRET_KEY
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
key = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(settings.secret_key.encode()).digest()
|
||||
).decode()
|
||||
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt a secret for at-rest storage."""
|
||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(token: str) -> str:
|
||||
"""Decrypt a stored secret. Raises CredentialDecryptionError on failure."""
|
||||
try:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise CredentialDecryptionError("Stored credential cannot be decrypted") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
# bcrypt hard limit: 72 bytes
|
||||
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode()[:72], hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT (RS256 with key files, HS256 fallback for dev without keys)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_key(path: str) -> str | None:
|
||||
p = Path(path)
|
||||
return p.read_text() if p.exists() else None
|
||||
|
||||
|
||||
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||
claims: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
|
||||
private_key = _read_key(settings.jwt_private_key_path)
|
||||
if private_key and settings.jwt_algorithm == "RS256":
|
||||
return jwt.encode(claims, private_key, algorithm="RS256")
|
||||
# Dev fallback when no key pair exists yet
|
||||
return jwt.encode(claims, settings.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT. Raises InvalidTokenError on failure."""
|
||||
try:
|
||||
public_key = _read_key(settings.jwt_public_key_path)
|
||||
if public_key and settings.jwt_algorithm == "RS256":
|
||||
return jwt.decode(token, public_key, algorithms=["RS256"]) # type: ignore[no-any-return]
|
||||
return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) # type: ignore[no-any-return]
|
||||
except JWTError as exc:
|
||||
raise InvalidTokenError("Token is invalid or expired") from exc
|
||||
@@ -0,0 +1,108 @@
|
||||
"""FastAPI application entrypoint.
|
||||
|
||||
Mounts:
|
||||
- REST API under /api
|
||||
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
|
||||
- /health liveness probe
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import socketio
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import init_db
|
||||
from app.core.exceptions import AppError
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.websocket import sio
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
||||
setup_logging()
|
||||
logger.info("app.starting", env=settings.app_env)
|
||||
|
||||
await init_db()
|
||||
await _seed_default_admin()
|
||||
|
||||
# Attach Redis manager for Socket.io pub/sub (optional in dev)
|
||||
try:
|
||||
from socketio import AsyncRedisManager
|
||||
|
||||
sio.manager = AsyncRedisManager(settings.redis_url)
|
||||
logger.info("ws.redis_manager_attached", url=settings.redis_url)
|
||||
except Exception as exc: # noqa: BLE001 - Redis optional for scaffold
|
||||
logger.warning("ws.redis_unavailable", error=str(exc))
|
||||
|
||||
yield
|
||||
|
||||
logger.info("app.stopping")
|
||||
|
||||
|
||||
async def _seed_default_admin() -> None:
|
||||
"""Create the initial admin user if no users exist (dev bootstrap).
|
||||
|
||||
Password comes from ADMIN_INITIAL_PASSWORD env, default 'admin' (dev only).
|
||||
"""
|
||||
import os
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import async_session_factory
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
|
||||
async with async_session_factory() as db:
|
||||
count = await db.scalar(select(func.count(User.id)))
|
||||
if count and count > 0:
|
||||
return
|
||||
password = os.environ.get("ADMIN_INITIAL_PASSWORD", "admin")
|
||||
db.add(
|
||||
User(
|
||||
username="admin",
|
||||
email=None,
|
||||
full_name="Administrator",
|
||||
password_hash=hash_password(password),
|
||||
is_admin=True,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("app.default_admin_created", username="admin")
|
||||
|
||||
|
||||
fastapi_app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
fastapi_app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@fastapi_app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
@fastapi_app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
|
||||
|
||||
|
||||
fastapi_app.include_router(api_router)
|
||||
|
||||
# Combined ASGI app: FastAPI + Socket.io
|
||||
app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app, socketio_path="socket.io")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy ORM models."""
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.credential import Credential
|
||||
from app.models.server import Server
|
||||
from app.models.update_job import UpdateJob, UpdateLog
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Credential",
|
||||
"Server",
|
||||
"UpdateJob",
|
||||
"UpdateLog",
|
||||
"User",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Immutable audit trail model."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(255), index=True)
|
||||
action: Mapped[str] = mapped_column(String(100), index=True) # e.g. server.create
|
||||
target: Mapped[str | None] = mapped_column(String(255), nullable=True) # e.g. server name
|
||||
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
|
||||
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Credential model - secrets stored Fernet-encrypted."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class CredentialType(str, enum.Enum):
|
||||
WINRM_USERPASS = "winrm_userpass"
|
||||
SSH_USERPASS = "ssh_userpass"
|
||||
SSH_KEY = "ssh_key"
|
||||
|
||||
|
||||
class Credential(Base):
|
||||
__tablename__ = "credentials"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
type: Mapped[CredentialType] = mapped_column(Enum(CredentialType))
|
||||
|
||||
username: Mapped[str] = mapped_column(String(255))
|
||||
# Encrypted at rest via core.security.encrypt()
|
||||
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
private_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
key_passphrase_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Server inventory model."""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ServerType(str, enum.Enum):
|
||||
WINDOWS = "windows" # WinRM
|
||||
LINUX = "linux" # SSH
|
||||
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
|
||||
|
||||
|
||||
class Server(Base):
|
||||
__tablename__ = "servers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
hostname: Mapped[str] = mapped_column(String(255))
|
||||
port: Mapped[int] = mapped_column(default=5985)
|
||||
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
||||
|
||||
credential_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("credentials.id"), nullable=True
|
||||
)
|
||||
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
|
||||
|
||||
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||
back_populates="server", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Update job + streamed log line models."""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobType(str, enum.Enum):
|
||||
WINDOWS_UPDATE = "windows_update"
|
||||
LINUX_UPDATE = "linux_update"
|
||||
CAU_RUN = "cau_run"
|
||||
HEALTH_CHECK = "health_check"
|
||||
|
||||
|
||||
class UpdateJob(Base):
|
||||
__tablename__ = "update_jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"), index=True)
|
||||
server: Mapped["Server"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||
|
||||
type: Mapped[JobType] = mapped_column(Enum(JobType))
|
||||
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
|
||||
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
started_by: Mapped[str] = mapped_column(String(255)) # username
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
logs: Mapped[list["UpdateLog"]] = relationship(
|
||||
back_populates="job", cascade="all, delete-orphan", order_by="UpdateLog.id"
|
||||
)
|
||||
|
||||
|
||||
class UpdateLog(Base):
|
||||
__tablename__ = "update_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey("update_jobs.id"), index=True)
|
||||
job: Mapped[UpdateJob] = relationship(back_populates="logs")
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
level: Mapped[str] = mapped_column(String(20), default="info")
|
||||
line: Mapped[str] = mapped_column(Text)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""User model - local admins or LDAP-mapped users."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # null = LDAP only
|
||||
is_ldap: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Pydantic schemas (request/response)."""
|
||||
|
||||
from app.schemas.audit import AuditLogRead
|
||||
from app.schemas.auth import LoginRequest, TokenResponse
|
||||
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
|
||||
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
||||
|
||||
__all__ = [
|
||||
"AuditLogRead",
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"ServerCreate",
|
||||
"ServerRead",
|
||||
"ServerUpdate",
|
||||
"JobTriggerRequest",
|
||||
"UpdateJobRead",
|
||||
"UpdateLogRead",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Audit log schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
timestamp: datetime
|
||||
username: str
|
||||
action: str
|
||||
target: str | None
|
||||
result: str
|
||||
details: str | None
|
||||
ip_address: str | None
|
||||
|
||||
|
||||
class AuditLogPage(BaseModel):
|
||||
items: list[AuditLogRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Auth schemas."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str | None
|
||||
full_name: str | None
|
||||
is_admin: bool
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Server schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.server import ServerType
|
||||
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
hostname: str = Field(min_length=1, max_length=255)
|
||||
port: int = 5985
|
||||
type: ServerType = ServerType.WINDOWS
|
||||
description: str | None = None
|
||||
tags: str | None = None
|
||||
credential_id: int | None = None
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
hostname: str | None = None
|
||||
port: int | None = None
|
||||
type: ServerType | None = None
|
||||
description: str | None = None
|
||||
tags: str | None = None
|
||||
credential_id: int | None = None
|
||||
|
||||
|
||||
class ServerRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
hostname: str
|
||||
port: int
|
||||
type: ServerType
|
||||
description: str | None
|
||||
tags: str | None
|
||||
credential_id: int | None
|
||||
last_health_at: datetime | None
|
||||
last_health_ok: bool | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HealthCheckResult(BaseModel):
|
||||
server_id: int
|
||||
ok: bool
|
||||
latency_ms: float | None = None
|
||||
message: str
|
||||
checked_at: datetime
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Update job schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.update_job import JobStatus, JobType
|
||||
|
||||
|
||||
class JobTriggerRequest(BaseModel):
|
||||
server_id: int
|
||||
type: JobType
|
||||
# CAU-specific options
|
||||
cluster_name: str | None = None
|
||||
# Linux-specific options
|
||||
reboot_if_required: bool = False
|
||||
|
||||
|
||||
class UpdateJobRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
server_id: int
|
||||
type: JobType
|
||||
status: JobStatus
|
||||
progress_percent: int
|
||||
current_phase: str | None
|
||||
started_by: str
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
error: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UpdateLogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
job_id: int
|
||||
timestamp: datetime
|
||||
level: str
|
||||
line: str
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic services: winrm, ssh, cau, audit."""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Audit service: write structured, immutable audit entries to DB."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Persists audit events. Every mutating API action should call this."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def log(
|
||||
self,
|
||||
username: str,
|
||||
action: str,
|
||||
target: str | None = None,
|
||||
result: str = "success",
|
||||
details: dict[str, Any] | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> AuditLog:
|
||||
entry = AuditLog(
|
||||
username=username,
|
||||
action=action,
|
||||
target=target,
|
||||
result=result,
|
||||
details=json.dumps(details) if details else None,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
self.db.add(entry)
|
||||
await self.db.flush()
|
||||
logger.info(
|
||||
"audit",
|
||||
username=username,
|
||||
action=action,
|
||||
target=target,
|
||||
result=result,
|
||||
)
|
||||
return entry
|
||||
@@ -0,0 +1,77 @@
|
||||
"""CAU service: Cluster-Aware Updating via PowerShell remoting (WinRM)."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.core.exceptions import CAUError
|
||||
from app.core.logging import get_logger
|
||||
from app.services.winrm import WinRMCredentials, WinRMService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CAUService:
|
||||
"""Orchestrates Invoke-CauRun against a Windows failover cluster."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cluster_name: str,
|
||||
access_node: str,
|
||||
port: int = 5985,
|
||||
credentials: WinRMCredentials | None = None,
|
||||
) -> None:
|
||||
self.cluster_name = cluster_name
|
||||
self.winrm = WinRMService(access_node, port=port, credentials=credentials)
|
||||
|
||||
async def get_cluster_nodes(self) -> list[dict[str, str]]:
|
||||
"""List cluster nodes and their state."""
|
||||
script = f"""
|
||||
Import-Module FailoverClusters
|
||||
Get-ClusterNode -Cluster {self.cluster_name} |
|
||||
Select-Object Name, State |
|
||||
ForEach-Object {{ "$($_.Name)|$($_.State)" }}
|
||||
"""
|
||||
output = await self.winrm.run_powershell(script)
|
||||
nodes = []
|
||||
for line in output.splitlines():
|
||||
if "|" in line:
|
||||
name, state = line.split("|", 1)
|
||||
nodes.append({"name": name.strip(), "state": state.strip()})
|
||||
return nodes
|
||||
|
||||
async def get_cau_status(self) -> str:
|
||||
"""Return the last CAU run summary."""
|
||||
script = f"""
|
||||
Import-Module ClusterAwareUpdating
|
||||
Get-CauRun -ClusterName {self.cluster_name} |
|
||||
Select-Object -First 1 |
|
||||
Format-List | Out-String
|
||||
"""
|
||||
return await self.winrm.run_powershell(script)
|
||||
|
||||
async def test_cluster(self) -> tuple[bool, str]:
|
||||
"""Verify the cluster is reachable and CAU module present."""
|
||||
try:
|
||||
nodes = await self.get_cluster_nodes()
|
||||
if not nodes:
|
||||
return False, "Keine Cluster-Knoten gefunden"
|
||||
return True, f"Cluster OK — {len(nodes)} Knoten"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
async def invoke_cau_run(self) -> AsyncIterator[str]:
|
||||
"""Start a CAU run and stream per-node progress lines.
|
||||
|
||||
Scaffold implementation: starts Invoke-CauRun and polls Get-CauReport.
|
||||
TODO: true live streaming of per-node phases via event log polling.
|
||||
"""
|
||||
script = f"""
|
||||
Import-Module ClusterAwareUpdating
|
||||
Invoke-CauRun -ClusterName {self.cluster_name} `
|
||||
-Force -Confirm:$false -WaitForCompletion |
|
||||
Out-String
|
||||
"""
|
||||
try:
|
||||
async for line in self.winrm.stream_powershell(script):
|
||||
yield line
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Job runner: executes update jobs in background tasks, streams logs to WS + DB."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.database import async_session_factory
|
||||
from app.core.logging import get_logger
|
||||
from app.models.credential import Credential, CredentialType
|
||||
from app.models.server import Server, ServerType
|
||||
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
||||
from app.services.cau import CAUService
|
||||
from app.services.ssh import SSHCredentials, SSHService
|
||||
from app.services.winrm import WinRMCredentials, WinRMService
|
||||
from app.websocket import handlers as ws
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class JobRunner:
|
||||
"""Manages asyncio tasks for running update jobs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[int, asyncio.Task] = {} # type: ignore[type-arg]
|
||||
|
||||
async def start(self, job_id: int) -> None:
|
||||
task = asyncio.create_task(self._run(job_id), name=f"job-{job_id}")
|
||||
self._tasks[job_id] = task
|
||||
task.add_done_callback(lambda _t: self._tasks.pop(job_id, None))
|
||||
|
||||
async def cancel(self, job_id: int) -> bool:
|
||||
task = self._tasks.get(job_id)
|
||||
if not task:
|
||||
return False
|
||||
task.cancel()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run(self, job_id: int) -> None:
|
||||
async with async_session_factory() as db:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
logger.error("job.not_found", job_id=job_id)
|
||||
return
|
||||
server = await db.get(Server, job.server_id)
|
||||
if not server:
|
||||
await self._finish(db, job, JobStatus.FAILED, "Server nicht gefunden")
|
||||
return
|
||||
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
await ws.emit_job_start(job.id, server.id, job.type.value)
|
||||
started = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await self._dispatch(db, job, server)
|
||||
await self._finish(db, job, JobStatus.SUCCESS, None, started)
|
||||
except asyncio.CancelledError:
|
||||
await self._finish(db, job, JobStatus.CANCELLED, "Vom Benutzer abgebrochen", started)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("job.failed", job_id=job_id, error=str(exc))
|
||||
await self._finish(db, job, JobStatus.FAILED, str(exc), started)
|
||||
|
||||
async def _dispatch(self, db, job: UpdateJob, server: Server) -> None: # type: ignore[no-untyped-def]
|
||||
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
|
||||
|
||||
if server.type == ServerType.LINUX:
|
||||
creds = self._ssh_creds(credential)
|
||||
service = SSHService(server.hostname, port=server.port, credentials=creds)
|
||||
async for line in service.stream_updates():
|
||||
await self._log(db, job, line)
|
||||
|
||||
elif server.type == ServerType.WINDOWS:
|
||||
creds = self._winrm_creds(credential)
|
||||
service = WinRMService(server.hostname, port=server.port, credentials=creds)
|
||||
async for line in service.install_updates():
|
||||
await self._log(db, job, line)
|
||||
|
||||
elif server.type == ServerType.CAU_CLUSTER:
|
||||
creds = self._winrm_creds(credential)
|
||||
service = CAUService(server.hostname, access_node=server.hostname, port=server.port, credentials=creds)
|
||||
async for line in service.invoke_cau_run():
|
||||
await self._log(db, job, line)
|
||||
|
||||
async def _log(self, db, job: UpdateJob, line: str, level: str = "info") -> None: # type: ignore[no-untyped-def]
|
||||
db.add(UpdateLog(job_id=job.id, level=level, line=line))
|
||||
await db.commit()
|
||||
await ws.emit_job_log(job.id, line, level)
|
||||
|
||||
async def _finish( # type: ignore[no-untyped-def]
|
||||
self, db, job: UpdateJob, status: JobStatus, error: str | None, started: datetime | None = None
|
||||
) -> None:
|
||||
job.status = status
|
||||
job.error = error
|
||||
job.finished_at = datetime.now(UTC)
|
||||
if status == JobStatus.SUCCESS:
|
||||
job.progress_percent = 100
|
||||
await db.commit()
|
||||
duration = (
|
||||
(job.finished_at - started).total_seconds() if started else None
|
||||
)
|
||||
await ws.emit_job_complete(job.id, status.value, duration)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _winrm_creds(credential: Credential | None) -> WinRMCredentials | None:
|
||||
if not credential or credential.type != CredentialType.WINRM_USERPASS:
|
||||
return None
|
||||
from app.core.security import decrypt
|
||||
|
||||
return WinRMCredentials(
|
||||
username=credential.username,
|
||||
password=decrypt(credential.password_encrypted or ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ssh_creds(credential: Credential | None) -> SSHCredentials | None:
|
||||
if not credential:
|
||||
return None
|
||||
from app.core.security import decrypt
|
||||
|
||||
return SSHCredentials(
|
||||
username=credential.username,
|
||||
password=decrypt(credential.password_encrypted) if credential.password_encrypted else None,
|
||||
private_key=decrypt(credential.private_key_encrypted)
|
||||
if credential.private_key_encrypted
|
||||
else None,
|
||||
passphrase=decrypt(credential.key_passphrase_encrypted)
|
||||
if credential.key_passphrase_encrypted
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
job_runner = JobRunner()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""SSH service: connect to Linux hosts via asyncssh, run updates with sudo."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import SSHError
|
||||
from app.core.logging import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHCredentials:
|
||||
username: str
|
||||
password: str | None = None
|
||||
private_key: str | None = None
|
||||
passphrase: str | None = None
|
||||
|
||||
|
||||
class SSHService:
|
||||
"""Async SSH operations via asyncssh."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int = 22,
|
||||
credentials: SSHCredentials | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.credentials = credentials
|
||||
|
||||
def _connect_kwargs(self) -> dict:
|
||||
kwargs: dict = {
|
||||
"host": self.hostname,
|
||||
"port": self.port,
|
||||
"known_hosts": None, # scaffold: accept any host key
|
||||
"connect_timeout": settings.ssh_timeout,
|
||||
}
|
||||
if self.credentials:
|
||||
kwargs["username"] = self.credentials.username
|
||||
if self.credentials.password:
|
||||
kwargs["password"] = self.credentials.password
|
||||
if self.credentials.private_key:
|
||||
kwargs["client_keys"] = [
|
||||
__import__("asyncssh").import_private_key(
|
||||
self.credentials.private_key,
|
||||
passphrase=self.credentials.passphrase,
|
||||
)
|
||||
]
|
||||
return kwargs
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||
result = await conn.run("hostname", check=True)
|
||||
return True, f"Verbunden mit {result.stdout.strip()}" # type: ignore[union-attr]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
async def run_command(self, command: str, sudo: bool = False) -> str:
|
||||
"""Run a command, optionally via sudo -S with the stored password."""
|
||||
import asyncssh
|
||||
|
||||
if sudo:
|
||||
password = (self.credentials.password if self.credentials else None) or ""
|
||||
command = f"echo '{password}' | sudo -S {command}"
|
||||
|
||||
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||
result = await conn.run(command, check=False)
|
||||
if result.returncode != 0:
|
||||
raise SSHError(
|
||||
f"Command failed (exit {result.returncode}): {str(result.stderr).strip()}"
|
||||
)
|
||||
return str(result.stdout)
|
||||
|
||||
async def detect_package_manager(self) -> str:
|
||||
"""Detect apt, dnf, or yum on the target."""
|
||||
import asyncssh
|
||||
|
||||
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||
for pm in ("apt-get", "dnf", "yum"):
|
||||
result = await conn.run(f"command -v {pm}", check=False)
|
||||
if result.returncode == 0:
|
||||
return pm
|
||||
raise SSHError("Kein unterstützter Paketmanager gefunden (apt/dnf/yum)")
|
||||
|
||||
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
|
||||
"""Run the distro update command and yield output lines live."""
|
||||
import asyncssh
|
||||
|
||||
pm = await self.detect_package_manager()
|
||||
if pm == "apt-get":
|
||||
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
|
||||
else:
|
||||
cmd = f"{pm} update -y"
|
||||
|
||||
password = (self.credentials.password if self.credentials else None) or ""
|
||||
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
|
||||
|
||||
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||
async with conn.create_process(full_cmd) as process:
|
||||
async for line in process.stdout: # type: ignore[union-attr]
|
||||
yield str(line).rstrip()
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise SSHError(f"Update fehlgeschlagen (exit {process.returncode})")
|
||||
|
||||
if reboot_if_required:
|
||||
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||
check = await conn.run("test -f /var/run/reboot-required", check=False)
|
||||
if check.returncode == 0:
|
||||
yield "REBOOT erforderlich — wird ausgeführt..."
|
||||
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""WinRM service: connect to Windows hosts, run PowerShell, stream output.
|
||||
|
||||
Uses python-winrm (pywinrm). All blocking calls run in a thread pool
|
||||
so the async event loop is never blocked.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import WinRMError
|
||||
from app.core.logging import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WinRMCredentials:
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class WinRMService:
|
||||
"""Wraps pywinrm for async usage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int = 5985,
|
||||
credentials: WinRMCredentials | None = None,
|
||||
transport: str | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.credentials = credentials
|
||||
self.transport = transport or settings.winrm_transport
|
||||
|
||||
def _build_session(self): # type: ignore[no-untyped-def]
|
||||
import winrm # pywinrm
|
||||
|
||||
scheme = "https" if self.port == 5986 else "http"
|
||||
endpoint = f"{scheme}://{self.hostname}:{self.port}/wsman"
|
||||
kwargs: dict = {
|
||||
"transport": self.transport,
|
||||
"server_cert_validation": settings.winrm_cert_validation,
|
||||
"operation_timeout_sec": settings.winrm_operation_timeout,
|
||||
"read_timeout_sec": settings.winrm_read_timeout,
|
||||
}
|
||||
if self.credentials:
|
||||
kwargs["username"] = self.credentials.username
|
||||
kwargs["password"] = self.credentials.password
|
||||
return winrm.Session(endpoint, **kwargs)
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
"""Run a trivial command to verify connectivity."""
|
||||
|
||||
def _run() -> tuple[bool, str]:
|
||||
try:
|
||||
session = self._build_session()
|
||||
result = session.run_ps("$env:COMPUTERNAME")
|
||||
if result.status_code == 0:
|
||||
name = result.std_out.decode(errors="replace").strip()
|
||||
return True, f"Verbunden mit {name}"
|
||||
return False, result.std_err.decode(errors="replace").strip()
|
||||
except Exception as exc: # noqa: BLE001 - surface any transport error
|
||||
return False, str(exc)
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def run_powershell(self, script: str) -> str:
|
||||
"""Run a PowerShell script and return stdout. Raises WinRMError on failure."""
|
||||
|
||||
def _run() -> str:
|
||||
session = self._build_session()
|
||||
result = session.run_ps(script)
|
||||
if result.status_code != 0:
|
||||
err = result.std_err.decode(errors="replace").strip()
|
||||
raise WinRMError(f"PowerShell exit {result.status_code}: {err}")
|
||||
return result.std_out.decode(errors="replace")
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def stream_powershell(self, script: str) -> AsyncIterator[str]:
|
||||
"""Yield output lines as they arrive (scaffold: runs to completion, then yields).
|
||||
|
||||
TODO: switch to winrm Protocol with shell polling for true streaming.
|
||||
"""
|
||||
output = await self.run_powershell(script)
|
||||
for line in output.splitlines():
|
||||
yield line
|
||||
await asyncio.sleep(0) # keep the loop responsive
|
||||
|
||||
async def get_pending_updates(self) -> list[str]:
|
||||
"""Query Windows Update for pending updates (titles only)."""
|
||||
script = """
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$searcher = $session.CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0")
|
||||
$result.Updates | ForEach-Object { $_.Title }
|
||||
"""
|
||||
output = await self.run_powershell(script)
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
|
||||
async def install_updates(self) -> AsyncIterator[str]:
|
||||
"""Install all pending Windows Updates, yielding progress lines."""
|
||||
script = """
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$searcher = $session.CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0")
|
||||
Write-Output "Gefundene Updates: $($result.Updates.Count)"
|
||||
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||
$result.Updates | ForEach-Object { $toInstall.Add($_) | Out-Null }
|
||||
$installer = $session.CreateUpdateInstaller()
|
||||
$installer.Updates = $toInstall
|
||||
$installResult = $installer.Install()
|
||||
Write-Output "ResultCode: $($installResult.ResultCode)"
|
||||
Write-Output "RebootRequired: $($installResult.RebootRequired)"
|
||||
"""
|
||||
async for line in self.stream_powershell(script):
|
||||
yield line
|
||||
@@ -0,0 +1,6 @@
|
||||
"""WebSocket layer: Socket.io server, connection manager, event handlers."""
|
||||
|
||||
from app.websocket.handlers import sio
|
||||
from app.websocket.manager import WSManager, ws_manager
|
||||
|
||||
__all__ = ["WSManager", "sio", "ws_manager"]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Socket.io server instance and event handlers.
|
||||
|
||||
Events (see AGENTS.md):
|
||||
Server -> Client: job:start, job:log, job:progress, job:complete
|
||||
Client -> Server: job:subscribe, job:unsubscribe, job:cancel
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import socketio
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.websocket.manager import ws_manager
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
sio = socketio.AsyncServer(
|
||||
async_mode="asgi",
|
||||
cors_allowed_origins=settings.cors_origin_list or "*",
|
||||
# Redis manager for multi-worker pub/sub; set in main.py when Redis is up
|
||||
)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid: str, environ: dict, auth: dict | None) -> None: # noqa: ARG001
|
||||
# TODO: validate JWT from auth payload before accepting
|
||||
ws_manager.register(sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid: str) -> None:
|
||||
ws_manager.unregister(sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def subscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Client subscribes to a job's live log room."""
|
||||
job_id = int(data.get("job_id", 0))
|
||||
room = ws_manager.subscribe(sid, job_id)
|
||||
await sio.enter_room(sid, room)
|
||||
logger.info("ws.subscribed", sid=sid, room=room)
|
||||
return {"ok": True, "room": room}
|
||||
|
||||
|
||||
@sio.event
|
||||
async def unsubscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
job_id = int(data.get("job_id", 0))
|
||||
room = ws_manager.unsubscribe(sid, job_id)
|
||||
await sio.leave_room(sid, room)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@sio.event
|
||||
async def cancel_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Client requests job cancellation."""
|
||||
from app.services.job_runner import job_runner
|
||||
|
||||
job_id = int(data.get("job_id", 0))
|
||||
cancelled = await job_runner.cancel(job_id)
|
||||
return {"ok": cancelled}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit helpers used by services / job runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def emit_job_start(job_id: int, server_id: int, job_type: str) -> None:
|
||||
await sio.emit(
|
||||
"job:start",
|
||||
{"job_id": job_id, "server_id": server_id, "type": job_type},
|
||||
room=ws_manager.room_for(job_id),
|
||||
)
|
||||
|
||||
|
||||
async def emit_job_log(job_id: int, line: str, level: str = "info") -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
await sio.emit(
|
||||
"job:log",
|
||||
{"job_id": job_id, "line": line, "level": level, "timestamp": datetime.now(UTC).isoformat()},
|
||||
room=ws_manager.room_for(job_id),
|
||||
)
|
||||
|
||||
|
||||
async def emit_job_progress(
|
||||
job_id: int, percent: int, phase: str, node: str | None = None
|
||||
) -> None:
|
||||
await sio.emit(
|
||||
"job:progress",
|
||||
{"job_id": job_id, "percent": percent, "phase": phase, "node": node},
|
||||
room=ws_manager.room_for(job_id),
|
||||
)
|
||||
|
||||
|
||||
async def emit_job_complete(job_id: int, status: str, duration: float | None) -> None:
|
||||
await sio.emit(
|
||||
"job:complete",
|
||||
{"job_id": job_id, "status": status, "duration": duration},
|
||||
room=ws_manager.room_for(job_id),
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Connection manager for Socket.io rooms (one room per update job)."""
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WSManager:
|
||||
"""Tracks active Socket.io sessions and job-room subscriptions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# sid -> set of job rooms the client subscribed to
|
||||
self._subscriptions: dict[str, set[str]] = {}
|
||||
|
||||
def register(self, sid: str) -> None:
|
||||
self._subscriptions.setdefault(sid, set())
|
||||
logger.info("ws.client_connected", sid=sid)
|
||||
|
||||
def unregister(self, sid: str) -> None:
|
||||
self._subscriptions.pop(sid, None)
|
||||
logger.info("ws.client_disconnected", sid=sid)
|
||||
|
||||
def subscribe(self, sid: str, job_id: int) -> str:
|
||||
room = self.room_for(job_id)
|
||||
self._subscriptions.setdefault(sid, set()).add(room)
|
||||
return room
|
||||
|
||||
def unsubscribe(self, sid: str, job_id: int) -> str:
|
||||
room = self.room_for(job_id)
|
||||
if sid in self._subscriptions:
|
||||
self._subscriptions[sid].discard(room)
|
||||
return room
|
||||
|
||||
@staticmethod
|
||||
def room_for(job_id: int) -> str:
|
||||
return f"job:{job_id}"
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._subscriptions)
|
||||
|
||||
|
||||
ws_manager = WSManager()
|
||||
Reference in New Issue
Block a user