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
+4
View File
@@ -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)
+105
View File
@@ -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)
+228
View File
@@ -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
+111
View File
@@ -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)
+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,
)
+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