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
+115 -32
View File
@@ -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