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

345 lines
11 KiB
Python

"""Shared Apache Airflow integration helpers."""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from pydantic import Field, field_validator
from config.strict_config import StrictConfigModel
from integrations._validation_helpers import report_classify_failure, report_validation_failure
logger = logging.getLogger(__name__)
DEFAULT_AIRFLOW_BASE_URL = "http://localhost:8080/api/v1"
DEFAULT_AIRFLOW_TIMEOUT_SECONDS = 15.0
DEFAULT_AIRFLOW_MAX_RESULTS = 50
class AirflowConfig(StrictConfigModel):
"""Normalized Airflow connection settings."""
base_url: str = DEFAULT_AIRFLOW_BASE_URL
username: str = ""
password: str = ""
auth_token: str = ""
timeout_seconds: float = Field(default=DEFAULT_AIRFLOW_TIMEOUT_SECONDS, gt=0)
verify_ssl: bool = True
max_results: int = Field(default=DEFAULT_AIRFLOW_MAX_RESULTS, gt=0, le=200)
integration_id: str = ""
@field_validator("base_url", mode="before")
@classmethod
def _normalize_base_url(cls, value: Any) -> str:
normalized = str(value or DEFAULT_AIRFLOW_BASE_URL).strip().rstrip("/")
return normalized or DEFAULT_AIRFLOW_BASE_URL
@field_validator("username", "password", "auth_token", mode="before")
@classmethod
def _normalize_str(cls, value: Any) -> str:
return str(value or "").strip()
@property
def headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
@property
def auth(self) -> tuple[str, str] | None:
if self.username and not self.auth_token:
return (self.username, self.password)
return None
@property
def is_configured(self) -> bool:
return bool(self.base_url and (self.auth_token or self.username))
@dataclass(frozen=True)
class AirflowValidationResult:
"""Result of validating an Airflow integration."""
ok: bool
detail: str
def build_airflow_config(raw: dict[str, Any] | None) -> AirflowConfig:
"""Build a normalized Airflow config object from env/store data."""
return AirflowConfig.model_validate(raw or {})
def airflow_config_from_env() -> AirflowConfig | None:
"""Load an Airflow config from env vars."""
username = os.getenv("AIRFLOW_USERNAME", "").strip()
auth_token = os.getenv("AIRFLOW_AUTH_TOKEN", "").strip()
if not username and not auth_token:
return None
return build_airflow_config(
{
"base_url": os.getenv("AIRFLOW_BASE_URL", DEFAULT_AIRFLOW_BASE_URL).strip()
or DEFAULT_AIRFLOW_BASE_URL,
"username": username,
"password": os.getenv("AIRFLOW_PASSWORD", "").strip(),
"auth_token": auth_token,
"timeout_seconds": os.getenv(
"AIRFLOW_TIMEOUT_SECONDS",
str(DEFAULT_AIRFLOW_TIMEOUT_SECONDS),
),
"verify_ssl": os.getenv("AIRFLOW_VERIFY_SSL", "true").strip().lower()
in ("true", "1", "yes"),
"max_results": os.getenv(
"AIRFLOW_MAX_RESULTS", str(DEFAULT_AIRFLOW_MAX_RESULTS)
).strip(),
}
)
def _request_json(
config: AirflowConfig,
method: str,
path: str,
*,
params: list[tuple[str, str | int | float | bool | None]] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
"""Make an Airflow API request and return parsed JSON."""
url = f"{config.base_url}{path}"
response = httpx.request(
method,
url,
headers=config.headers,
auth=config.auth,
params=params,
json=json,
timeout=config.timeout_seconds,
verify=config.verify_ssl,
)
response.raise_for_status()
return response.json()
def validate_airflow_config(config: AirflowConfig) -> AirflowValidationResult:
"""Validate Airflow connectivity with a lightweight DAG query."""
if not config.is_configured:
return AirflowValidationResult(
ok=False,
detail="Airflow auth is required. Provide AIRFLOW_AUTH_TOKEN or AIRFLOW_USERNAME/AIRFLOW_PASSWORD.",
)
try:
payload = validate_airflow_connection(config=config)
dags = payload.get("dags", []) if isinstance(payload, dict) else []
total_entries = (
payload.get("total_entries", len(dags)) if isinstance(payload, dict) else len(dags)
)
return AirflowValidationResult(
ok=True,
detail=f"Airflow connectivity successful. Reachable DAG API; total visible DAGs: {total_entries}.",
)
except httpx.HTTPStatusError as err:
detail = err.response.text.strip() or str(err)
return AirflowValidationResult(ok=False, detail=f"Airflow validation failed: {detail}")
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="airflow",
method="validate_airflow_config",
)
return AirflowValidationResult(ok=False, detail=f"Airflow validation failed: {err}")
def validate_airflow_connection(
*,
config: AirflowConfig,
) -> dict[str, Any]:
"""Validate Airflow connection."""
payload = _request_json(
config,
"GET",
"/dags",
params=[("limit", 1)],
)
return payload if isinstance(payload, dict) else {}
def get_airflow_dag_runs(
*,
config: AirflowConfig,
dag_id: str,
limit: int = 10,
state: str | None = None,
order_by: str = "-start_date",
) -> list[dict[str, Any]]:
"""Fetch DAG runs for a given DAG."""
effective_limit = min(limit, config.max_results)
encoded_dag_id = quote(dag_id, safe="")
params: list[tuple[str, str | int | float | bool | None]] = [
("limit", effective_limit),
("order_by", order_by),
]
if state:
params.append(("state", state))
payload = _request_json(
config,
"GET",
f"/dags/{encoded_dag_id}/dagRuns",
params=params,
)
if not isinstance(payload, dict):
return []
dag_runs = payload.get("dag_runs", [])
return dag_runs if isinstance(dag_runs, list) else []
def get_airflow_task_instances(
*,
config: AirflowConfig,
dag_id: str,
dag_run_id: str,
) -> list[dict[str, Any]]:
"""Fetch task instances for a given DAG run."""
encoded_dag_id = quote(dag_id, safe="")
encoded_dag_run_id = quote(dag_run_id, safe="")
payload = _request_json(
config,
"GET",
f"/dags/{encoded_dag_id}/dagRuns/{encoded_dag_run_id}/taskInstances",
)
if not isinstance(payload, dict):
return []
task_instances = payload.get("task_instances", [])
return task_instances if isinstance(task_instances, list) else []
def _to_failure_evidence(
*,
dag_id: str,
dag_run: dict[str, Any],
task_instance: dict[str, Any],
) -> dict[str, Any]:
"""Normalize a failed or retrying task instance into investigation-friendly evidence."""
start_date = task_instance.get("start_date") or dag_run.get("start_date")
end_date = task_instance.get("end_date") or dag_run.get("end_date")
state = task_instance.get("state", "")
try_number = task_instance.get("try_number")
max_tries = task_instance.get("max_tries")
duration = task_instance.get("duration")
return {
"source": "airflow",
"dag_id": dag_id,
"dag_run_id": dag_run.get("dag_run_id", ""),
"logical_date": dag_run.get("logical_date", ""),
"run_type": dag_run.get("run_type", ""),
"dag_run_state": dag_run.get("state", ""),
"task_id": task_instance.get("task_id", ""),
"task_state": state,
"operator": task_instance.get("operator", ""),
"try_number": try_number,
"max_tries": max_tries,
"queued_dttm": task_instance.get("queued_dttm", ""),
"start_date": start_date,
"end_date": end_date,
"duration": duration,
"hostname": task_instance.get("hostname", ""),
"unixname": task_instance.get("unixname", ""),
"pool": task_instance.get("pool", ""),
"queue": task_instance.get("queue", ""),
"priority_weight": task_instance.get("priority_weight"),
}
def get_recent_airflow_failures(
*,
config: AirflowConfig,
dag_id: str,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Fetch recent failed or retrying task evidence for a DAG.
Strategy:
- fetch recent DAG runs
- fetch task instances for each run
- return failed/up_for_retry/upstream_failed task evidence
"""
dag_runs = get_airflow_dag_runs(
config=config,
dag_id=dag_id,
limit=limit,
)
evidence: list[dict[str, Any]] = []
interesting_states = {"failed", "up_for_retry", "upstream_failed"}
for dag_run in dag_runs:
dag_run_id = str(dag_run.get("dag_run_id", "")).strip()
if not dag_run_id:
continue
try:
task_instances = get_airflow_task_instances(
config=config,
dag_id=dag_id,
dag_run_id=dag_run_id,
)
except Exception as err:
report_validation_failure(
err,
logger=logger,
integration="airflow",
method="get_recent_airflow_failures.task_instances",
extras={"dag_id": dag_id, "dag_run_id": dag_run_id},
)
continue
for task_instance in task_instances:
state = str(task_instance.get("state", "")).strip().lower()
if state not in interesting_states:
continue
evidence.append(
_to_failure_evidence(
dag_id=dag_id,
dag_run=dag_run,
task_instance=task_instance,
)
)
return evidence
def classify(
credentials: dict[str, Any], record_id: str
) -> tuple[AirflowConfig | None, str | None]:
try:
cfg = build_airflow_config(
{
"base_url": credentials.get("base_url", DEFAULT_AIRFLOW_BASE_URL),
"username": credentials.get("username", ""),
"password": credentials.get("password", ""),
"auth_token": credentials.get("auth_token", ""),
"timeout_seconds": credentials.get("timeout_seconds", 15.0),
"verify_ssl": credentials.get("verify_ssl", True),
"max_results": credentials.get("max_results", 50),
"integration_id": record_id,
}
)
except Exception as exc:
report_classify_failure(exc, logger=logger, integration="airflow", record_id=record_id)
return None, None
if cfg.is_configured:
return cfg, "airflow"
return None, None