Port core API routes for sites and devices

This commit is contained in:
2025-10-22 23:43:16 -06:00
parent d0fa6929b2
commit 4bc529aaf4
22 changed files with 2092 additions and 1 deletions

View File

@@ -24,6 +24,10 @@ __all__ = [
"GitHubService",
"GitHubTokenPayload",
"EnrollmentAdminService",
"SiteService",
"DeviceInventoryService",
"DeviceViewService",
"CredentialService",
]
_LAZY_TARGETS: Dict[str, Tuple[str, str]] = {
@@ -48,6 +52,19 @@ _LAZY_TARGETS: Dict[str, Tuple[str, str]] = {
"Data.Engine.services.enrollment.admin_service",
"EnrollmentAdminService",
),
"SiteService": ("Data.Engine.services.sites.site_service", "SiteService"),
"DeviceInventoryService": (
"Data.Engine.services.devices.device_inventory_service",
"DeviceInventoryService",
),
"DeviceViewService": (
"Data.Engine.services.devices.device_view_service",
"DeviceViewService",
),
"CredentialService": (
"Data.Engine.services.credentials.credential_service",
"CredentialService",
),
}

View File

@@ -13,10 +13,14 @@ from Data.Engine.integrations.github import GitHubArtifactProvider
from Data.Engine.repositories.sqlite import (
SQLiteConnectionFactory,
SQLiteDeviceRepository,
SQLiteDeviceInventoryRepository,
SQLiteDeviceViewRepository,
SQLiteCredentialRepository,
SQLiteEnrollmentRepository,
SQLiteGitHubRepository,
SQLiteJobRepository,
SQLiteRefreshTokenRepository,
SQLiteSiteRepository,
SQLiteUserRepository,
)
from Data.Engine.services.auth import (
@@ -32,10 +36,14 @@ from Data.Engine.services.crypto.signing import ScriptSigner, load_signer
from Data.Engine.services.enrollment import EnrollmentService
from Data.Engine.services.enrollment.admin_service import EnrollmentAdminService
from Data.Engine.services.enrollment.nonce_cache import NonceCache
from Data.Engine.services.devices import DeviceInventoryService
from Data.Engine.services.devices import DeviceViewService
from Data.Engine.services.credentials import CredentialService
from Data.Engine.services.github import GitHubService
from Data.Engine.services.jobs import SchedulerService
from Data.Engine.services.rate_limit import SlidingWindowRateLimiter
from Data.Engine.services.realtime import AgentRealtimeService
from Data.Engine.services.sites import SiteService
__all__ = ["EngineServiceContainer", "build_service_container"]
@@ -43,9 +51,13 @@ __all__ = ["EngineServiceContainer", "build_service_container"]
@dataclass(frozen=True, slots=True)
class EngineServiceContainer:
device_auth: DeviceAuthService
device_inventory: DeviceInventoryService
device_view_service: DeviceViewService
credential_service: CredentialService
token_service: TokenService
enrollment_service: EnrollmentService
enrollment_admin_service: EnrollmentAdminService
site_service: SiteService
jwt_service: JWTService
dpop_validator: DPoPValidator
agent_realtime: AgentRealtimeService
@@ -64,10 +76,20 @@ def build_service_container(
log = logger or logging.getLogger("borealis.engine.services")
device_repo = SQLiteDeviceRepository(db_factory, logger=log.getChild("devices"))
device_inventory_repo = SQLiteDeviceInventoryRepository(
db_factory, logger=log.getChild("devices.inventory")
)
device_view_repo = SQLiteDeviceViewRepository(
db_factory, logger=log.getChild("devices.views")
)
credential_repo = SQLiteCredentialRepository(
db_factory, logger=log.getChild("credentials.repo")
)
token_repo = SQLiteRefreshTokenRepository(db_factory, logger=log.getChild("tokens"))
enrollment_repo = SQLiteEnrollmentRepository(db_factory, logger=log.getChild("enrollment"))
job_repo = SQLiteJobRepository(db_factory, logger=log.getChild("jobs"))
github_repo = SQLiteGitHubRepository(db_factory, logger=log.getChild("github_repo"))
site_repo = SQLiteSiteRepository(db_factory, logger=log.getChild("sites.repo"))
user_repo = SQLiteUserRepository(db_factory, logger=log.getChild("users"))
jwt_service = load_jwt_service()
@@ -128,6 +150,22 @@ def build_service_container(
repository=user_repo,
logger=log.getChild("operator_accounts"),
)
device_inventory = DeviceInventoryService(
repository=device_inventory_repo,
logger=log.getChild("device_inventory"),
)
device_view_service = DeviceViewService(
repository=device_view_repo,
logger=log.getChild("device_views"),
)
credential_service = CredentialService(
repository=credential_repo,
logger=log.getChild("credentials"),
)
site_service = SiteService(
repository=site_repo,
logger=log.getChild("sites"),
)
github_provider = GitHubArtifactProvider(
cache_file=settings.github.cache_file,
@@ -155,6 +193,10 @@ def build_service_container(
github_service=github_service,
operator_auth_service=operator_auth_service,
operator_account_service=operator_account_service,
device_inventory=device_inventory,
device_view_service=device_view_service,
credential_service=credential_service,
site_service=site_service,
)

View File

@@ -0,0 +1,3 @@
from .credential_service import CredentialService
__all__ = ["CredentialService"]

View File

@@ -0,0 +1,29 @@
"""Expose read access to stored credentials."""
from __future__ import annotations
import logging
from typing import List, Optional
from Data.Engine.repositories.sqlite.credential_repository import SQLiteCredentialRepository
__all__ = ["CredentialService"]
class CredentialService:
def __init__(
self,
repository: SQLiteCredentialRepository,
*,
logger: Optional[logging.Logger] = None,
) -> None:
self._repo = repository
self._log = logger or logging.getLogger("borealis.engine.services.credentials")
def list_credentials(
self,
*,
site_id: Optional[int] = None,
connection_type: Optional[str] = None,
) -> List[dict]:
return self._repo.list_credentials(site_id=site_id, connection_type=connection_type)

View File

@@ -0,0 +1,4 @@
from .device_inventory_service import DeviceInventoryService, RemoteDeviceError
from .device_view_service import DeviceViewService
__all__ = ["DeviceInventoryService", "RemoteDeviceError", "DeviceViewService"]

View File

@@ -0,0 +1,178 @@
"""Mirrors the legacy device inventory HTTP behaviour."""
from __future__ import annotations
import logging
import sqlite3
from typing import Dict, List, Optional
from Data.Engine.repositories.sqlite.device_inventory_repository import (
SQLiteDeviceInventoryRepository,
)
__all__ = ["DeviceInventoryService", "RemoteDeviceError"]
class RemoteDeviceError(Exception):
def __init__(self, code: str, message: Optional[str] = None) -> None:
super().__init__(message or code)
self.code = code
class DeviceInventoryService:
def __init__(
self,
repository: SQLiteDeviceInventoryRepository,
*,
logger: Optional[logging.Logger] = None,
) -> None:
self._repo = repository
self._log = logger or logging.getLogger("borealis.engine.services.devices")
def list_devices(self) -> List[Dict[str, object]]:
return self._repo.fetch_devices()
def list_agent_devices(self) -> List[Dict[str, object]]:
return self._repo.fetch_devices(only_agents=True)
def list_remote_devices(self, connection_type: str) -> List[Dict[str, object]]:
return self._repo.fetch_devices(connection_type=connection_type)
def get_device_by_guid(self, guid: str) -> Optional[Dict[str, object]]:
snapshot = self._repo.load_snapshot(guid=guid)
if not snapshot:
return None
devices = self._repo.fetch_devices(hostname=snapshot.get("hostname"))
return devices[0] if devices else None
def collect_agent_hash_records(self) -> List[Dict[str, object]]:
records: List[Dict[str, object]] = []
key_to_index: Dict[str, int] = {}
for device in self._repo.fetch_devices():
summary = device.get("summary", {}) if isinstance(device, dict) else {}
agent_id = (summary.get("agent_id") or "").strip()
agent_guid = (summary.get("agent_guid") or "").strip()
hostname = (summary.get("hostname") or device.get("hostname") or "").strip()
agent_hash = (summary.get("agent_hash") or device.get("agent_hash") or "").strip()
keys: List[str] = []
if agent_id:
keys.append(f"id:{agent_id.lower()}")
if agent_guid:
keys.append(f"guid:{agent_guid.lower()}")
if hostname:
keys.append(f"host:{hostname.lower()}")
payload = {
"agent_id": agent_id or None,
"agent_guid": agent_guid or None,
"hostname": hostname or None,
"agent_hash": agent_hash or None,
"source": "database",
}
if not keys:
records.append(payload)
continue
existing_index = None
for key in keys:
if key in key_to_index:
existing_index = key_to_index[key]
break
if existing_index is None:
existing_index = len(records)
records.append(payload)
for key in keys:
key_to_index[key] = existing_index
continue
merged = records[existing_index]
for key in ("agent_id", "agent_guid", "hostname", "agent_hash"):
if not merged.get(key) and payload.get(key):
merged[key] = payload[key]
return records
def upsert_remote_device(
self,
connection_type: str,
hostname: str,
address: Optional[str],
description: Optional[str],
os_hint: Optional[str],
*,
ensure_existing_type: Optional[str],
) -> Dict[str, object]:
normalized_type = (connection_type or "").strip().lower()
if not normalized_type:
raise RemoteDeviceError("invalid_type", "connection type required")
normalized_host = (hostname or "").strip()
if not normalized_host:
raise RemoteDeviceError("invalid_hostname", "hostname is required")
existing = self._repo.load_snapshot(hostname=normalized_host)
existing_type = (existing or {}).get("summary", {}).get("connection_type") or ""
existing_type = existing_type.strip().lower()
if ensure_existing_type and existing_type != ensure_existing_type.lower():
raise RemoteDeviceError("not_found", "device not found")
if ensure_existing_type is None and existing_type and existing_type != normalized_type:
raise RemoteDeviceError("conflict", "device already exists with different connection type")
created_ts = None
if existing:
created_ts = existing.get("summary", {}).get("created_at")
endpoint = (address or "").strip() or (existing or {}).get("summary", {}).get("connection_endpoint") or ""
if not endpoint:
raise RemoteDeviceError("address_required", "address is required")
description_val = description if description is not None else (existing or {}).get("summary", {}).get("description")
os_value = os_hint or (existing or {}).get("summary", {}).get("operating_system")
os_value = (os_value or "").strip()
device_type_label = "SSH Remote" if normalized_type == "ssh" else "WinRM Remote"
summary_payload = {
"connection_type": normalized_type,
"connection_endpoint": endpoint,
"internal_ip": endpoint,
"external_ip": endpoint,
"device_type": device_type_label,
"operating_system": os_value or "",
"last_seen": 0,
"description": (description_val or ""),
}
try:
self._repo.upsert_device(
normalized_host,
description_val,
{"summary": summary_payload},
created_ts,
)
except sqlite3.DatabaseError as exc: # type: ignore[name-defined]
raise RemoteDeviceError("storage_error", str(exc)) from exc
except Exception as exc: # pragma: no cover - defensive
raise RemoteDeviceError("storage_error", str(exc)) from exc
devices = self._repo.fetch_devices(hostname=normalized_host)
if not devices:
raise RemoteDeviceError("reload_failed", "failed to load device after upsert")
return devices[0]
def delete_remote_device(self, connection_type: str, hostname: str) -> None:
normalized_host = (hostname or "").strip()
if not normalized_host:
raise RemoteDeviceError("invalid_hostname", "invalid hostname")
existing = self._repo.load_snapshot(hostname=normalized_host)
if not existing:
raise RemoteDeviceError("not_found", "device not found")
existing_type = (existing.get("summary", {}) or {}).get("connection_type") or ""
if (existing_type or "").strip().lower() != (connection_type or "").strip().lower():
raise RemoteDeviceError("not_found", "device not found")
self._repo.delete_device_by_hostname(normalized_host)

View File

@@ -0,0 +1,73 @@
"""Service exposing CRUD for saved device list views."""
from __future__ import annotations
import logging
from typing import List, Optional
from Data.Engine.domain.device_views import DeviceListView
from Data.Engine.repositories.sqlite.device_view_repository import SQLiteDeviceViewRepository
__all__ = ["DeviceViewService"]
class DeviceViewService:
def __init__(
self,
repository: SQLiteDeviceViewRepository,
*,
logger: Optional[logging.Logger] = None,
) -> None:
self._repo = repository
self._log = logger or logging.getLogger("borealis.engine.services.device_views")
def list_views(self) -> List[DeviceListView]:
return self._repo.list_views()
def get_view(self, view_id: int) -> Optional[DeviceListView]:
return self._repo.get_view(view_id)
def create_view(self, name: str, columns: List[str], filters: dict) -> DeviceListView:
normalized_name = (name or "").strip()
if not normalized_name:
raise ValueError("missing_name")
if normalized_name.lower() == "default view":
raise ValueError("reserved")
return self._repo.create_view(normalized_name, list(columns), dict(filters))
def update_view(
self,
view_id: int,
*,
name: Optional[str] = None,
columns: Optional[List[str]] = None,
filters: Optional[dict] = None,
) -> DeviceListView:
updates: dict = {}
if name is not None:
normalized = (name or "").strip()
if not normalized:
raise ValueError("missing_name")
if normalized.lower() == "default view":
raise ValueError("reserved")
updates["name"] = normalized
if columns is not None:
if not isinstance(columns, list) or not all(isinstance(col, str) for col in columns):
raise ValueError("invalid_columns")
updates["columns"] = list(columns)
if filters is not None:
if not isinstance(filters, dict):
raise ValueError("invalid_filters")
updates["filters"] = dict(filters)
if not updates:
raise ValueError("no_fields")
return self._repo.update_view(
view_id,
name=updates.get("name"),
columns=updates.get("columns"),
filters=updates.get("filters"),
)
def delete_view(self, view_id: int) -> bool:
return self._repo.delete_view(view_id)

View File

@@ -0,0 +1,3 @@
from .site_service import SiteService
__all__ = ["SiteService"]

View File

@@ -0,0 +1,73 @@
"""Site management service that mirrors the legacy Flask behaviour."""
from __future__ import annotations
import logging
from typing import Dict, Iterable, List, Optional
from Data.Engine.domain.sites import SiteDeviceMapping, SiteSummary
from Data.Engine.repositories.sqlite.site_repository import SQLiteSiteRepository
__all__ = ["SiteService"]
class SiteService:
def __init__(self, repository: SQLiteSiteRepository, *, logger: Optional[logging.Logger] = None) -> None:
self._repo = repository
self._log = logger or logging.getLogger("borealis.engine.services.sites")
def list_sites(self) -> List[SiteSummary]:
return self._repo.list_sites()
def create_site(self, name: str, description: str) -> SiteSummary:
normalized_name = (name or "").strip()
normalized_description = (description or "").strip()
if not normalized_name:
raise ValueError("missing_name")
try:
return self._repo.create_site(normalized_name, normalized_description)
except ValueError as exc:
if str(exc) == "duplicate":
raise ValueError("duplicate") from exc
raise
def delete_sites(self, ids: Iterable[int]) -> int:
normalized = []
for value in ids:
try:
normalized.append(int(value))
except Exception:
continue
if not normalized:
return 0
return self._repo.delete_sites(tuple(normalized))
def rename_site(self, site_id: int, new_name: str) -> SiteSummary:
normalized_name = (new_name or "").strip()
if not normalized_name:
raise ValueError("missing_name")
try:
return self._repo.rename_site(int(site_id), normalized_name)
except ValueError as exc:
if str(exc) == "duplicate":
raise ValueError("duplicate") from exc
raise
def map_devices(self, hostnames: Optional[Iterable[str]] = None) -> Dict[str, SiteDeviceMapping]:
return self._repo.map_devices(hostnames)
def assign_devices(self, site_id: int, hostnames: Iterable[str]) -> None:
try:
numeric_id = int(site_id)
except Exception as exc:
raise ValueError("invalid_site_id") from exc
normalized = [hn for hn in hostnames if isinstance(hn, str) and hn.strip()]
if not normalized:
raise ValueError("invalid_hostnames")
try:
self._repo.assign_devices(numeric_id, normalized)
except LookupError as exc:
if str(exc) == "not_found":
raise LookupError("not_found") from exc
raise