Files
tracer-cloud--opensre/tests/e2e/kubernetes/trigger_lambda/handler.py
T
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

310 lines
10 KiB
Python

"""
Lambda handler for K8s ETL pipeline trigger.
Endpoints:
POST /trigger - Run pipeline with valid data (happy path)
POST /trigger?inject_error=true - Run pipeline with bad data (schema error)
Flow:
1. Upload test data to S3 landing bucket
2. Submit etl-extract K8s job, wait for completion
3. Submit etl-transform (or etl-transform-error) K8s job
4. Return pipeline_run_id immediately (Datadog/Slack alert follows async)
"""
from __future__ import annotations
import base64
import contextlib
import json
import os
import ssl
import tempfile
import time
import urllib.request
import uuid
from datetime import UTC, datetime
import boto3
from botocore.auth import SigV4QueryAuth
from botocore.awsrequest import AWSRequest
# Environment variables (set at deploy time)
CLUSTER_NAME = os.environ["CLUSTER_NAME"]
CLUSTER_ENDPOINT = os.environ["CLUSTER_ENDPOINT"]
CLUSTER_CA_DATA = os.environ["CLUSTER_CA_DATA"]
LANDING_BUCKET = os.environ["LANDING_BUCKET"]
PROCESSED_BUCKET = os.environ["PROCESSED_BUCKET"]
NAMESPACE = os.environ.get("NAMESPACE", "tracer-test")
REGION = os.environ.get("AWS_REGION", "us-east-1")
IMAGE_URI = os.environ["IMAGE_URI"]
SERVICE_ACCOUNT = os.environ.get("SERVICE_ACCOUNT", "etl-pipeline-sa")
VALID_PAYLOAD = {
"data": [
{
"customer_id": "CUST-001",
"order_id": "ORD-001",
"amount": 99.99,
"timestamp": "2026-01-01",
},
{
"customer_id": "CUST-002",
"order_id": "ORD-002",
"amount": 149.50,
"timestamp": "2026-01-01",
},
]
}
INVALID_PAYLOAD = {
"data": [
{"order_id": "ORD-001", "amount": 99.99, "timestamp": "2026-01-01"},
]
}
# ---------------------------------------------------------------------------
# EKS auth + K8s API
# ---------------------------------------------------------------------------
def _get_eks_token() -> str:
"""Generate an EKS bearer token using botocore presigned STS request."""
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()
request = AWSRequest(
method="GET",
url="https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
headers={"x-k8s-aws-id": CLUSTER_NAME},
)
SigV4QueryAuth(credentials, "sts", REGION, expires=60).add_auth(request)
return "k8s-aws-v1." + base64.urlsafe_b64encode(request.url.encode()).rstrip(b"=").decode()
def _k8s_request(method: str, path: str, body: dict | None = None) -> dict:
"""Make an authenticated request to the K8s API."""
token = _get_eks_token()
ca_bytes = base64.b64decode(CLUSTER_CA_DATA)
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as f:
f.write(ca_bytes)
ca_file = f.name
try:
ctx = ssl.create_default_context(cafile=ca_file)
url = f"{CLUSTER_ENDPOINT}{path}"
data = json.dumps(body).encode() if body else None
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"K8s API error {e.code}: {e.read().decode()}") from e
finally:
os.unlink(ca_file)
def _delete_job(job_name: str) -> None:
"""Delete a K8s job if it exists."""
with contextlib.suppress(RuntimeError):
_k8s_request(
"DELETE",
f"/apis/batch/v1/namespaces/{NAMESPACE}/jobs/{job_name}",
body={"propagationPolicy": "Background"},
)
def _ensure_namespace_and_service_account() -> None:
"""Ensure namespace and service account exist for job submissions."""
ns_path = f"/api/v1/namespaces/{NAMESPACE}"
sa_path = f"/api/v1/namespaces/{NAMESPACE}/serviceaccounts/{SERVICE_ACCOUNT}"
try:
_k8s_request("GET", ns_path)
except RuntimeError as err:
if '"code":404' in str(err):
_k8s_request(
"POST",
"/api/v1/namespaces",
{"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": NAMESPACE}},
)
else:
raise
try:
_k8s_request("GET", sa_path)
except RuntimeError as err:
if '"code":404' in str(err):
_k8s_request(
"POST",
f"/api/v1/namespaces/{NAMESPACE}/serviceaccounts",
{
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": SERVICE_ACCOUNT},
},
)
else:
raise
def _create_job(job_name: str, stage: str, env_vars: dict[str, str]) -> None:
"""Submit a K8s Job for the given pipeline stage."""
env = [{"name": k, "value": v} for k, v in env_vars.items()]
env.append({"name": "PIPELINE_STAGE", "value": stage})
creds = boto3.Session().get_credentials().get_frozen_credentials()
env.extend(
[
{"name": "AWS_ACCESS_KEY_ID", "value": creds.access_key},
{"name": "AWS_SECRET_ACCESS_KEY", "value": creds.secret_key},
{"name": "AWS_SESSION_TOKEN", "value": creds.token or ""},
{"name": "AWS_REGION", "value": REGION},
{"name": "AWS_DEFAULT_REGION", "value": REGION},
]
)
manifest = {
"apiVersion": "batch/v1",
"kind": "Job",
"metadata": {
"name": job_name,
"namespace": NAMESPACE,
"labels": {"app": "etl-pipeline", "stage": stage, "tracer": "test-case"},
},
"spec": {
"backoffLimit": 0,
"template": {
"metadata": {"labels": {"app": "etl-pipeline", "stage": stage}},
"spec": {
"serviceAccountName": SERVICE_ACCOUNT,
"restartPolicy": "Never",
"containers": [
{
"name": stage,
"image": IMAGE_URI,
"imagePullPolicy": "Always",
"env": env,
}
],
},
},
},
}
_k8s_request("POST", f"/apis/batch/v1/namespaces/{NAMESPACE}/jobs", manifest)
def _wait_for_job(job_name: str, timeout: int = 90) -> str:
"""Poll until job completes or fails. Returns 'complete', 'failed', or 'timeout'."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
resp = _k8s_request("GET", f"/apis/batch/v1/namespaces/{NAMESPACE}/jobs/{job_name}")
conditions = resp.get("status", {}).get("conditions", [])
for cond in conditions:
if cond.get("type") == "Complete" and cond.get("status") == "True":
return "complete"
if cond.get("type") == "Failed" and cond.get("status") == "True":
return "failed"
time.sleep(2)
return "timeout"
# ---------------------------------------------------------------------------
# S3 data upload
# ---------------------------------------------------------------------------
def _upload_data(payload: dict) -> tuple[str, str]:
"""Upload test data to S3. Returns (s3_key, correlation_id)."""
s3 = boto3.client("s3")
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
key = f"ingested/{ts}/data.json"
correlation_id = f"lambda-{ts}"
s3.put_object(
Bucket=LANDING_BUCKET,
Key=key,
Body=json.dumps(payload),
ContentType="application/json",
Metadata={"correlation_id": correlation_id},
)
return key, correlation_id
# ---------------------------------------------------------------------------
# Lambda handler
# ---------------------------------------------------------------------------
def lambda_handler(event: dict, context) -> dict:
inject_error = (event.get("queryStringParameters", {}) or {}).get(
"inject_error", ""
).lower() == "true"
run_id = f"api-{uuid.uuid4().hex[:8]}"
payload = INVALID_PAYLOAD if inject_error else VALID_PAYLOAD
try:
# Upload data to S3
s3_key, correlation_id = _upload_data(payload)
# Ensure base namespace primitives exist
_ensure_namespace_and_service_account()
# Clean up stale jobs
for job in ("etl-extract", "etl-transform", "etl-transform-error"):
_delete_job(job)
time.sleep(2)
# Common env vars for all stages
base_env = {
"LANDING_BUCKET": LANDING_BUCKET,
"PROCESSED_BUCKET": PROCESSED_BUCKET,
"PIPELINE_RUN_ID": run_id,
}
# Submit extract job, wait for it to complete
_create_job("etl-extract", "extract", {**base_env, "S3_KEY": s3_key})
extract_status = _wait_for_job("etl-extract", timeout=90)
if extract_status != "complete":
return _response(
500,
{
"error": f"Extract job {extract_status}",
"pipeline_run_id": run_id,
},
)
# Submit transform job (will fail on bad data -- async, let Datadog catch it)
transform_job = "etl-transform-error" if inject_error else "etl-transform"
_create_job(transform_job, "transform", base_env)
return _response(
200,
{
"status": "submitted",
"pipeline_run_id": run_id,
"inject_error": inject_error,
"s3_key": s3_key,
"correlation_id": correlation_id,
"transform_job": transform_job,
},
)
except Exception as e:
return _response(500, {"error": str(e), "pipeline_run_id": run_id})
def _response(status_code: int, body: dict) -> dict:
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}