refactor(cloud): move package into workspace member
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""Public platform SDK: versioned REST API and Python client for external integrators."""
|
||||
|
||||
__all__ = [
|
||||
"CloudClient",
|
||||
"create_cloud_router",
|
||||
"AuthProvider",
|
||||
"NullAuthProvider",
|
||||
"Principal",
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Versioned REST API for external integrators.
|
||||
|
||||
Capability: ``platform-sdk``.
|
||||
|
||||
Mirrors ``api/console.py``'s ``create_console_router`` shape: a factory that
|
||||
returns a ``fastapi.APIRouter`` mounted under a versioned ``/v1`` prefix.
|
||||
Every route flows through an ``AuthProvider`` hook (no-op default) so real
|
||||
authentication can be added later without changing route signatures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from cloud.sdk.models import (
|
||||
DeviceResponse,
|
||||
ErrorResponse,
|
||||
HostResponse,
|
||||
PluginRegistrationRequest,
|
||||
PluginResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.scheduler import TaskConstraints, TaskScheduler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
"""An authenticated principal. ``anonymous`` for the NullAuthProvider."""
|
||||
|
||||
id: str = "anonymous"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProvider(Protocol):
|
||||
"""Returns a Principal if the request is allowed, None to reject."""
|
||||
|
||||
def authenticate(self, request: object) -> Principal | None: ...
|
||||
|
||||
|
||||
class NullAuthProvider:
|
||||
"""Default auth provider: every caller is anonymous-and-allowed."""
|
||||
|
||||
def authenticate(self, request: object) -> Principal | None:
|
||||
return Principal()
|
||||
|
||||
|
||||
def create_cloud_router(
|
||||
*,
|
||||
pool: "DevicePool",
|
||||
scheduler: "TaskScheduler",
|
||||
plugin_registry: "PluginRegistry",
|
||||
auth_provider: AuthProvider | None = None,
|
||||
version_prefix: str = "/v1",
|
||||
) -> APIRouter:
|
||||
"""Build the ``/v1`` APIRouter exposing the platform SDK surface."""
|
||||
auth = auth_provider or NullAuthProvider()
|
||||
router = APIRouter(prefix=version_prefix, tags=["cloud-platform"])
|
||||
|
||||
def _authorize(request: Request) -> Principal:
|
||||
principal = auth.authenticate(request)
|
||||
if principal is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
)
|
||||
return principal
|
||||
|
||||
@router.post(
|
||||
"/tasks",
|
||||
response_model=TaskSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def submit_task(
|
||||
payload: TaskSubmissionRequest,
|
||||
request: Request,
|
||||
) -> TaskSubmissionResponse:
|
||||
_authorize(request)
|
||||
task_constraints = _build_constraints(payload.constraints)
|
||||
try:
|
||||
task_id = scheduler.submit(
|
||||
goal=payload.goal,
|
||||
workflow_definition_id=payload.workflow_definition_id,
|
||||
constraints=task_constraints,
|
||||
)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return TaskSubmissionResponse(task_id=task_id)
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskStatusResponse)
|
||||
def get_task_status(task_id: str, request: Request) -> TaskStatusResponse:
|
||||
_authorize(request)
|
||||
task = scheduler.store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task {task_id!r} not found",
|
||||
)
|
||||
return TaskStatusResponse(
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
goal=task.goal,
|
||||
workflow_definition_id=task.workflow_definition_id,
|
||||
assigned_device_id=task.assigned_device_id,
|
||||
assigned_host_id=task.assigned_host_id,
|
||||
)
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceResponse])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
DeviceResponse(
|
||||
device_id=d.device_id,
|
||||
host_id=d.host_id,
|
||||
driver_type=d.driver_type,
|
||||
status=d.status,
|
||||
capability_tags=list(d.capability_tags),
|
||||
)
|
||||
for d in pool.list_devices()
|
||||
]
|
||||
|
||||
@router.get("/hosts", response_model=list[HostResponse])
|
||||
def list_hosts(request: Request) -> list[HostResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
HostResponse(
|
||||
host_id=h.host_id,
|
||||
address=h.address,
|
||||
last_seen_at=h.last_seen_at.isoformat() if h.last_seen_at else "",
|
||||
)
|
||||
for h in pool.list_hosts()
|
||||
]
|
||||
|
||||
@router.get("/plugins", response_model=list[PluginResponse])
|
||||
def list_plugins(request: Request) -> list[PluginResponse]:
|
||||
_authorize(request)
|
||||
return [
|
||||
PluginResponse(
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
entry_point_kind=manifest.entry_point_kind,
|
||||
target=manifest.target,
|
||||
wired=wired,
|
||||
)
|
||||
for manifest, wired in plugin_registry.list()
|
||||
]
|
||||
|
||||
@router.post(
|
||||
"/plugins",
|
||||
response_model=PluginResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
|
||||
status.HTTP_409_CONFLICT: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
def register_plugin(
|
||||
payload: PluginRegistrationRequest,
|
||||
request: Request,
|
||||
) -> PluginResponse:
|
||||
_authorize(request)
|
||||
from cloud.plugins import (
|
||||
DriverRegistryUnavailableError,
|
||||
DuplicatePluginError,
|
||||
PluginManifest,
|
||||
PluginValidationError,
|
||||
)
|
||||
|
||||
manifest = PluginManifest(
|
||||
name=payload.name,
|
||||
version=payload.version,
|
||||
entry_point_kind=payload.entry_point_kind,
|
||||
target=payload.target,
|
||||
)
|
||||
try:
|
||||
plugin_registry.register(manifest)
|
||||
except DuplicatePluginError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except DriverRegistryUnavailableError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (PluginValidationError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
stored = plugin_registry.store.get_plugin(manifest.name)
|
||||
wired = stored[1] if stored is not None else False
|
||||
return PluginResponse(
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
entry_point_kind=manifest.entry_point_kind,
|
||||
target=manifest.target,
|
||||
wired=wired,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _build_constraints(model):
|
||||
from cloud.scheduler import TaskConstraints
|
||||
|
||||
return TaskConstraints(
|
||||
driver_type=model.driver_type,
|
||||
capability_tags=list(model.capability_tags),
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Thin Python SDK client for the ``/v1`` REST API.
|
||||
|
||||
Capability: ``platform-sdk``.
|
||||
|
||||
Uses ``httpx`` directly so the client can talk to any deployed cloud-runtime
|
||||
process over HTTP, or to a FastAPI ``TestClient`` instance in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class CloudClient:
|
||||
"""A minimal Python wrapper for the platform SDK's ``/v1`` routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
http_client: httpx.Client | Any = None,
|
||||
api_prefix: str = "/v1",
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_prefix = api_prefix.rstrip("/")
|
||||
if http_client is None:
|
||||
self._http = httpx.Client(base_url=self._base_url)
|
||||
self._owns_client = True
|
||||
else:
|
||||
self._http = http_client
|
||||
self._owns_client = False
|
||||
|
||||
def close(self) -> None:
|
||||
if self._owns_client:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "CloudClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
# ------------------------------------------------------------------- tasks
|
||||
|
||||
def submit_task(
|
||||
self,
|
||||
*,
|
||||
goal: str | None = None,
|
||||
workflow_definition_id: str | None = None,
|
||||
driver_type: str | None = None,
|
||||
capability_tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"goal": goal,
|
||||
"workflow_definition_id": workflow_definition_id,
|
||||
}
|
||||
if driver_type is not None or capability_tags is not None:
|
||||
payload["constraints"] = {
|
||||
"driver_type": driver_type,
|
||||
"capability_tags": list(capability_tags or []),
|
||||
}
|
||||
resp = self._http.post(self._url("/tasks"), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
resp = self._http.get(self._url(f"/tasks/{task_id}"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ----------------------------------------------------------------- devices
|
||||
|
||||
def list_devices(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/devices"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def list_hosts(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/hosts"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ----------------------------------------------------------------- plugins
|
||||
|
||||
def list_plugins(self) -> list[dict[str, Any]]:
|
||||
resp = self._http.get(self._url("/plugins"))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def register_plugin(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
version: str,
|
||||
entry_point_kind: str,
|
||||
target: str,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"name": name,
|
||||
"version": version,
|
||||
"entry_point_kind": entry_point_kind,
|
||||
"target": target,
|
||||
}
|
||||
resp = self._http.post(self._url("/plugins"), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}{self._api_prefix}{path}"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic request/response models for the platform SDK REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskConstraintsModel(BaseModel):
|
||||
driver_type: str | None = None
|
||||
capability_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskSubmissionRequest(BaseModel):
|
||||
goal: str | None = None
|
||||
workflow_definition_id: str | None = None
|
||||
constraints: TaskConstraintsModel = Field(default_factory=TaskConstraintsModel)
|
||||
|
||||
|
||||
class TaskSubmissionResponse(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
class TaskStatusResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
goal: str | None = None
|
||||
workflow_definition_id: str | None = None
|
||||
assigned_device_id: str | None = None
|
||||
assigned_host_id: str | None = None
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
device_id: str
|
||||
host_id: str
|
||||
driver_type: str
|
||||
status: str
|
||||
capability_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostResponse(BaseModel):
|
||||
host_id: str
|
||||
address: str | None = None
|
||||
last_seen_at: str
|
||||
|
||||
|
||||
class PluginRegistrationRequest(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
entry_point_kind: Literal["driver", "tool", "skill"]
|
||||
target: str
|
||||
|
||||
|
||||
class PluginResponse(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
entry_point_kind: str
|
||||
target: str
|
||||
wired: bool
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
Reference in New Issue
Block a user