chore(repo): initialize team collaboration repository
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user