Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

353 lines
13 KiB
Python

"""Prefect REST API client.
Wraps the Prefect Server / Prefect Cloud API endpoints used for flow run,
worker, and deployment investigation.
Credentials come from the user's Prefect integration stored locally or via env vars.
"""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import quote
import httpx
from pydantic import field_validator
from config.strict_config import StrictConfigModel
from platform.observability.errors.service import capture_service_error
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 30
_PREFECT_CLOUD_BASE = "https://api.prefect.cloud/api"
class PrefectConfig(StrictConfigModel):
"""Normalized Prefect credentials.
Supports both Prefect Cloud (api_key + account_id + workspace_id)
and self-hosted Prefect Server (api_url only).
"""
api_url: str = _PREFECT_CLOUD_BASE
api_key: str = ""
account_id: str = ""
workspace_id: str = ""
integration_id: str = ""
@field_validator("api_url", mode="before")
@classmethod
def _normalize_api_url(cls, value: object) -> str:
return str(value or _PREFECT_CLOUD_BASE).strip().rstrip("/")
@field_validator("api_key", "account_id", "workspace_id", mode="before")
@classmethod
def _normalize_str(cls, value: object) -> str:
return str(value or "").strip()
@property
def base_url(self) -> str:
"""Resolve the effective API base URL.
For Prefect Cloud, the path includes account and workspace slugs.
For self-hosted, api_url is used as-is.
"""
if self.account_id and self.workspace_id:
return f"{self.api_url}/accounts/{self.account_id}/workspaces/{self.workspace_id}"
return self.api_url
@property
def headers(self) -> dict[str, str]:
h: dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
return h
class PrefectClient:
"""Synchronous client for the Prefect REST API."""
def __init__(self, config: PrefectConfig) -> None:
self.config = config
self._client: httpx.Client | None = None
def _get_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
base_url=self.config.base_url,
headers=self.config.headers,
timeout=_DEFAULT_TIMEOUT,
)
return self._client
def close(self) -> None:
"""Close the underlying HTTP connection pool."""
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> PrefectClient:
return self
def __exit__(self, *_: object) -> None:
self.close()
@property
def is_configured(self) -> bool:
"""Return True when an API key is set or a non-default API URL has been supplied."""
return bool(self.config.api_key) or self.config.api_url != _PREFECT_CLOUD_BASE
# ------------------------------------------------------------------
# Flow runs
# ------------------------------------------------------------------
def get_flow_runs(
self,
limit: int = 20,
states: list[str] | None = None,
) -> dict[str, Any]:
"""Fetch recent flow runs, optionally filtered by state.
Args:
limit: Maximum number of flow runs to return.
states: List of Prefect state names to filter on (e.g. ["FAILED", "CRASHED"]).
"""
body: dict[str, Any] = {
"limit": min(limit, 200),
"sort": "START_TIME_DESC",
}
if states:
body["flow_runs"] = {"state": {"type": {"any_": [s.upper() for s in states]}}}
try:
resp = self._get_client().post("/flow_runs/filter", json=body)
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
runs = [
{
"id": r.get("id", ""),
"name": r.get("name", ""),
"flow_id": r.get("flow_id", ""),
"state_type": r.get("state_type", ""),
"state_name": r.get("state_name", ""),
"start_time": r.get("start_time", ""),
"end_time": r.get("end_time", ""),
"duration": r.get("total_run_time", None),
"deployment_id": r.get("deployment_id", ""),
"tags": r.get("tags", []),
}
for r in raw
]
return {"success": True, "flow_runs": runs, "total": len(runs)}
except httpx.HTTPStatusError as exc:
capture_service_error(exc, logger=logger, integration="prefect", method="get_flow_runs")
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(exc, logger=logger, integration="prefect", method="get_flow_runs")
return {"success": False, "error": str(exc)}
def get_flow_run_logs(self, flow_run_id: str, limit: int = 100) -> dict[str, Any]:
"""Fetch logs emitted during a specific flow run.
Args:
flow_run_id: The UUID of the flow run.
limit: Maximum number of log lines to return.
"""
body: dict[str, Any] = {
"limit": min(limit, 1000),
"logs": {"flow_run_id": {"any_": [flow_run_id]}},
"sort": "TIMESTAMP_ASC",
}
try:
resp = self._get_client().post("/logs/filter", json=body)
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
logs = [
{
"timestamp": entry.get("timestamp", ""),
"level": entry.get("level", ""),
"message": entry.get("message", ""),
"task_run_id": entry.get("task_run_id", ""),
}
for entry in raw
]
return {"success": True, "logs": logs, "total": len(logs)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="prefect",
method="get_flow_run_logs",
extras={"flow_run_id": flow_run_id},
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="prefect",
method="get_flow_run_logs",
extras={"flow_run_id": flow_run_id},
)
return {"success": False, "error": str(exc)}
# ------------------------------------------------------------------
# Workers / work pools
# ------------------------------------------------------------------
def get_work_pools(self, limit: int = 20) -> dict[str, Any]:
"""List available Prefect work pools.
Args:
limit: Maximum number of work pools to return.
"""
body: dict[str, Any] = {"limit": min(limit, 200)}
try:
resp = self._get_client().post("/work_pools/filter", json=body)
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
pools = [
{
"id": p.get("id", ""),
"name": p.get("name", ""),
"type": p.get("type", ""),
"status": p.get("status", ""),
"is_paused": p.get("is_paused", False),
"concurrency_limit": p.get("concurrency_limit", None),
}
for p in raw
]
return {"success": True, "work_pools": pools, "total": len(pools)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc, logger=logger, integration="prefect", method="get_work_pools"
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc, logger=logger, integration="prefect", method="get_work_pools"
)
return {"success": False, "error": str(exc)}
def get_workers(self, work_pool_name: str, limit: int = 20) -> dict[str, Any]:
"""List workers registered in a work pool.
Args:
work_pool_name: The name of the work pool to query.
limit: Maximum number of workers to return.
"""
body: dict[str, Any] = {"limit": min(limit, 200)}
try:
resp = self._get_client().post(
f"/work_pools/{quote(work_pool_name, safe='')}/workers/filter", json=body
)
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
workers = [
{
"name": w.get("name", ""),
"status": w.get("status", ""),
"last_heartbeat_time": w.get("last_heartbeat_time", ""),
"work_pool_id": w.get("work_pool_id", ""),
}
for w in raw
]
return {"success": True, "workers": workers, "total": len(workers)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="prefect",
method="get_workers",
extras={"work_pool_name": work_pool_name},
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="prefect",
method="get_workers",
extras={"work_pool_name": work_pool_name},
)
return {"success": False, "error": str(exc)}
# ------------------------------------------------------------------
# Deployments
# ------------------------------------------------------------------
def get_deployments(self, limit: int = 20) -> dict[str, Any]:
"""List Prefect deployments.
Args:
limit: Maximum number of deployments to return.
"""
body: dict[str, Any] = {"limit": min(limit, 200)}
try:
resp = self._get_client().post("/deployments/filter", json=body)
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
deployments = [
{
"id": d.get("id", ""),
"name": d.get("name", ""),
"flow_id": d.get("flow_id", ""),
"is_schedule_active": d.get("is_schedule_active", False),
"work_pool_name": d.get("work_pool_name", ""),
"last_polled": d.get("last_polled", ""),
"status": d.get("status", ""),
"tags": d.get("tags", []),
}
for d in raw
]
return {"success": True, "deployments": deployments, "total": len(deployments)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc, logger=logger, integration="prefect", method="get_deployments"
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc, logger=logger, integration="prefect", method="get_deployments"
)
return {"success": False, "error": str(exc)}
def make_prefect_client(
api_url: str | None,
api_key: str | None = None,
account_id: str | None = None,
workspace_id: str | None = None,
) -> PrefectClient | None:
"""Build a configured PrefectClient, returning None if api_url is absent."""
url = (api_url or "").strip()
if not url:
return None
try:
return PrefectClient(
PrefectConfig(
api_url=url,
api_key=api_key or "",
account_id=account_id or "",
workspace_id=workspace_id or "",
)
)
except Exception:
return None