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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user