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
353 lines
13 KiB
Python
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
|