Hub-and-Spoke Umbau: Multi-Tenant Zentrale + Satellite-Agent
- Backend: Customer/Satellite Models, customer_id auf Server/Job/Audit - Satellite-API: heartbeat, poll (atomares Claiming), logs, result, scan-result, health-report - Auth via X-Api-Key (SHA-256 gehasht) - Job-Queue: pending/claimed/running/success/failed + Stale-Janitor - Batch-Trigger: ein Job pro Server, Satellite arbeitet sequenziell ab - Credentials bleiben lokal: nur symbolische credential_ref zentral - Neues Paket satellite/: Pull-Loop, WinRM/SSH/CAU/Scanner, PyInstaller-tauglich - Frontend: Kunden-Switcher, Satelliten-View, Polling statt WebSocket - Entfernt: WebSocket/Socket.io, Redis, zentrale Credentials, JobRunner - Docs: README/AGENTS/PROMPT auf neue Architektur aktualisiert
This commit is contained in:
@@ -2,10 +2,13 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import audit, auth, servers, updates
|
||||
from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(customers.router, prefix="/customers", tags=["customers"])
|
||||
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
|
||||
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"])
|
||||
api_router.include_router(satellite_api.router, prefix="/satellite", tags=["satellite-api"])
|
||||
|
||||
+20
-4
@@ -1,12 +1,14 @@
|
||||
"""Shared API dependencies: current user extraction from JWT."""
|
||||
"""Shared API dependencies: dashboard user auth (JWT) and satellite auth (API key)."""
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi import Depends, Header, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
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.satellite import Satellite, hash_api_key
|
||||
from app.models.user import User
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -23,8 +25,6 @@ async def get_current_user(
|
||||
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:
|
||||
@@ -38,5 +38,21 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_satellite(
|
||||
x_api_key: str | None = Header(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Satellite:
|
||||
"""Authenticate a satellite by its API key (X-Api-Key header)."""
|
||||
if not x_api_key:
|
||||
raise UnauthorizedError("X-Api-Key header fehlt")
|
||||
result = await db.execute(
|
||||
select(Satellite).where(Satellite.api_key_hash == hash_api_key(x_api_key))
|
||||
)
|
||||
satellite = result.scalar_one_or_none()
|
||||
if not satellite or not satellite.is_active:
|
||||
raise UnauthorizedError("Satellite unbekannt oder deaktiviert")
|
||||
return satellite
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
|
||||
@@ -19,6 +19,7 @@ async def list_audit_logs(
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
action: str | None = None,
|
||||
username: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> AuditLogPage:
|
||||
@@ -31,6 +32,9 @@ async def list_audit_logs(
|
||||
if username:
|
||||
stmt = stmt.where(AuditLog.username == username)
|
||||
count_stmt = count_stmt.where(AuditLog.username == username)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(AuditLog.customer_id == customer_id)
|
||||
count_stmt = count_stmt.where(AuditLog.customer_id == customer_id)
|
||||
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Customer routes (tenant management)."""
|
||||
|
||||
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 ConflictError, NotFoundError
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.schemas.customer import CustomerCreate, CustomerRead, CustomerUpdate
|
||||
from app.services.audit import AuditService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomerRead])
|
||||
async def list_customers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[Customer]:
|
||||
result = await db.execute(select(Customer).order_by(Customer.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerRead, status_code=201)
|
||||
async def create_customer(
|
||||
payload: CustomerCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Customer:
|
||||
existing = await db.execute(
|
||||
select(Customer).where((Customer.name == payload.name) | (Customer.slug == payload.slug))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError("Kunde mit diesem Namen oder Slug existiert bereits")
|
||||
|
||||
customer = Customer(**payload.model_dump())
|
||||
db.add(customer)
|
||||
await db.flush()
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="customer.create",
|
||||
target=customer.name,
|
||||
customer_id=customer.id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return customer
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerRead)
|
||||
async def get_customer(
|
||||
customer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> Customer:
|
||||
customer = await db.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
return customer
|
||||
|
||||
|
||||
@router.patch("/{customer_id}", response_model=CustomerRead)
|
||||
async def update_customer(
|
||||
customer_id: int,
|
||||
payload: CustomerUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Customer:
|
||||
customer = await db.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(customer, field, value)
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="customer.update",
|
||||
target=customer.name,
|
||||
customer_id=customer.id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=204)
|
||||
async def delete_customer(
|
||||
customer_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
customer = await db.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="customer.delete",
|
||||
target=customer.name,
|
||||
customer_id=customer.id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.delete(customer)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Satellite agent API - polled by remote satellites, authenticated via X-Api-Key.
|
||||
|
||||
Pull model: satellites poll for jobs, execute them locally in the customer
|
||||
network, push log batches and final results back here.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_satellite
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.core.logging import get_logger
|
||||
from app.models.satellite import Satellite
|
||||
from app.models.server import Server, ServerType
|
||||
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
|
||||
from app.schemas.satellite_api import (
|
||||
HealthReportRequest,
|
||||
HeartbeatRequest,
|
||||
JobResultRequest,
|
||||
LogBatchRequest,
|
||||
PollResponse,
|
||||
SatelliteJob,
|
||||
ScanResultRequest,
|
||||
)
|
||||
from app.services.audit import AuditService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/heartbeat")
|
||||
async def heartbeat(
|
||||
payload: HeartbeatRequest,
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
satellite.last_seen_at = datetime.now(UTC)
|
||||
satellite.version = payload.version
|
||||
satellite.hostname = payload.hostname
|
||||
return {"ok": True, "server_time": datetime.now(UTC).isoformat()}
|
||||
|
||||
|
||||
@router.get("/poll", response_model=PollResponse)
|
||||
async def poll_jobs(
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PollResponse:
|
||||
"""Claim and return pending jobs for this satellite's customer.
|
||||
|
||||
Claiming is atomic-ish: status flips pending -> claimed in the same
|
||||
transaction, so two satellites of one customer do not get the same job.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UpdateJob)
|
||||
.where(
|
||||
UpdateJob.customer_id == satellite.customer_id,
|
||||
UpdateJob.status == JobStatus.PENDING,
|
||||
)
|
||||
.order_by(UpdateJob.id)
|
||||
.limit(5)
|
||||
.with_for_update()
|
||||
)
|
||||
jobs = list(result.scalars().all())
|
||||
|
||||
now = datetime.now(UTC)
|
||||
out: list[SatelliteJob] = []
|
||||
for job in jobs:
|
||||
job.status = JobStatus.CLAIMED
|
||||
job.satellite_id = satellite.id
|
||||
job.claimed_at = now
|
||||
job.last_report_at = now
|
||||
|
||||
params = json.loads(job.params) if job.params else {}
|
||||
server = job.server
|
||||
out.append(
|
||||
SatelliteJob(
|
||||
job_id=job.id,
|
||||
type=job.type,
|
||||
server_id=server.id if server else None,
|
||||
server_name=server.name if server else None,
|
||||
hostname=server.hostname if server else None,
|
||||
port=server.port if server else None,
|
||||
server_type=server.type.value if server else None,
|
||||
credential_ref=server.credential_ref if server else None,
|
||||
reboot_if_required=bool(params.get("reboot_if_required", False)),
|
||||
scan_subnet=params.get("scan_subnet"),
|
||||
)
|
||||
)
|
||||
|
||||
if out:
|
||||
logger.info(
|
||||
"satellite.jobs_claimed",
|
||||
satellite=satellite.name,
|
||||
customer_id=satellite.customer_id,
|
||||
count=len(out),
|
||||
)
|
||||
return PollResponse(jobs=out)
|
||||
|
||||
|
||||
@router.post("/logs")
|
||||
async def push_logs(
|
||||
payload: LogBatchRequest,
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
job = await _get_own_job(db, payload.job_id, satellite)
|
||||
|
||||
if job.status == JobStatus.CLAIMED:
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = datetime.now(UTC)
|
||||
|
||||
for line in payload.lines:
|
||||
db.add(
|
||||
UpdateLog(
|
||||
job_id=job.id,
|
||||
timestamp=line.timestamp,
|
||||
level=line.level,
|
||||
line=line.line,
|
||||
)
|
||||
)
|
||||
if payload.progress_percent is not None:
|
||||
job.progress_percent = payload.progress_percent
|
||||
if payload.current_phase is not None:
|
||||
job.current_phase = payload.current_phase
|
||||
job.last_report_at = datetime.now(UTC)
|
||||
|
||||
return {"ok": True, "accepted": len(payload.lines)}
|
||||
|
||||
|
||||
@router.post("/result")
|
||||
async def push_result(
|
||||
payload: JobResultRequest,
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
job = await _get_own_job(db, payload.job_id, satellite)
|
||||
|
||||
job.status = JobStatus.SUCCESS if payload.status == "success" else JobStatus.FAILED
|
||||
job.error = payload.error
|
||||
job.finished_at = datetime.now(UTC)
|
||||
job.last_report_at = job.finished_at
|
||||
if job.status == JobStatus.SUCCESS:
|
||||
job.progress_percent = 100
|
||||
|
||||
await AuditService(db).log(
|
||||
username=f"satellite:{satellite.name}",
|
||||
action="job.result",
|
||||
target=f"job:{job.id}",
|
||||
result="success" if payload.status == "success" else "failure",
|
||||
customer_id=satellite.customer_id,
|
||||
details={"type": job.type.value, "error": payload.error},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/scan-result")
|
||||
async def push_scan_result(
|
||||
payload: ScanResultRequest,
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Ingest discovered hosts from a NETWORK_SCAN job as server candidates."""
|
||||
job = await _get_own_job(db, payload.job_id, satellite)
|
||||
if job.type != JobType.NETWORK_SCAN:
|
||||
raise ForbiddenError("Scan-Ergebnisse nur für NETWORK_SCAN Jobs")
|
||||
|
||||
created = 0
|
||||
for host in payload.hosts:
|
||||
existing = await db.execute(
|
||||
select(Server).where(
|
||||
Server.customer_id == satellite.customer_id,
|
||||
Server.hostname.in_([host.hostname, host.ip]),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
if host.winrm_open:
|
||||
stype, port = ServerType.WINDOWS, 5985
|
||||
elif host.ssh_open:
|
||||
stype, port = ServerType.LINUX, 22
|
||||
else:
|
||||
continue # not manageable - skip
|
||||
|
||||
db.add(
|
||||
Server(
|
||||
customer_id=satellite.customer_id,
|
||||
name=host.hostname,
|
||||
hostname=host.ip,
|
||||
port=port,
|
||||
type=stype,
|
||||
description=f"Auto-Discovery via Scan (Job #{job.id})",
|
||||
discovered_by_scan=True,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
|
||||
return {"ok": True, "created": created}
|
||||
|
||||
|
||||
@router.post("/health-report")
|
||||
async def push_health_report(
|
||||
payload: HealthReportRequest,
|
||||
satellite: Satellite = Depends(get_current_satellite),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
server = await db.get(Server, payload.server_id)
|
||||
if not server or server.customer_id != satellite.customer_id:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
server.last_health_at = datetime.now(UTC)
|
||||
server.last_health_ok = payload.ok
|
||||
server.last_health_message = payload.message
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _get_own_job(
|
||||
db: AsyncSession, job_id: int, satellite: Satellite
|
||||
) -> UpdateJob:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job or job.customer_id != satellite.customer_id:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
return job
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Satellite management routes (dashboard side).
|
||||
|
||||
The plaintext API key is returned exactly once on creation.
|
||||
"""
|
||||
|
||||
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.customer import Customer
|
||||
from app.models.satellite import Satellite, generate_api_key, hash_api_key
|
||||
from app.models.user import User
|
||||
from app.schemas.satellite import SatelliteCreate, SatelliteCreated, SatelliteRead
|
||||
from app.services.audit import AuditService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _created_response(satellite: Satellite, api_key: str) -> SatelliteCreated:
|
||||
data = SatelliteRead.model_validate(satellite).model_dump()
|
||||
return SatelliteCreated(**data, api_key=api_key)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SatelliteRead])
|
||||
async def list_satellites(
|
||||
customer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[Satellite]:
|
||||
stmt = select(Satellite).order_by(Satellite.id)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(Satellite.customer_id == customer_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=SatelliteCreated, status_code=201)
|
||||
async def create_satellite(
|
||||
payload: SatelliteCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> SatelliteCreated:
|
||||
customer = await db.get(Customer, payload.customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
|
||||
api_key = generate_api_key()
|
||||
satellite = Satellite(
|
||||
customer_id=payload.customer_id,
|
||||
name=payload.name,
|
||||
api_key_hash=hash_api_key(api_key),
|
||||
api_key_prefix=api_key[:11],
|
||||
)
|
||||
db.add(satellite)
|
||||
await db.flush()
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="satellite.create",
|
||||
target=f"{customer.name}/{satellite.name}",
|
||||
customer_id=customer.id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return _created_response(satellite, api_key)
|
||||
|
||||
|
||||
@router.delete("/{satellite_id}", status_code=204)
|
||||
async def delete_satellite(
|
||||
satellite_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
satellite = await db.get(Satellite, satellite_id)
|
||||
if not satellite:
|
||||
raise NotFoundError("Satellite nicht gefunden")
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="satellite.delete",
|
||||
target=satellite.name,
|
||||
customer_id=satellite.customer_id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.delete(satellite)
|
||||
|
||||
|
||||
@router.post("/{satellite_id}/rotate-key", response_model=SatelliteCreated)
|
||||
async def rotate_satellite_key(
|
||||
satellite_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> SatelliteCreated:
|
||||
satellite = await db.get(Satellite, satellite_id)
|
||||
if not satellite:
|
||||
raise NotFoundError("Satellite nicht gefunden")
|
||||
|
||||
api_key = generate_api_key()
|
||||
satellite.api_key_hash = hash_api_key(api_key)
|
||||
satellite.api_key_prefix = api_key[:11]
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="satellite.rotate_key",
|
||||
target=satellite.name,
|
||||
customer_id=satellite.customer_id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return _created_response(satellite, api_key)
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Server inventory routes."""
|
||||
"""Server inventory routes (customer-scoped).
|
||||
|
||||
from datetime import UTC, datetime
|
||||
No direct connectivity from the central server - health checks are
|
||||
HEALTH_CHECK jobs executed by the customer's satellite.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
@@ -8,26 +10,26 @@ 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.core.exceptions import ConflictError, NotFoundError
|
||||
from app.models.customer import Customer
|
||||
from app.models.server import Server
|
||||
from app.models.user import User
|
||||
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
|
||||
from app.schemas.server import 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(
|
||||
customer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[Server]:
|
||||
result = await db.execute(select(Server).order_by(Server.name))
|
||||
stmt = select(Server).order_by(Server.name)
|
||||
if customer_id is not None:
|
||||
stmt = stmt.where(Server.customer_id == customer_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -38,6 +40,18 @@ async def create_server(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
customer = await db.get(Customer, payload.customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Server).where(
|
||||
Server.customer_id == payload.customer_id, Server.name == payload.name
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError("Server mit diesem Namen existiert beim Kunden bereits")
|
||||
|
||||
server = Server(**payload.model_dump())
|
||||
db.add(server)
|
||||
await db.flush()
|
||||
@@ -45,6 +59,7 @@ async def create_server(
|
||||
username=user.username,
|
||||
action="server.create",
|
||||
target=server.name,
|
||||
customer_id=customer.id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
@@ -79,6 +94,7 @@ async def update_server(
|
||||
username=user.username,
|
||||
action="server.update",
|
||||
target=server.name,
|
||||
customer_id=server.customer_id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
@@ -98,63 +114,7 @@ async def delete_server(
|
||||
username=user.username,
|
||||
action="server.delete",
|
||||
target=server.name,
|
||||
customer_id=server.customer_id,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Update job routes: trigger, list, logs, cancel."""
|
||||
"""Update job routes: trigger (single + batch), list, logs, cancel.
|
||||
|
||||
Triggering only queues a job - a satellite of that customer picks it up
|
||||
on its next poll and executes it locally.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, select
|
||||
@@ -6,13 +12,19 @@ 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.core.exceptions import JobNotCancellableError, NotFoundError, ValidationError
|
||||
from app.models.customer import Customer
|
||||
from app.models.server import Server
|
||||
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
||||
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
|
||||
from app.models.user import User
|
||||
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
||||
from app.schemas.update import (
|
||||
BatchJobTriggerRequest,
|
||||
BatchJobTriggerResponse,
|
||||
JobTriggerRequest,
|
||||
UpdateJobRead,
|
||||
UpdateLogRead,
|
||||
)
|
||||
from app.services.audit import AuditService
|
||||
from app.services.job_runner import job_runner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -24,39 +36,69 @@ async def trigger_update(
|
||||
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()
|
||||
|
||||
job = await _create_job(db, payload, user.username)
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.trigger",
|
||||
target=server.name,
|
||||
details={"job_id": job.id, "type": payload.type.value},
|
||||
target=f"job:{job.id}",
|
||||
customer_id=payload.customer_id,
|
||||
details={"type": payload.type.value, "server_id": payload.server_id},
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await job_runner.start(job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/trigger-batch", response_model=BatchJobTriggerResponse, status_code=201)
|
||||
async def trigger_batch(
|
||||
payload: BatchJobTriggerRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> BatchJobTriggerResponse:
|
||||
"""Queue one job per server - the satellite works through them in order."""
|
||||
stmt = select(Server).where(Server.customer_id == payload.customer_id)
|
||||
if payload.server_ids:
|
||||
stmt = stmt.where(Server.id.in_(payload.server_ids))
|
||||
result = await db.execute(stmt.order_by(Server.name))
|
||||
servers = list(result.scalars().all())
|
||||
if not servers:
|
||||
raise NotFoundError("Keine Server für diesen Kunden gefunden")
|
||||
|
||||
job_ids: list[int] = []
|
||||
for server in servers:
|
||||
job = await _create_job(
|
||||
db,
|
||||
JobTriggerRequest(
|
||||
customer_id=payload.customer_id,
|
||||
type=payload.type,
|
||||
server_id=server.id,
|
||||
reboot_if_required=payload.reboot_if_required,
|
||||
),
|
||||
user.username,
|
||||
)
|
||||
job_ids.append(job.id)
|
||||
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.trigger_batch",
|
||||
customer_id=payload.customer_id,
|
||||
details={"type": payload.type.value, "count": len(job_ids)},
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return BatchJobTriggerResponse(created=len(job_ids), job_ids=job_ids)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UpdateJobRead])
|
||||
async def list_jobs(
|
||||
customer_id: int | None = None,
|
||||
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 customer_id is not None:
|
||||
stmt = stmt.where(UpdateJob.customer_id == customer_id)
|
||||
if status:
|
||||
stmt = stmt.where(UpdateJob.status == status)
|
||||
result = await db.execute(stmt)
|
||||
@@ -103,17 +145,17 @@ async def cancel_job(
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
|
||||
# Only pending jobs can be cancelled centrally - a claimed/running job
|
||||
# is already on the satellite and finishes there.
|
||||
if job.status != JobStatus.PENDING:
|
||||
raise JobNotCancellableError()
|
||||
|
||||
cancelled = await job_runner.cancel(job_id)
|
||||
if not cancelled:
|
||||
job.status = JobStatus.CANCELLED
|
||||
|
||||
job.status = JobStatus.CANCELLED
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.cancel",
|
||||
target=f"job:{job_id}",
|
||||
customer_id=job.customer_id,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return job
|
||||
@@ -121,14 +163,55 @@ async def cancel_job(
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def job_stats(
|
||||
customer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
total = await db.scalar(select(func.count(UpdateJob.id)))
|
||||
base = select(func.count(UpdateJob.id))
|
||||
if customer_id is not None:
|
||||
base = base.where(UpdateJob.customer_id == customer_id)
|
||||
total = await db.scalar(base)
|
||||
running = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
|
||||
base.where(UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]))
|
||||
)
|
||||
failed = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
|
||||
failed = await db.scalar(base.where(UpdateJob.status == JobStatus.FAILED))
|
||||
pending = await db.scalar(base.where(UpdateJob.status == JobStatus.PENDING))
|
||||
return {
|
||||
"total": total or 0,
|
||||
"running": running or 0,
|
||||
"failed": failed or 0,
|
||||
"pending": pending or 0,
|
||||
}
|
||||
|
||||
|
||||
async def _create_job(
|
||||
db: AsyncSession, payload: JobTriggerRequest, username: str
|
||||
) -> UpdateJob:
|
||||
customer = await db.get(Customer, payload.customer_id)
|
||||
if not customer:
|
||||
raise NotFoundError("Kunde nicht gefunden")
|
||||
|
||||
if payload.type == JobType.NETWORK_SCAN:
|
||||
if payload.server_id is not None:
|
||||
raise ValidationError("NETWORK_SCAN hat keinen Ziel-Server")
|
||||
else:
|
||||
if payload.server_id is None:
|
||||
raise ValidationError("server_id erforderlich")
|
||||
server = await db.get(Server, payload.server_id)
|
||||
if not server or server.customer_id != payload.customer_id:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
params: dict = {"reboot_if_required": payload.reboot_if_required}
|
||||
if payload.scan_subnet:
|
||||
params["scan_subnet"] = payload.scan_subnet
|
||||
|
||||
job = UpdateJob(
|
||||
customer_id=payload.customer_id,
|
||||
server_id=payload.server_id,
|
||||
type=payload.type,
|
||||
created_by=username,
|
||||
params=json.dumps(params),
|
||||
)
|
||||
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
return job
|
||||
|
||||
@@ -18,7 +18,6 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
|
||||
@@ -29,19 +28,12 @@ class Settings(BaseSettings):
|
||||
jwt_access_token_expire_minutes: int = 30
|
||||
jwt_refresh_token_expire_days: int = 7
|
||||
|
||||
# Database / Redis
|
||||
# Database
|
||||
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
|
||||
# Jobs: a claimed/running job without satellite reports for this many
|
||||
# seconds is considered stale and marked failed by the janitor
|
||||
job_stale_timeout: int = 3600
|
||||
|
||||
# LDAP (stub)
|
||||
ldap_enabled: bool = False
|
||||
|
||||
@@ -62,5 +62,10 @@ class CAUError(AppError):
|
||||
detail = "Cluster-Aware Updating operation failed"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 422
|
||||
detail = "Validation failed"
|
||||
|
||||
|
||||
class JobNotCancellableError(ConflictError):
|
||||
detail = "Job cannot be cancelled in its current state"
|
||||
|
||||
@@ -1,58 +1,21 @@
|
||||
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
|
||||
"""Security helpers: JWT issue/verify, password hashing.
|
||||
|
||||
No credential encryption here - target-system credentials live exclusively
|
||||
on the satellites, never in the central database.
|
||||
"""
|
||||
|
||||
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
|
||||
from app.core.exceptions import 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
|
||||
@@ -66,11 +29,6 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
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
|
||||
|
||||
+15
-22
@@ -1,14 +1,13 @@
|
||||
"""FastAPI application entrypoint.
|
||||
|
||||
Mounts:
|
||||
- REST API under /api
|
||||
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
|
||||
Central instance of the Insight Updater hub:
|
||||
- Dashboard REST API under /api (JWT auth)
|
||||
- Satellite agent API under /api/satellite (X-Api-Key auth)
|
||||
- /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
|
||||
@@ -18,7 +17,6 @@ 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__)
|
||||
@@ -32,17 +30,15 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
||||
await init_db()
|
||||
await _seed_default_admin()
|
||||
|
||||
# Attach Redis manager for Socket.io pub/sub (optional in dev)
|
||||
try:
|
||||
from socketio import AsyncRedisManager
|
||||
import asyncio
|
||||
|
||||
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))
|
||||
from app.services.janitor import run_janitor
|
||||
|
||||
janitor = asyncio.create_task(run_janitor(), name="job-janitor")
|
||||
|
||||
yield
|
||||
|
||||
janitor.cancel()
|
||||
logger.info("app.stopping")
|
||||
|
||||
|
||||
@@ -77,13 +73,13 @@ async def _seed_default_admin() -> None:
|
||||
logger.info("app.default_admin_created", username="admin")
|
||||
|
||||
|
||||
fastapi_app = FastAPI(
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
fastapi_app.add_middleware(
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
@@ -92,17 +88,14 @@ fastapi_app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@fastapi_app.exception_handler(AppError)
|
||||
@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")
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
|
||||
return {"status": "ok", "env": settings.app_env, "version": "0.2.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")
|
||||
app.include_router(api_router)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""SQLAlchemy ORM models."""
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.credential import Credential
|
||||
from app.models.customer import Customer
|
||||
from app.models.satellite import Satellite
|
||||
from app.models.server import Server
|
||||
from app.models.update_job import UpdateJob, UpdateLog
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"AuditLog",
|
||||
"Credential",
|
||||
"Customer",
|
||||
"Satellite",
|
||||
"Server",
|
||||
"UpdateJob",
|
||||
"UpdateLog",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -15,9 +15,12 @@ class AuditLog(Base):
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
|
||||
)
|
||||
customer_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("customers.id"), nullable=True, 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
|
||||
target: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
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)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""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,36 @@
|
||||
"""Customer model - one per client site (tenant)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
notes: 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),
|
||||
)
|
||||
|
||||
satellites: Mapped[list["Satellite"]] = relationship( # noqa: F821
|
||||
back_populates="customer", cascade="all, delete-orphan"
|
||||
)
|
||||
servers: Mapped[list["Server"]] = relationship( # noqa: F821
|
||||
back_populates="customer", cascade="all, delete-orphan"
|
||||
)
|
||||
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||
back_populates="customer", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Satellite model - remote agent at a customer site.
|
||||
|
||||
The API key is stored as a SHA-256 hash; the plaintext key is shown
|
||||
exactly once at creation time.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a new satellite API key (plaintext, show once)."""
|
||||
return f"ius_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def hash_api_key(key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
class Satellite(Base):
|
||||
__tablename__ = "satellites"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id"), index=True
|
||||
)
|
||||
customer: Mapped["Customer"] = relationship(back_populates="satellites") # noqa: F821
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
api_key_prefix: Mapped[str] = mapped_column(String(12)) # for display in UI
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Filled by heartbeat
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||
back_populates="satellite"
|
||||
)
|
||||
@@ -1,15 +1,19 @@
|
||||
"""Server inventory model."""
|
||||
"""Server inventory model.
|
||||
|
||||
Credentials are NOT stored centrally. `credential_ref` is a symbolic name
|
||||
that the satellite resolves against its local credentials.yaml.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ServerType(str, enum.Enum):
|
||||
class ServerType(enum.StrEnum):
|
||||
WINDOWS = "windows" # WinRM
|
||||
LINUX = "linux" # SSH
|
||||
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
|
||||
@@ -17,22 +21,33 @@ class ServerType(str, enum.Enum):
|
||||
|
||||
class Server(Base):
|
||||
__tablename__ = "servers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("customer_id", "name", name="uq_server_customer_name"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id"), index=True
|
||||
)
|
||||
customer: Mapped["Customer"] = relationship(back_populates="servers") # noqa: F821
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), 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
|
||||
# Symbolic reference to a credential stored locally on the satellite
|
||||
credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Last health result reported by a satellite
|
||||
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
last_health_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Set by network scan jobs
|
||||
discovered_by_scan: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
@@ -44,5 +59,5 @@ class Server(Base):
|
||||
)
|
||||
|
||||
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||
back_populates="server", cascade="all, delete-orphan"
|
||||
back_populates="server"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Update job + streamed log line models."""
|
||||
"""Update job + log line models.
|
||||
|
||||
Job lifecycle (pull model):
|
||||
pending -> claimed (satellite picked it up) -> running -> success | failed | cancelled
|
||||
A claimed/running job whose satellite goes silent past the stale timeout
|
||||
is marked failed by the janitor.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
@@ -9,36 +15,58 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
class JobStatus(enum.StrEnum):
|
||||
PENDING = "pending"
|
||||
CLAIMED = "claimed"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobType(str, enum.Enum):
|
||||
class JobType(enum.StrEnum):
|
||||
WINDOWS_UPDATE = "windows_update"
|
||||
LINUX_UPDATE = "linux_update"
|
||||
CAU_RUN = "cau_run"
|
||||
HEALTH_CHECK = "health_check"
|
||||
NETWORK_SCAN = "network_scan"
|
||||
|
||||
|
||||
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
|
||||
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("customers.id"), index=True
|
||||
)
|
||||
customer: Mapped["Customer"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||
|
||||
# Null for NETWORK_SCAN jobs (target = whole local network)
|
||||
server_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("servers.id"), nullable=True, index=True
|
||||
)
|
||||
server: Mapped["Server | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||
|
||||
# Set when a satellite claims the job
|
||||
satellite_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("satellites.id"), nullable=True, index=True
|
||||
)
|
||||
satellite: Mapped["Satellite | None"] = 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
|
||||
# Optional job parameters (e.g. reboot_if_required, scan_subnet)
|
||||
params: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
||||
|
||||
created_by: Mapped[str] = mapped_column(String(255)) # dashboard username
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_report_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(
|
||||
|
||||
@@ -1,18 +1 @@
|
||||
"""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",
|
||||
]
|
||||
"""Pydantic request/response schemas."""
|
||||
|
||||
@@ -10,6 +10,7 @@ class AuditLogRead(BaseModel):
|
||||
|
||||
id: int
|
||||
timestamp: datetime
|
||||
customer_id: int | None
|
||||
username: str
|
||||
action: str
|
||||
target: str | None
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Customer schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CustomerRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Satellite schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SatelliteCreate(BaseModel):
|
||||
customer_id: int
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class SatelliteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
customer_id: int
|
||||
name: str
|
||||
api_key_prefix: str
|
||||
is_active: bool
|
||||
last_seen_at: datetime | None
|
||||
version: str | None
|
||||
hostname: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SatelliteCreated(SatelliteRead):
|
||||
"""Returned exactly once on creation - contains the plaintext API key."""
|
||||
|
||||
api_key: str
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Satellite-facing schemas (agent API)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.update_job import JobType
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
version: str = Field(max_length=50)
|
||||
hostname: str = Field(max_length=255)
|
||||
|
||||
|
||||
class PollResponse(BaseModel):
|
||||
"""Jobs handed to the satellite on poll. Empty list = nothing to do."""
|
||||
|
||||
jobs: list["SatelliteJob"]
|
||||
|
||||
|
||||
class SatelliteJob(BaseModel):
|
||||
job_id: int
|
||||
type: JobType
|
||||
# Target info (absent for network scans)
|
||||
server_id: int | None = None
|
||||
server_name: str | None = None
|
||||
hostname: str | None = None
|
||||
port: int | None = None
|
||||
server_type: str | None = None
|
||||
credential_ref: str | None = None
|
||||
# Parameters
|
||||
reboot_if_required: bool = False
|
||||
scan_subnet: str | None = None
|
||||
|
||||
|
||||
class LogLine(BaseModel):
|
||||
timestamp: datetime
|
||||
level: str = "info"
|
||||
line: str
|
||||
|
||||
|
||||
class LogBatchRequest(BaseModel):
|
||||
job_id: int
|
||||
lines: list[LogLine]
|
||||
progress_percent: int | None = None
|
||||
current_phase: str | None = None
|
||||
|
||||
|
||||
class JobResultRequest(BaseModel):
|
||||
job_id: int
|
||||
status: str # "success" | "failed"
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ScanResultHost(BaseModel):
|
||||
hostname: str
|
||||
ip: str
|
||||
os_guess: str | None = None # "windows" | "linux" | None
|
||||
winrm_open: bool = False
|
||||
ssh_open: bool = False
|
||||
|
||||
|
||||
class ScanResultRequest(BaseModel):
|
||||
job_id: int
|
||||
hosts: list[ScanResultHost]
|
||||
|
||||
|
||||
class HealthReportRequest(BaseModel):
|
||||
server_id: int
|
||||
ok: bool
|
||||
message: str
|
||||
@@ -8,13 +8,14 @@ from app.models.server import ServerType
|
||||
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
customer_id: int
|
||||
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
|
||||
credential_ref: str | None = None
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
@@ -24,29 +25,24 @@ class ServerUpdate(BaseModel):
|
||||
type: ServerType | None = None
|
||||
description: str | None = None
|
||||
tags: str | None = None
|
||||
credential_id: int | None = None
|
||||
credential_ref: str | None = None
|
||||
|
||||
|
||||
class ServerRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
customer_id: int
|
||||
name: str
|
||||
hostname: str
|
||||
port: int
|
||||
type: ServerType
|
||||
description: str | None
|
||||
tags: str | None
|
||||
credential_id: int | None
|
||||
credential_ref: str | None
|
||||
last_health_at: datetime | None
|
||||
last_health_ok: bool | None
|
||||
last_health_message: str | None
|
||||
discovered_by_scan: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HealthCheckResult(BaseModel):
|
||||
server_id: int
|
||||
ok: bool
|
||||
latency_ms: float | None = None
|
||||
message: str
|
||||
checked_at: datetime
|
||||
|
||||
@@ -8,24 +8,28 @@ from app.models.update_job import JobStatus, JobType
|
||||
|
||||
|
||||
class JobTriggerRequest(BaseModel):
|
||||
server_id: int
|
||||
customer_id: int
|
||||
type: JobType
|
||||
# CAU-specific options
|
||||
cluster_name: str | None = None
|
||||
# Linux-specific options
|
||||
# Target server; not required for NETWORK_SCAN
|
||||
server_id: int | None = None
|
||||
# Optional parameters
|
||||
reboot_if_required: bool = False
|
||||
scan_subnet: str | None = None # e.g. "192.168.1.0/24"
|
||||
|
||||
|
||||
class UpdateJobRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
server_id: int
|
||||
customer_id: int
|
||||
server_id: int | None
|
||||
satellite_id: int | None
|
||||
type: JobType
|
||||
status: JobStatus
|
||||
progress_percent: int
|
||||
current_phase: str | None
|
||||
started_by: str
|
||||
created_by: str
|
||||
claimed_at: datetime | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
error: str | None
|
||||
@@ -40,3 +44,17 @@ class UpdateLogRead(BaseModel):
|
||||
timestamp: datetime
|
||||
level: str
|
||||
line: str
|
||||
|
||||
|
||||
class BatchJobTriggerRequest(BaseModel):
|
||||
"""Trigger one job per server (or all servers of a customer)."""
|
||||
|
||||
customer_id: int
|
||||
type: JobType
|
||||
server_ids: list[int] | None = None # None = all servers of the customer
|
||||
reboot_if_required: bool = False
|
||||
|
||||
|
||||
class BatchJobTriggerResponse(BaseModel):
|
||||
created: int
|
||||
job_ids: list[int]
|
||||
|
||||
@@ -25,6 +25,7 @@ class AuditService:
|
||||
result: str = "success",
|
||||
details: dict[str, Any] | None = None,
|
||||
ip_address: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
) -> AuditLog:
|
||||
entry = AuditLog(
|
||||
username=username,
|
||||
@@ -33,6 +34,7 @@ class AuditService:
|
||||
result=result,
|
||||
details=json.dumps(details) if details else None,
|
||||
ip_address=ip_address,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
self.db.add(entry)
|
||||
await self.db.flush()
|
||||
@@ -42,5 +44,6 @@ class AuditService:
|
||||
action=action,
|
||||
target=target,
|
||||
result=result,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
return entry
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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,47 @@
|
||||
"""Stale job janitor: marks claimed/running jobs as failed when their
|
||||
satellite stops reporting (e.g. satellite offline, job crashed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import async_session_factory
|
||||
from app.core.logging import get_logger
|
||||
from app.models.update_job import JobStatus, UpdateJob
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CHECK_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
async def run_janitor() -> None:
|
||||
"""Background task; runs until cancelled."""
|
||||
while True:
|
||||
try:
|
||||
await _sweep()
|
||||
except Exception as exc: # noqa: BLE001 - janitor must never die
|
||||
logger.error("janitor.error", error=str(exc))
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
async def _sweep() -> None:
|
||||
cutoff = datetime.now(UTC) - timedelta(seconds=settings.job_stale_timeout)
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(UpdateJob).where(
|
||||
UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]),
|
||||
UpdateJob.last_report_at < cutoff,
|
||||
)
|
||||
)
|
||||
stale = list(result.scalars().all())
|
||||
for job in stale:
|
||||
job.status = JobStatus.FAILED
|
||||
job.error = "Satellite meldet sich nicht mehr (Timeout)"
|
||||
job.finished_at = datetime.now(UTC)
|
||||
logger.warning("janitor.job_stale", job_id=job.id, satellite_id=job.satellite_id)
|
||||
if stale:
|
||||
await db.commit()
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,118 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,122 +0,0 @@
|
||||
"""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
|
||||
@@ -1,6 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,103 +0,0 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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()
|
||||
@@ -11,16 +11,11 @@ dependencies = [
|
||||
"alembic>=1.13.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"redis>=5.0.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"bcrypt>=4.1.0",
|
||||
"cryptography>=42.0.0",
|
||||
"pydantic[email]>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"pywinrm[kerberos]>=0.4.3",
|
||||
"paramiko>=3.4.0",
|
||||
"asyncssh>=2.14.0",
|
||||
"python-socketio>=5.10.0",
|
||||
"structlog>=24.1.0",
|
||||
"python-json-logger>=2.0.7",
|
||||
"python-multipart>=0.0.6",
|
||||
|
||||
Reference in New Issue
Block a user