"""Small HTTP client for the control-plane Agent contract.""" from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple import httpx class ControlPlaneError(RuntimeError): pass class AgentUnauthorized(ControlPlaneError): pass class StepLeaseConflict(ControlPlaneError): pass class ControlPlaneClient: def __init__( self, server: str, *, enrollment_token: Optional[str] = None, basic_auth: Optional[Tuple[str, str]] = None, timeout_s: float = 15.0, transport: Optional[httpx.BaseTransport] = None, ) -> None: self.server = server.rstrip("/") self.enrollment_token = enrollment_token self._client = httpx.Client( base_url=self.server, auth=httpx.BasicAuth(*basic_auth) if basic_auth else None, timeout=timeout_s, transport=transport, ) def close(self) -> None: self._client.close() def __enter__(self) -> "ControlPlaneClient": return self def __exit__(self, *_: object) -> None: self.close() @staticmethod def _payload(response: httpx.Response) -> Dict[str, Any]: try: value = response.json() except ValueError as exc: raise ControlPlaneError( "控制平面返回了非 JSON 响应: HTTP {0}".format(response.status_code) ) from exc if not isinstance(value, dict): raise ControlPlaneError("控制平面响应不是 JSON object") return value def register(self, fingerprint: Dict[str, Any]) -> Dict[str, Any]: headers = {} if self.enrollment_token: headers["X-FlashOps-Enrollment-Token"] = self.enrollment_token response = self._client.post( "/api/v1/agent/register", json=fingerprint, headers=headers, ) if response.status_code == 401: raise AgentUnauthorized("Agent 注册口令无效") if response.status_code >= 400: raise ControlPlaneError( "Agent 注册失败: HTTP {0} {1}".format( response.status_code, response.text[:200] ) ) payload = self._payload(response) for field in ("agent_id", "host_id", "token"): if not payload.get(field): raise ControlPlaneError("注册响应缺少字段: {0}".format(field)) return payload def heartbeat( self, agent_id: str, token: str, heartbeat: Dict[str, Any], ) -> Dict[str, Any]: response = self._client.post( "/api/v1/agent/{0}/heartbeat".format(agent_id), json=heartbeat, headers={"Authorization": "Bearer " + token}, ) if response.status_code == 401: raise AgentUnauthorized("Agent token 已失效") if response.status_code >= 400: raise ControlPlaneError( "Agent 心跳失败: HTTP {0} {1}".format( response.status_code, response.text[:200] ) ) payload = self._payload(response) if payload.get("ok") is not True: raise ControlPlaneError("控制平面未确认心跳") return payload @staticmethod def _agent_headers(token: str, idempotency_key: Optional[str] = None) -> Dict[str, str]: headers = {"Authorization": "Bearer " + token} if idempotency_key: headers["Idempotency-Key"] = idempotency_key return headers def lease( self, agent_id: str, token: str, *, wait_timeout_s: float = 0.0, ) -> Optional[Dict[str, Any]]: response = self._client.post( "/api/v1/agent/{0}/lease".format(agent_id), json={"wait_timeout_s": wait_timeout_s}, headers=self._agent_headers(token), ) if response.status_code == 204: return None return self._step_response(response, "领取步骤") def _step_response(self, response: httpx.Response, action: str) -> Dict[str, Any]: if response.status_code == 401: raise AgentUnauthorized("Agent token 已失效") if response.status_code == 409: detail = response.text[:300] try: detail = str(response.json().get("detail", detail)) except (ValueError, AttributeError): pass raise StepLeaseConflict("{0}冲突: {1}".format(action, detail)) if response.status_code >= 400: raise ControlPlaneError( "{0}失败: HTTP {1} {2}".format( action, response.status_code, response.text[:200] ) ) return self._payload(response) def _step_post( self, agent_id: str, token: str, step_id: str, action: str, payload: Dict[str, Any], idempotency_key: str, ) -> Dict[str, Any]: response = self._client.post( "/api/v1/agent/{0}/steps/{1}/{2}".format( agent_id, step_id, action ), json=payload, headers=self._agent_headers(token, idempotency_key), ) return self._step_response(response, action) def ack( self, agent_id: str, token: str, step_id: str, attempt: int, idempotency_key: str, ) -> Dict[str, Any]: return self._step_post( agent_id, token, step_id, "ack", {"attempt": attempt}, idempotency_key, ) def renew( self, agent_id: str, token: str, step_id: str, attempt: int, idempotency_key: str, ) -> Dict[str, Any]: return self._step_post( agent_id, token, step_id, "renew", {"attempt": attempt}, idempotency_key, ) def report_events( self, agent_id: str, token: str, step_id: str, attempt: int, events: List[Dict[str, Any]], idempotency_key: str, ) -> Dict[str, Any]: return self._step_post( agent_id, token, step_id, "events", {"attempt": attempt, "events": events}, idempotency_key, ) def complete( self, agent_id: str, token: str, step_id: str, attempt: int, *, status: str, idempotency_key: str, exit_code: Optional[int] = None, error_class: Optional[str] = None, result: Optional[Dict[str, Any]] = None, log_uri: Optional[str] = None, ) -> Dict[str, Any]: return self._step_post( agent_id, token, step_id, "complete", { "attempt": attempt, "status": status, "exit_code": exit_code, "error_class": error_class, "result": result or {}, "log_uri": log_uri, }, idempotency_key, )