248 lines
7.1 KiB
Python
248 lines
7.1 KiB
Python
"""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,
|
|
)
|