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:
B0rbor4d
2026-07-31 23:45:31 +00:00
commit cf7c29639c
72 changed files with 10610 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Insight Updater backend application package."""
__version__ = "0.1.0"
+11
View File
@@ -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"])
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""API route modules."""
+44
View File
@@ -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,
)
+55
View File
@@ -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")
+160
View File
@@ -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,
)
+134
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
"""Core package: config, database, security, logging, exceptions."""
+68
View File
@@ -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()
+47
View File
@@ -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)
+66
View File
@@ -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"
+44
View File
@@ -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]
+100
View File
@@ -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
+108
View File
@@ -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")
+16
View File
@@ -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",
]
+23
View File
@@ -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)
+39
View File
@@ -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),
)
+48
View File
@@ -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"
)
+64
View File
@@ -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)
+27
View File
@@ -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)
+18
View File
@@ -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",
]
+25
View File
@@ -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
+22
View File
@@ -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
+52
View File
@@ -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
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Business logic services: winrm, ssh, cau, audit."""
+46
View File
@@ -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
+77
View File
@@ -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
+138
View File
@@ -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()
+118
View File
@@ -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)
+122
View File
@@ -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
+6
View File
@@ -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"]
+103
View File
@@ -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),
)
+43
View File
@@ -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()