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:
B0rbor4d
2026-08-07 03:42:06 +00:00
parent b91dd66fee
commit cc4c3fcecb
72 changed files with 2759 additions and 1642 deletions
+28 -68
View File
@@ -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,
)