354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
|
|
|
The public gateway owns user-facing control operations only. Runtime lifecycle
|
|
transitions are deliberately absent: external callers cannot directly move a
|
|
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
|
|
through the provenance-protected orchestrator and internal reconciliation API.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib.parse
|
|
from enum import Enum
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any, Mapping
|
|
|
|
from aios_hermes import HermesConflict, HermesNotFound
|
|
|
|
from .control_plane import (
|
|
ControlPlaneError,
|
|
ControlPlaneService,
|
|
IdempotencyConflict,
|
|
InvalidTransition,
|
|
NotFound,
|
|
PolicyDenied,
|
|
RequestContext,
|
|
RevisionConflict,
|
|
TaskState,
|
|
TenantIsolationError,
|
|
Unauthorized,
|
|
)
|
|
from .dispatch_store import DispatchConflict, DispatchNotFound
|
|
from .orchestrator import OrchestrationError
|
|
from .provenance import ProvenanceDenied, ProvenanceReplay
|
|
|
|
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
|
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
|
ROLES_HEADER = "X-AIOS-Roles"
|
|
TRACE_HEADER = "X-AIOS-Trace-ID"
|
|
SESSION_HEADER = "X-AIOS-Session-ID"
|
|
POLICY_HEADER = "X-AIOS-Policy-Version"
|
|
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
|
|
|
|
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
|
|
|
|
|
def _json_value(value: Any) -> Any:
|
|
if dataclasses.is_dataclass(value):
|
|
return {
|
|
field.name: _json_value(getattr(value, field.name))
|
|
for field in dataclasses.fields(value)
|
|
}
|
|
if isinstance(value, Enum):
|
|
return value.value
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _json_value(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return [_json_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError(f"{label} must be an object")
|
|
return value
|
|
|
|
|
|
def _require_resource_id(value: Any, *, label: str) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not _RESOURCE_ID_RE.fullmatch(normalized):
|
|
raise ValueError(f"invalid {label}")
|
|
return normalized
|
|
|
|
|
|
class GatewayApplication:
|
|
"""Pure user-facing request router that can be tested without a socket."""
|
|
|
|
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
|
self.service = service or ControlPlaneService()
|
|
|
|
@staticmethod
|
|
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
|
normalized_headers = {
|
|
str(key).strip().lower(): value for key, value in headers.items()
|
|
}
|
|
|
|
def header(name: str, default: str = "") -> str:
|
|
return str(normalized_headers.get(name.lower(), default))
|
|
|
|
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
|
|
if authenticated not in {"1", "true", "yes"}:
|
|
raise Unauthorized("trusted authentication assertion is required")
|
|
roles = [
|
|
role.strip()
|
|
for role in header(ROLES_HEADER).split(",")
|
|
if role.strip()
|
|
]
|
|
return RequestContext.create(
|
|
tenant_id=header(TENANT_HEADER),
|
|
principal_id=header(PRINCIPAL_HEADER),
|
|
trace_id=header(TRACE_HEADER).strip() or None,
|
|
session_id=header(SESSION_HEADER).strip() or None,
|
|
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
|
|
roles=roles,
|
|
)
|
|
|
|
def handle(
|
|
self,
|
|
method: str,
|
|
raw_path: str,
|
|
headers: Mapping[str, str],
|
|
body: Any = None,
|
|
) -> tuple[int, Mapping[str, Any]]:
|
|
parsed = urllib.parse.urlparse(raw_path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
|
method = method.upper()
|
|
|
|
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
|
|
return HTTPStatus.OK, {
|
|
"service": "aios-v2-control-plane",
|
|
"status": "ok",
|
|
"store": type(self.service.store).__name__,
|
|
}
|
|
|
|
context = self.context_from_headers(headers)
|
|
payload = _require_mapping(body or {})
|
|
|
|
if method == "POST" and path == "/api/v2/tenants":
|
|
tenant = self.service.register_tenant(
|
|
context,
|
|
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
|
|
display_name=str(payload.get("display_name", "")),
|
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
|
)
|
|
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
|
|
|
if method == "GET" and path == "/api/v2/tenants/current":
|
|
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
|
|
|
|
if method == "POST" and path == "/api/v2/agents":
|
|
agent = self.service.register_agent(
|
|
context,
|
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
|
display_name=str(payload.get("display_name", "")),
|
|
runtime=str(payload.get("runtime", "")),
|
|
capabilities=payload.get("capabilities", ()),
|
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
|
)
|
|
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
|
|
|
if method == "GET" and path == "/api/v2/agents":
|
|
return HTTPStatus.OK, {"agents": _json_value(self.service.list_agents(context))}
|
|
|
|
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
|
if method == "GET" and agent_match:
|
|
agent_id = _require_resource_id(
|
|
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
|
|
)
|
|
return HTTPStatus.OK, {
|
|
"agent": _json_value(self.service.get_agent(context, agent_id))
|
|
}
|
|
|
|
if method == "POST" and path == "/api/v2/tasks":
|
|
task = self.service.create_task(
|
|
context,
|
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
|
action=str(payload.get("action", "")).strip(),
|
|
payload=_require_mapping(payload.get("payload", {}), label="payload"),
|
|
idempotency_key=(
|
|
str(payload["idempotency_key"]).strip()
|
|
if payload.get("idempotency_key") is not None
|
|
else None
|
|
),
|
|
)
|
|
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
|
|
|
if method == "GET" and path == "/api/v2/tasks":
|
|
tasks = self.service.list_tasks(context, states=query.get("state"))
|
|
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
|
|
|
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
|
if method == "GET" and task_match:
|
|
task_id = _require_resource_id(
|
|
urllib.parse.unquote(task_match.group(1)), label="task_id"
|
|
)
|
|
return HTTPStatus.OK, {
|
|
"task": _json_value(self.service.get_task(context, task_id))
|
|
}
|
|
|
|
bypass_match = re.fullmatch(
|
|
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
|
|
)
|
|
if method == "POST" and bypass_match:
|
|
return HTTPStatus.FORBIDDEN, {
|
|
"error": "RuntimeLifecycleBypassDenied",
|
|
"message": (
|
|
"RUNNING, COMPLETED and FAILED transitions are internal; "
|
|
"use the provenance-protected dispatch/reconcile flow"
|
|
),
|
|
}
|
|
|
|
transition_match = re.fullmatch(
|
|
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
|
|
)
|
|
if method == "POST" and transition_match:
|
|
task_id = _require_resource_id(
|
|
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
|
)
|
|
target = (
|
|
TaskState.APPROVED
|
|
if transition_match.group(2) == "approve"
|
|
else TaskState.CANCELLED
|
|
)
|
|
expected_revision = payload.get("expected_revision")
|
|
task = self.service.transition_task(
|
|
context,
|
|
task_id=task_id,
|
|
target_state=target,
|
|
expected_revision=(
|
|
int(expected_revision) if expected_revision is not None else None
|
|
),
|
|
)
|
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
|
|
|
if method == "GET" and path == "/api/v2/audit":
|
|
resource_id = query.get("resource_id", [None])[0]
|
|
events = self.service.list_audit_events(
|
|
context,
|
|
resource_id=(
|
|
_require_resource_id(resource_id, label="resource_id")
|
|
if resource_id
|
|
else None
|
|
),
|
|
)
|
|
return HTTPStatus.OK, {"events": _json_value(events)}
|
|
|
|
return HTTPStatus.NOT_FOUND, {
|
|
"error": "not_found",
|
|
"message": "route not found",
|
|
}
|
|
|
|
|
|
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
|
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
|
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
|
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
|
|
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
|
(NotFound, HTTPStatus.NOT_FOUND),
|
|
(DispatchNotFound, HTTPStatus.NOT_FOUND),
|
|
(HermesNotFound, HTTPStatus.NOT_FOUND),
|
|
(ProvenanceReplay, HTTPStatus.CONFLICT),
|
|
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
|
(RevisionConflict, HTTPStatus.CONFLICT),
|
|
(InvalidTransition, HTTPStatus.CONFLICT),
|
|
(DispatchConflict, HTTPStatus.CONFLICT),
|
|
(HermesConflict, HTTPStatus.CONFLICT),
|
|
(OrchestrationError, HTTPStatus.CONFLICT),
|
|
(ValueError, HTTPStatus.BAD_REQUEST),
|
|
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
|
)
|
|
|
|
|
|
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
|
for error_type, status in _ERROR_STATUS:
|
|
if isinstance(exc, error_type):
|
|
return status, {"error": error_type.__name__, "message": str(exc)}
|
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
|
"error": "InternalServerError",
|
|
"message": "internal server error",
|
|
}
|
|
|
|
|
|
class GatewayRequestHandler(BaseHTTPRequestHandler):
|
|
server_version = "AIOSV2Gateway/1.0"
|
|
max_body_bytes = 1_048_576
|
|
|
|
@property
|
|
def application(self) -> GatewayApplication:
|
|
return self.server.application # type: ignore[attr-defined]
|
|
|
|
def _read_body(self) -> Any:
|
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
if length < 0 or length > self.max_body_bytes:
|
|
raise ValueError("request body is too large")
|
|
if length == 0:
|
|
return {}
|
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
|
if content_type != "application/json":
|
|
raise ValueError("Content-Type must be application/json")
|
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
|
|
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
|
encoded = json.dumps(
|
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8")
|
|
self.send_response(int(status))
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _dispatch(self, method: str) -> None:
|
|
try:
|
|
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
|
|
status, payload = self.application.handle(
|
|
method,
|
|
self.path,
|
|
{key: value for key, value in self.headers.items()},
|
|
body,
|
|
)
|
|
except Exception as exc:
|
|
status, payload = error_response(exc)
|
|
self._send_json(status, payload)
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
self._dispatch("GET")
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
self._dispatch("POST")
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
super().log_message(format, *args)
|
|
|
|
|
|
class GatewayHTTPServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
def __init__(
|
|
self,
|
|
server_address: tuple[str, int],
|
|
application: GatewayApplication,
|
|
) -> None:
|
|
self.application = application
|
|
super().__init__(server_address, GatewayRequestHandler)
|
|
|
|
|
|
def main() -> None:
|
|
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
|
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
|
server = GatewayHTTPServer((host, port), GatewayApplication())
|
|
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|