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
|
||||
|
||||
Reference in New Issue
Block a user