pulse-engine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse_engine/__init__.py +0 -0
- pulse_engine/adapters/__init__.py +58 -0
- pulse_engine/adapters/audio_transcription.py +167 -0
- pulse_engine/adapters/batcher.py +36 -0
- pulse_engine/adapters/digital_news.py +128 -0
- pulse_engine/adapters/digital_news_metadata.py +536 -0
- pulse_engine/adapters/exceptions.py +10 -0
- pulse_engine/adapters/models.py +134 -0
- pulse_engine/adapters/opensearch_storage.py +160 -0
- pulse_engine/adapters/speech_content.py +130 -0
- pulse_engine/adapters/speech_metadata.py +374 -0
- pulse_engine/adapters/twitter.py +423 -0
- pulse_engine/adapters/youtube_downloader.py +186 -0
- pulse_engine/adapters/youtube_metadata.py +261 -0
- pulse_engine/api/__init__.py +0 -0
- pulse_engine/api/v1/__init__.py +0 -0
- pulse_engine/api/v1/auth.py +91 -0
- pulse_engine/api/v1/health.py +62 -0
- pulse_engine/api/v1/router.py +16 -0
- pulse_engine/chain_recovery.py +131 -0
- pulse_engine/cli/__init__.py +0 -0
- pulse_engine/cli/main.py +169 -0
- pulse_engine/cli/templates/cookiecutter.json +4 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/.gitignore +13 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/Dockerfile +32 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pipeline.yaml +17 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/pyproject.toml +25 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/src/pulse_{{cookiecutter.product_slug}}/__init__.py +8 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/__init__.py +0 -0
- pulse_engine/cli/templates/pulse-{{cookiecutter.product_name}}/tests/unit/test_manifest.py +15 -0
- pulse_engine/client.py +95 -0
- pulse_engine/config.py +157 -0
- pulse_engine/core/__init__.py +0 -0
- pulse_engine/core/error_handlers.py +64 -0
- pulse_engine/core/exceptions.py +67 -0
- pulse_engine/core/job_token.py +109 -0
- pulse_engine/core/logging.py +45 -0
- pulse_engine/core/scope.py +23 -0
- pulse_engine/core/security.py +130 -0
- pulse_engine/database.py +30 -0
- pulse_engine/dependencies.py +166 -0
- pulse_engine/deployment/__init__.py +0 -0
- pulse_engine/deployment/backend_deployment_repository.py +83 -0
- pulse_engine/deployment/backends/__init__.py +0 -0
- pulse_engine/deployment/backends/base.py +50 -0
- pulse_engine/deployment/backends/exceptions.py +20 -0
- pulse_engine/deployment/backends/native_lambda.py +125 -0
- pulse_engine/deployment/backends/prefect_ecs.py +116 -0
- pulse_engine/deployment/backends/prefect_k8s.py +131 -0
- pulse_engine/deployment/backends/registry.py +50 -0
- pulse_engine/deployment/infra_provisioner.py +285 -0
- pulse_engine/deployment/job_launcher.py +178 -0
- pulse_engine/deployment/models.py +48 -0
- pulse_engine/deployment/repository.py +54 -0
- pulse_engine/deployment/router.py +22 -0
- pulse_engine/deployment/schemas.py +18 -0
- pulse_engine/deployment/service.py +65 -0
- pulse_engine/extractor/__init__.py +0 -0
- pulse_engine/extractor/adapters/__init__.py +0 -0
- pulse_engine/extractor/base.py +48 -0
- pulse_engine/extractor/models.py +50 -0
- pulse_engine/extractor/orchestrator/__init__.py +15 -0
- pulse_engine/extractor/orchestrator/base.py +34 -0
- pulse_engine/extractor/orchestrator/noop.py +37 -0
- pulse_engine/extractor/orchestrator/prefect.py +163 -0
- pulse_engine/extractor/repository.py +163 -0
- pulse_engine/extractor/router.py +102 -0
- pulse_engine/extractor/schemas.py +93 -0
- pulse_engine/extractor/service.py +431 -0
- pulse_engine/extractor/stage_models.py +36 -0
- pulse_engine/extractor/stage_repository.py +109 -0
- pulse_engine/main.py +195 -0
- pulse_engine/mcp/__init__.py +0 -0
- pulse_engine/mcp/__main__.py +5 -0
- pulse_engine/mcp/server.py +108 -0
- pulse_engine/mcp/tools_jobs.py +159 -0
- pulse_engine/mcp/tools_kb.py +88 -0
- pulse_engine/mcp/tools_modules.py +115 -0
- pulse_engine/mcp/tools_pipelines.py +215 -0
- pulse_engine/mcp/tools_processor.py +208 -0
- pulse_engine/middleware/__init__.py +0 -0
- pulse_engine/middleware/rate_limit.py +144 -0
- pulse_engine/middleware/request_id.py +16 -0
- pulse_engine/middleware/security_headers.py +25 -0
- pulse_engine/middleware/tenant.py +90 -0
- pulse_engine/pipeline/__init__.py +0 -0
- pulse_engine/pipeline/config_parser.py +148 -0
- pulse_engine/pipeline/expression.py +268 -0
- pulse_engine/pipeline/models.py +98 -0
- pulse_engine/pipeline/repositories.py +224 -0
- pulse_engine/pipeline/router_modules.py +66 -0
- pulse_engine/pipeline/router_pipelines.py +198 -0
- pulse_engine/pipeline/schemas.py +200 -0
- pulse_engine/pipeline/service.py +250 -0
- pulse_engine/pipeline/translators/__init__.py +44 -0
- pulse_engine/pipeline/translators/airflow_status.py +11 -0
- pulse_engine/pipeline/translators/airflow_translator.py +22 -0
- pulse_engine/pipeline/translators/base.py +42 -0
- pulse_engine/pipeline/translators/prefect_status.py +93 -0
- pulse_engine/pipeline/translators/prefect_translator.py +195 -0
- pulse_engine/processor/__init__.py +0 -0
- pulse_engine/processor/base.py +36 -0
- pulse_engine/processor/core/__init__.py +0 -0
- pulse_engine/processor/core/analysis.py +148 -0
- pulse_engine/processor/core/chunking.py +158 -0
- pulse_engine/processor/core/prompts.py +340 -0
- pulse_engine/processor/core/topic_splitter.py +105 -0
- pulse_engine/processor/defaults/__init__.py +11 -0
- pulse_engine/processor/defaults/core_processor.py +12 -0
- pulse_engine/processor/defaults/postprocessor.py +12 -0
- pulse_engine/processor/defaults/preprocessor.py +12 -0
- pulse_engine/processor/llm/__init__.py +0 -0
- pulse_engine/processor/llm/provider.py +58 -0
- pulse_engine/processor/ocr/gemini.py +52 -0
- pulse_engine/processor/pipeline.py +107 -0
- pulse_engine/processor/postprocessor/__init__.py +0 -0
- pulse_engine/processor/postprocessor/embeddings.py +34 -0
- pulse_engine/processor/postprocessor/tasks.py +180 -0
- pulse_engine/processor/preprocessor/__init__.py +0 -0
- pulse_engine/processor/preprocessor/tasks.py +71 -0
- pulse_engine/processor/router.py +192 -0
- pulse_engine/processor/schemas.py +167 -0
- pulse_engine/registry.py +117 -0
- pulse_engine/runners/__init__.py +0 -0
- pulse_engine/runners/lambda_runner.py +26 -0
- pulse_engine/runners/pipeline_runner.py +43 -0
- pulse_engine/runners/prefect_pipeline_flow.py +904 -0
- pulse_engine/runners/prefect_runner.py +33 -0
- pulse_engine/s3.py +72 -0
- pulse_engine/secrets.py +46 -0
- pulse_engine/services/__init__.py +0 -0
- pulse_engine/services/bootstrap.py +211 -0
- pulse_engine/services/opensearch.py +84 -0
- pulse_engine/storage/__init__.py +0 -0
- pulse_engine/storage/connectors/__init__.py +0 -0
- pulse_engine/storage/connectors/athena.py +226 -0
- pulse_engine/storage/connectors/base.py +32 -0
- pulse_engine/storage/connectors/opensearch.py +344 -0
- pulse_engine/storage/knowledge_base.py +68 -0
- pulse_engine/storage/router.py +78 -0
- pulse_engine/storage/schemas.py +93 -0
- pulse_engine/testing/__init__.py +13 -0
- pulse_engine/testing/fixtures.py +50 -0
- pulse_engine/testing/mocks.py +104 -0
- pulse_engine/worker.py +53 -0
- pulse_engine-0.2.0.dist-info/METADATA +654 -0
- pulse_engine-0.2.0.dist-info/RECORD +150 -0
- pulse_engine-0.2.0.dist-info/WHEEL +4 -0
- pulse_engine-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Native Lambda runner backend — invokes Lambda directly via boto3.
|
|
2
|
+
|
|
3
|
+
When InfraProvisioner is configured, Lambda functions are auto-provisioned
|
|
4
|
+
from container images at registration time. Otherwise falls back to
|
|
5
|
+
expecting pre-provisioned functions (backward compatible).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import boto3
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from pulse_engine.deployment.backends.base import BaseRunnerBackend
|
|
18
|
+
from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pulse_engine.config import Settings
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NativeLambdaBackend(BaseRunnerBackend):
|
|
27
|
+
"""Invokes Lambda functions directly via boto3 (no Prefect involved).
|
|
28
|
+
|
|
29
|
+
Lambda functions follow the naming convention: {product}-{stage}.
|
|
30
|
+
If pipeline infrastructure settings are configured, functions are
|
|
31
|
+
auto-provisioned from container images. Otherwise they must be
|
|
32
|
+
pre-provisioned by Terraform.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, settings: Settings) -> None:
|
|
36
|
+
self._settings = settings
|
|
37
|
+
self._region = settings.aws_region
|
|
38
|
+
self._provisioner: Any = None
|
|
39
|
+
|
|
40
|
+
def _get_provisioner(self) -> Any:
|
|
41
|
+
if self._provisioner is not None:
|
|
42
|
+
return self._provisioner
|
|
43
|
+
role_arn = getattr(self._settings, "lambda_execution_role_arn", "")
|
|
44
|
+
if not isinstance(role_arn, str) or not role_arn:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
from pulse_engine.deployment.infra_provisioner import InfraProvisioner
|
|
48
|
+
|
|
49
|
+
self._provisioner = InfraProvisioner(
|
|
50
|
+
region=self._settings.aws_region,
|
|
51
|
+
pipeline_cluster_name=self._settings.pipeline_cluster_name,
|
|
52
|
+
pipeline_execution_role_arn=self._settings.pipeline_execution_role_arn,
|
|
53
|
+
pipeline_task_role_arn=self._settings.pipeline_task_role_arn,
|
|
54
|
+
pipeline_log_group=self._settings.pipeline_log_group,
|
|
55
|
+
pipeline_subnets=self._settings.pipeline_subnet_list,
|
|
56
|
+
pipeline_security_groups=self._settings.pipeline_sg_list,
|
|
57
|
+
lambda_execution_role_arn=self._settings.lambda_execution_role_arn,
|
|
58
|
+
lambda_subnets=self._settings.lambda_subnet_list,
|
|
59
|
+
lambda_security_groups=self._settings.lambda_sg_list,
|
|
60
|
+
lambda_log_group=self._settings.lambda_log_group,
|
|
61
|
+
)
|
|
62
|
+
return self._provisioner
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.warning("Failed to create InfraProvisioner", exc_info=True)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
async def prepare(self) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
async def register(
|
|
71
|
+
self,
|
|
72
|
+
product: str,
|
|
73
|
+
stage: str,
|
|
74
|
+
image: str,
|
|
75
|
+
entrypoint: str | None = None,
|
|
76
|
+
) -> str:
|
|
77
|
+
func_name = f"{product}-{stage}"
|
|
78
|
+
|
|
79
|
+
# Auto-provision Lambda function from container image
|
|
80
|
+
provisioner = self._get_provisioner()
|
|
81
|
+
if provisioner and image:
|
|
82
|
+
provisioner.ensure_lambda_function(func_name, image)
|
|
83
|
+
logger.info(
|
|
84
|
+
"lambda_auto_provisioned",
|
|
85
|
+
function=func_name,
|
|
86
|
+
image=image,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return func_name
|
|
90
|
+
|
|
91
|
+
async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
|
|
92
|
+
from botocore.exceptions import ClientError
|
|
93
|
+
|
|
94
|
+
from pulse_engine.core.exceptions import NotFoundError
|
|
95
|
+
|
|
96
|
+
run_id = str(uuid.uuid4())
|
|
97
|
+
payload = {**parameters, "run_id": run_id}
|
|
98
|
+
client = boto3.client("lambda", region_name=self._region)
|
|
99
|
+
try:
|
|
100
|
+
client.invoke(
|
|
101
|
+
FunctionName=handle,
|
|
102
|
+
InvocationType="Event", # async fire-and-forget
|
|
103
|
+
Payload=json.dumps(payload),
|
|
104
|
+
)
|
|
105
|
+
except ClientError as exc:
|
|
106
|
+
code = exc.response["Error"]["Code"]
|
|
107
|
+
if code == "ResourceNotFoundException":
|
|
108
|
+
raise NotFoundError(
|
|
109
|
+
f"Lambda function not found: {handle}. "
|
|
110
|
+
"Ensure pipeline infrastructure settings are configured "
|
|
111
|
+
"or the function is deployed manually.",
|
|
112
|
+
function=handle,
|
|
113
|
+
) from exc
|
|
114
|
+
raise
|
|
115
|
+
logger.info("lambda_triggered", function=handle, run_id=run_id)
|
|
116
|
+
return run_id
|
|
117
|
+
|
|
118
|
+
async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
|
|
119
|
+
# Status arrives via Lambda callback to POST /jobs/{id}/status
|
|
120
|
+
# The engine's job record is the source of truth — not Lambda
|
|
121
|
+
return OrchestratorRunStatus(run_id=run_id, status="unknown")
|
|
122
|
+
|
|
123
|
+
async def cancel_run(self, run_id: str) -> bool:
|
|
124
|
+
# Async Lambda invocations cannot be cancelled post-fire
|
|
125
|
+
return False
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Prefect + ECS runner backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from pulse_engine.deployment.backends.base import BaseRunnerBackend
|
|
12
|
+
from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pulse_engine.config import Settings
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
_PREFECT_STATE_MAP: dict[str, str] = {
|
|
20
|
+
"COMPLETED": "completed",
|
|
21
|
+
"FAILED": "failed",
|
|
22
|
+
"CRASHED": "failed",
|
|
23
|
+
"CANCELLED": "cancelled",
|
|
24
|
+
"CANCELLING": "cancelled",
|
|
25
|
+
"RUNNING": "running",
|
|
26
|
+
"PENDING": "pending",
|
|
27
|
+
"SCHEDULED": "pending",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PrefectECSBackend(BaseRunnerBackend):
|
|
32
|
+
"""Runs Prefect flow runs on an ECS work pool."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, settings: Settings) -> None:
|
|
35
|
+
self._work_pool_name = settings.prefect_ecs_work_pool_name
|
|
36
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
37
|
+
if settings.prefect_api_key:
|
|
38
|
+
encoded = base64.b64encode(settings.prefect_api_key.encode()).decode()
|
|
39
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
40
|
+
self._client = httpx.AsyncClient(
|
|
41
|
+
base_url=settings.prefect_api_url.rstrip("/"),
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=10.0,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def prepare(self) -> None:
|
|
47
|
+
resp = await self._client.get(f"/work_pools/{self._work_pool_name}")
|
|
48
|
+
if resp.is_success:
|
|
49
|
+
return
|
|
50
|
+
payload: dict[str, Any] = {
|
|
51
|
+
"name": self._work_pool_name,
|
|
52
|
+
"type": "ecs",
|
|
53
|
+
}
|
|
54
|
+
resp = await self._client.post("/work_pools/", json=payload)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
|
|
57
|
+
async def register(
|
|
58
|
+
self,
|
|
59
|
+
product: str,
|
|
60
|
+
stage: str,
|
|
61
|
+
image: str,
|
|
62
|
+
entrypoint: str | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
name = f"{product}-{stage}"
|
|
65
|
+
flow_id = await self._get_or_create_flow_id(name)
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"name": name,
|
|
68
|
+
"flow_id": flow_id,
|
|
69
|
+
"entrypoint": entrypoint,
|
|
70
|
+
"work_pool_name": self._work_pool_name,
|
|
71
|
+
"job_variables": {"image": image},
|
|
72
|
+
}
|
|
73
|
+
resp = await self._client.post("/deployments/", json=payload)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
data: dict[str, Any] = resp.json()
|
|
76
|
+
return str(data["id"])
|
|
77
|
+
|
|
78
|
+
async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
|
|
79
|
+
resp = await self._client.post(
|
|
80
|
+
f"/deployments/{handle}/create_flow_run",
|
|
81
|
+
json={"parameters": parameters},
|
|
82
|
+
)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
data: dict[str, Any] = resp.json()
|
|
85
|
+
return str(data["id"])
|
|
86
|
+
|
|
87
|
+
async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
|
|
88
|
+
try:
|
|
89
|
+
resp = await self._client.get(f"/flow_runs/{run_id}")
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
data = resp.json()
|
|
92
|
+
raw_state = data.get("state", {}).get("type", "UNKNOWN")
|
|
93
|
+
canonical = _PREFECT_STATE_MAP.get(raw_state.upper(), "unknown")
|
|
94
|
+
return OrchestratorRunStatus(
|
|
95
|
+
run_id=run_id, status=canonical, raw_state=raw_state
|
|
96
|
+
)
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.warning("ecs_backend_status_failed", run_id=run_id, exc_info=True)
|
|
99
|
+
return OrchestratorRunStatus(run_id=run_id, status="unknown")
|
|
100
|
+
|
|
101
|
+
async def cancel_run(self, run_id: str) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
resp = await self._client.post(
|
|
104
|
+
f"/flow_runs/{run_id}/set_state",
|
|
105
|
+
json={"state": {"type": "CANCELLED"}},
|
|
106
|
+
)
|
|
107
|
+
return resp.is_success
|
|
108
|
+
except Exception:
|
|
109
|
+
logger.warning("ecs_backend_cancel_failed", run_id=run_id, exc_info=True)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
async def _get_or_create_flow_id(self, flow_name: str) -> str:
|
|
113
|
+
resp = await self._client.post("/flows/", json={"name": flow_name})
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
data: dict[str, Any] = resp.json()
|
|
116
|
+
return str(data["id"])
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Prefect + Kubernetes runner backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from pulse_engine.deployment.backends.base import BaseRunnerBackend
|
|
12
|
+
from pulse_engine.deployment.backends.prefect_ecs import _PREFECT_STATE_MAP
|
|
13
|
+
from pulse_engine.extractor.orchestrator.base import OrchestratorRunStatus
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pulse_engine.config import Settings
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrefectK8sBackend(BaseRunnerBackend):
|
|
22
|
+
"""Runs Prefect flow runs on a Kubernetes work pool.
|
|
23
|
+
|
|
24
|
+
A Prefect Kubernetes worker runs inside the cluster (deployed via Terraform/Helm).
|
|
25
|
+
S3 access uses IRSA — no credentials are injected here.
|
|
26
|
+
CPU/memory defaults come from settings; overrides can be passed in
|
|
27
|
+
trigger parameters via config["cpu"] and config["memory"].
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings: Settings) -> None:
|
|
31
|
+
self._work_pool_name = settings.prefect_k8s_work_pool_name
|
|
32
|
+
self._namespace = settings.prefect_k8s_namespace
|
|
33
|
+
self._default_cpu = settings.prefect_k8s_default_cpu
|
|
34
|
+
self._default_memory = settings.prefect_k8s_default_memory
|
|
35
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
36
|
+
if settings.prefect_api_key:
|
|
37
|
+
encoded = base64.b64encode(settings.prefect_api_key.encode()).decode()
|
|
38
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
39
|
+
self._client = httpx.AsyncClient(
|
|
40
|
+
base_url=settings.prefect_api_url.rstrip("/"),
|
|
41
|
+
headers=headers,
|
|
42
|
+
timeout=10.0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def prepare(self) -> None:
|
|
46
|
+
resp = await self._client.get(f"/work_pools/{self._work_pool_name}")
|
|
47
|
+
if resp.is_success:
|
|
48
|
+
return
|
|
49
|
+
payload: dict[str, Any] = {
|
|
50
|
+
"name": self._work_pool_name,
|
|
51
|
+
"type": "kubernetes",
|
|
52
|
+
}
|
|
53
|
+
resp = await self._client.post("/work_pools/", json=payload)
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
|
|
56
|
+
async def register(
|
|
57
|
+
self,
|
|
58
|
+
product: str,
|
|
59
|
+
stage: str,
|
|
60
|
+
image: str,
|
|
61
|
+
entrypoint: str | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
name = f"{product}-{stage}"
|
|
64
|
+
flow_id = await self._get_or_create_flow_id(name)
|
|
65
|
+
payload: dict[str, Any] = {
|
|
66
|
+
"name": name,
|
|
67
|
+
"flow_id": flow_id,
|
|
68
|
+
"entrypoint": entrypoint,
|
|
69
|
+
"work_pool_name": self._work_pool_name,
|
|
70
|
+
"job_variables": {
|
|
71
|
+
"image": image,
|
|
72
|
+
"namespace": self._namespace,
|
|
73
|
+
"cpu": self._default_cpu,
|
|
74
|
+
"memory": self._default_memory,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
resp = await self._client.post("/deployments/", json=payload)
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
dep_data: dict[str, Any] = resp.json()
|
|
80
|
+
return str(dep_data["id"])
|
|
81
|
+
|
|
82
|
+
async def trigger(self, handle: str, parameters: dict[str, Any]) -> str:
|
|
83
|
+
config: dict[str, Any] = parameters.get("config", {})
|
|
84
|
+
job_variables: dict[str, Any] = {}
|
|
85
|
+
if "cpu" in config:
|
|
86
|
+
job_variables["cpu"] = config["cpu"]
|
|
87
|
+
if "memory" in config:
|
|
88
|
+
job_variables["memory"] = config["memory"]
|
|
89
|
+
|
|
90
|
+
payload: dict[str, Any] = {"parameters": parameters}
|
|
91
|
+
if job_variables:
|
|
92
|
+
payload["job_variables"] = job_variables
|
|
93
|
+
|
|
94
|
+
resp = await self._client.post(
|
|
95
|
+
f"/deployments/{handle}/create_flow_run",
|
|
96
|
+
json=payload,
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
run_data: dict[str, Any] = resp.json()
|
|
100
|
+
return str(run_data["id"])
|
|
101
|
+
|
|
102
|
+
async def get_run_status(self, run_id: str) -> OrchestratorRunStatus:
|
|
103
|
+
try:
|
|
104
|
+
resp = await self._client.get(f"/flow_runs/{run_id}")
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
data = resp.json()
|
|
107
|
+
raw_state = data.get("state", {}).get("type", "UNKNOWN")
|
|
108
|
+
canonical = _PREFECT_STATE_MAP.get(raw_state.upper(), "unknown")
|
|
109
|
+
return OrchestratorRunStatus(
|
|
110
|
+
run_id=run_id, status=canonical, raw_state=raw_state
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.warning("k8s_backend_status_failed", run_id=run_id, exc_info=True)
|
|
114
|
+
return OrchestratorRunStatus(run_id=run_id, status="unknown")
|
|
115
|
+
|
|
116
|
+
async def cancel_run(self, run_id: str) -> bool:
|
|
117
|
+
try:
|
|
118
|
+
resp = await self._client.post(
|
|
119
|
+
f"/flow_runs/{run_id}/set_state",
|
|
120
|
+
json={"state": {"type": "CANCELLED"}},
|
|
121
|
+
)
|
|
122
|
+
return resp.is_success
|
|
123
|
+
except Exception:
|
|
124
|
+
logger.warning("k8s_backend_cancel_failed", run_id=run_id, exc_info=True)
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
async def _get_or_create_flow_id(self, flow_name: str) -> str:
|
|
128
|
+
resp = await self._client.post("/flows/", json={"name": flow_name})
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
flow_data: dict[str, Any] = resp.json()
|
|
131
|
+
return str(flow_data["id"])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Registry mapping (orchestrator, compute) pairs to BaseRunnerBackend instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pulse_engine.deployment.backends.base import BaseRunnerBackend
|
|
9
|
+
from pulse_engine.deployment.backends.exceptions import BackendNotAvailableError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pulse_engine.config import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BackendRegistry:
|
|
16
|
+
"""Lazy-initializing registry. Backends are instantiated on first get()."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings: Settings) -> None:
|
|
19
|
+
self._settings = settings
|
|
20
|
+
self._instances: dict[tuple[str, str], BaseRunnerBackend] = {}
|
|
21
|
+
|
|
22
|
+
def get(self, orchestrator: str, compute: str) -> BaseRunnerBackend:
|
|
23
|
+
key = (orchestrator, compute)
|
|
24
|
+
if key not in self._instances:
|
|
25
|
+
self._instances[key] = self._create(orchestrator, compute)
|
|
26
|
+
return self._instances[key]
|
|
27
|
+
|
|
28
|
+
def available_backends(self) -> list[tuple[str, str]]:
|
|
29
|
+
return [
|
|
30
|
+
("prefect", "ecs"),
|
|
31
|
+
("prefect", "kubernetes"),
|
|
32
|
+
("native", "lambda"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def _create(self, orchestrator: str, compute: str) -> BaseRunnerBackend:
|
|
36
|
+
from pulse_engine.deployment.backends.native_lambda import NativeLambdaBackend
|
|
37
|
+
from pulse_engine.deployment.backends.prefect_ecs import PrefectECSBackend
|
|
38
|
+
from pulse_engine.deployment.backends.prefect_k8s import PrefectK8sBackend
|
|
39
|
+
|
|
40
|
+
# IMPORTANT: use lambdas so only the matched backend is instantiated.
|
|
41
|
+
# A plain dict literal would call all three constructors on every _create().
|
|
42
|
+
factories: dict[tuple[str, str], Callable[[], BaseRunnerBackend]] = {
|
|
43
|
+
("prefect", "ecs"): lambda: PrefectECSBackend(self._settings),
|
|
44
|
+
("prefect", "kubernetes"): lambda: PrefectK8sBackend(self._settings),
|
|
45
|
+
("native", "lambda"): lambda: NativeLambdaBackend(self._settings),
|
|
46
|
+
}
|
|
47
|
+
if (orchestrator, compute) not in factories:
|
|
48
|
+
available = [f"{o}×{c}" for o, c in self.available_backends()]
|
|
49
|
+
raise BackendNotAvailableError(orchestrator, compute, available)
|
|
50
|
+
return factories[(orchestrator, compute)]()
|