Files
webmanager-mvp/webui/backend/app/services/remote_client_service.py
T

152 lines
6.1 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Callable
from backend.app.api.errors import AppError
from backend.app.api.schemas import (
RemoteClientHeartbeatRequest,
RemoteClientItem,
RemoteClientListResponse,
RemoteClientRegisterRequest,
)
from backend.app.db.remote_client_repository import RemoteClientRepository
class RemoteClientService:
def __init__(
self,
repository: RemoteClientRepository,
registration_token: str,
offline_timeout_seconds: int,
now: Callable[[], datetime] | None = None,
):
self._repository = repository
self._registration_token = registration_token.strip()
self._offline_timeout_seconds = max(1, int(offline_timeout_seconds))
self._now = now or (lambda: datetime.now(tz=timezone.utc))
def list_clients(self) -> RemoteClientListResponse:
self._refresh_stale_statuses()
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
return RemoteClientListResponse(items=items)
def get_client(self, client_id: str) -> RemoteClientItem:
normalized_client_id = (client_id or "").strip()
if not normalized_client_id:
raise AppError(
code="invalid_request",
message="client_id is required",
status_code=400,
details={"client_id": client_id},
)
self._refresh_stale_statuses()
item = self._repository.get_client(normalized_client_id)
if item is None:
raise AppError(
code="path_not_found",
message="Remote client was not found",
status_code=404,
details={"client_id": normalized_client_id},
)
return RemoteClientItem(**item)
def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem:
self._require_registration_auth(authorization)
payload = self._normalize_register_request(request)
now_iso = self._to_iso(self._now())
item = self._repository.upsert_client(now_iso=now_iso, **payload)
return RemoteClientItem(**item)
def record_heartbeat(self, authorization: str | None, request: RemoteClientHeartbeatRequest) -> RemoteClientItem:
self._require_registration_auth(authorization)
client_id = (request.client_id or "").strip()
agent_version = (request.agent_version or "").strip()
if not client_id:
raise AppError(
code="invalid_request",
message="client_id is required",
status_code=400,
details={"client_id": request.client_id},
)
if not agent_version:
raise AppError(
code="invalid_request",
message="agent_version is required",
status_code=400,
details={"agent_version": request.agent_version},
)
item = self._repository.record_heartbeat(
client_id=client_id,
agent_version=agent_version,
now_iso=self._to_iso(self._now()),
)
if item is None:
raise AppError(
code="path_not_found",
message="Remote client was not found",
status_code=404,
details={"client_id": client_id},
)
return RemoteClientItem(**item)
def _require_registration_auth(self, authorization: str | None) -> None:
if not self._registration_token:
raise AppError(
code="remote_client_registration_disabled",
message="Remote client registration is not configured",
status_code=503,
)
expected = f"Bearer {self._registration_token}"
if (authorization or "").strip() != expected:
raise AppError(
code="forbidden",
message="Invalid remote client registration token",
status_code=403,
)
def _normalize_register_request(self, request: RemoteClientRegisterRequest) -> dict:
client_id = (request.client_id or "").strip()
display_name = (request.display_name or "").strip()
platform = (request.platform or "").strip()
agent_version = (request.agent_version or "").strip()
endpoint = (request.endpoint or "").strip()
shares = [
{"key": (item.key or "").strip(), "label": (item.label or "").strip()}
for item in request.shares
]
shares = [item for item in shares if item["key"] and item["label"]]
if not client_id:
raise AppError("invalid_request", "client_id is required", 400, {"client_id": request.client_id})
if not display_name:
raise AppError("invalid_request", "display_name is required", 400, {"display_name": request.display_name})
if not platform:
raise AppError("invalid_request", "platform is required", 400, {"platform": request.platform})
if not agent_version:
raise AppError("invalid_request", "agent_version is required", 400, {"agent_version": request.agent_version})
if not endpoint:
raise AppError("invalid_request", "endpoint is required", 400, {"endpoint": request.endpoint})
if not shares:
raise AppError("invalid_request", "at least one share is required", 400, {"shares": "[]"})
return {
"client_id": client_id,
"display_name": display_name,
"platform": platform,
"agent_version": agent_version,
"endpoint": endpoint,
"shares": shares,
}
def _refresh_stale_statuses(self) -> None:
now = self._now()
self._repository.mark_stale_clients_offline(
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
now_iso=self._to_iso(now),
)
@staticmethod
def _to_iso(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")