Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin

from aiohttp import ClientSession
from google.api_core.client_options import ClientOptions
from google.api_core.gapic_v1.method import DEFAULT, _MethodDefault
from google.auth.transport.requests import AuthorizedSession
from google.auth.transport.requests import AuthorizedSession, Request
from google.cloud.orchestration.airflow.service_v1 import (
EnvironmentsAsyncClient,
EnvironmentsClient,
Expand Down Expand Up @@ -472,6 +473,38 @@ def trigger_dag_run(

return response.json()

def get_dag_runs(
self,
composer_airflow_uri: str,
composer_dag_id: str,
timeout: float | None = None,
) -> dict:
"""
Get the list of dag runs for provided DAG.

:param composer_airflow_uri: The URI of the Apache Airflow Web UI hosted within Composer environment.
:param composer_dag_id: The ID of DAG.
:param timeout: The timeout for this request.
"""
response = self.make_composer_airflow_api_request(
method="GET",
airflow_uri=composer_airflow_uri,
path=f"/api/v1/dags/{composer_dag_id}/dagRuns",
timeout=timeout,
)

if response.status_code != 200:
self.log.error(
"Failed to get DAG runs for dag_id=%s from %s (status=%s): %s",
composer_dag_id,
composer_airflow_uri,
response.status_code,
response.text,
)
response.raise_for_status()

return response.json()


class CloudComposerAsyncHook(GoogleBaseAsyncHook):
"""Hook for Google Cloud Composer async APIs."""
Expand All @@ -489,6 +522,42 @@ async def get_environment_client(self) -> EnvironmentsAsyncClient:
client_options=self.client_options,
)

async def make_composer_airflow_api_request(
self,
method: str,
airflow_uri: str,
path: str,
data: Any | None = None,
timeout: float | None = None,
):
"""
Make a request to Cloud Composer environment's web server.

:param method: The request method to use ('GET', 'OPTIONS', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE').
:param airflow_uri: The URI of the Apache Airflow Web UI hosted within this environment.
:param path: The path to send the request.
:param data: Dictionary, list of tuples, bytes, or file-like object to send in the body of the request.
:param timeout: The timeout for this request.
"""
sync_hook = await self.get_sync_hook()
credentials = sync_hook.get_credentials()

if not credentials.valid:
credentials.refresh(Request())

async with ClientSession() as session:
async with session.request(
method=method,
url=urljoin(airflow_uri, path),
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {credentials.token}",
},
timeout=timeout,
) as response:
return await response.json(), response.status

def get_environment_name(self, project_id, region, environment_id):
return f"projects/{project_id}/locations/{region}/environments/{environment_id}"

Expand Down Expand Up @@ -594,6 +663,35 @@ async def update_environment(
metadata=metadata,
)

@GoogleBaseHook.fallback_to_default_project_id
async def get_environment(
self,
project_id: str,
region: str,
environment_id: str,
retry: AsyncRetry | _MethodDefault = DEFAULT,
timeout: float | None = None,
metadata: Sequence[tuple[str, str]] = (),
) -> Environment:
"""
Get an existing environment.

:param project_id: Required. The ID of the Google Cloud project that the service belongs to.
:param region: Required. The ID of the Google Cloud region that the service belongs to.
:param environment_id: Required. The ID of the Google Cloud environment that the service belongs to.
:param retry: Designation of what errors, if any, should be retried.
:param timeout: The timeout for this request.
:param metadata: Strings which should be sent along with the request as metadata.
"""
client = await self.get_environment_client()

return await client.get_environment(
request={"name": self.get_environment_name(project_id, region, environment_id)},
retry=retry,
timeout=timeout,
metadata=metadata,
)

@GoogleBaseHook.fallback_to_default_project_id
async def execute_airflow_command(
self,
Expand Down Expand Up @@ -719,3 +817,35 @@ async def wait_command_execution_result(

self.log.info("Sleeping for %s seconds.", poll_interval)
await asyncio.sleep(poll_interval)

async def get_dag_runs(
self,
composer_airflow_uri: str,
composer_dag_id: str,
timeout: float | None = None,
) -> dict:
"""
Get the list of dag runs for provided DAG.

:param composer_airflow_uri: The URI of the Apache Airflow Web UI hosted within Composer environment.
:param composer_dag_id: The ID of DAG.
:param timeout: The timeout for this request.
"""
response_body, response_status_code = await self.make_composer_airflow_api_request(
method="GET",
airflow_uri=composer_airflow_uri,
path=f"/api/v1/dags/{composer_dag_id}/dagRuns",
timeout=timeout,
)

if response_status_code != 200:
self.log.error(
"Failed to get DAG runs for dag_id=%s from %s (status=%s): %s",
composer_dag_id,
composer_airflow_uri,
response_status_code,
response_body["title"],
)
raise AirflowException(response_body["title"])

return response_body
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import TYPE_CHECKING

from dateutil import parser
from google.api_core.exceptions import NotFound
from google.cloud.orchestration.airflow.service_v1.types import Environment, ExecuteAirflowCommandResponse

from airflow.configuration import conf
Expand Down Expand Up @@ -97,6 +98,7 @@ def __init__(
impersonation_chain: str | Sequence[str] | None = None,
deferrable: bool = conf.getboolean("operators", "default_deferrable", fallback=False),
poll_interval: int = 10,
use_rest_api: bool = False,
**kwargs,
) -> None:
super().__init__(**kwargs)
Expand All @@ -111,6 +113,7 @@ def __init__(
self.impersonation_chain = impersonation_chain
self.deferrable = deferrable
self.poll_interval = poll_interval
self.use_rest_api = use_rest_api

if self.composer_dag_run_id and self.execution_range:
self.log.warning(
Expand Down Expand Up @@ -161,26 +164,51 @@ def poke(self, context: Context) -> bool:

def _pull_dag_runs(self) -> list[dict]:
"""Pull the list of dag runs."""
cmd_parameters = (
["-d", self.composer_dag_id, "-o", "json"]
if self._composer_airflow_version < 3
else [self.composer_dag_id, "-o", "json"]
)
dag_runs_cmd = self.hook.execute_airflow_command(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
command="dags",
subcommand="list-runs",
parameters=cmd_parameters,
)
cmd_result = self.hook.wait_command_execution_result(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
execution_cmd_info=ExecuteAirflowCommandResponse.to_dict(dag_runs_cmd),
)
dag_runs = json.loads(cmd_result["output"][0]["content"])
if self.use_rest_api:
try:
environment = self.hook.get_environment(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
timeout=self.timeout,
)
except NotFound as not_found_err:
self.log.info("The Composer environment %s does not exist.", self.environment_id)
raise AirflowException(not_found_err)
composer_airflow_uri = environment.config.airflow_uri

self.log.info(
"Pulling the DAG %s runs from the %s environment...",
self.composer_dag_id,
self.environment_id,
)
dag_runs_response = self.hook.get_dag_runs(
composer_airflow_uri=composer_airflow_uri,
composer_dag_id=self.composer_dag_id,
timeout=self.timeout,
)
dag_runs = dag_runs_response["dag_runs"]
else:
cmd_parameters = (
["-d", self.composer_dag_id, "-o", "json"]
if self._composer_airflow_version < 3
else [self.composer_dag_id, "-o", "json"]
)
dag_runs_cmd = self.hook.execute_airflow_command(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
command="dags",
subcommand="list-runs",
parameters=cmd_parameters,
)
cmd_result = self.hook.wait_command_execution_result(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
execution_cmd_info=ExecuteAirflowCommandResponse.to_dict(dag_runs_cmd),
)
dag_runs = json.loads(cmd_result["output"][0]["content"])
return dag_runs

def _check_dag_runs_states(
Expand Down Expand Up @@ -213,7 +241,10 @@ def _get_composer_airflow_version(self) -> int:

def _check_composer_dag_run_id_states(self, dag_runs: list[dict]) -> bool:
for dag_run in dag_runs:
if dag_run["run_id"] == self.composer_dag_run_id and dag_run["state"] in self.allowed_states:
if (
dag_run["dag_run_id" if self.use_rest_api else "run_id"] == self.composer_dag_run_id
and dag_run["state"] in self.allowed_states
):
return True
return False

Expand All @@ -236,6 +267,7 @@ def execute(self, context: Context) -> None:
impersonation_chain=self.impersonation_chain,
poll_interval=self.poll_interval,
composer_airflow_version=self._composer_airflow_version,
use_rest_api=self.use_rest_api,
),
method_name=GOOGLE_DEFAULT_DEFERRABLE_METHOD_NAME,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from typing import Any

from dateutil import parser
from google.api_core.exceptions import NotFound
from google.cloud.orchestration.airflow.service_v1.types import ExecuteAirflowCommandResponse

from airflow.exceptions import AirflowException
Expand Down Expand Up @@ -188,6 +189,7 @@ def __init__(
impersonation_chain: str | Sequence[str] | None = None,
poll_interval: int = 10,
composer_airflow_version: int = 2,
use_rest_api: bool = False,
):
super().__init__()
self.project_id = project_id
Expand All @@ -202,6 +204,7 @@ def __init__(
self.impersonation_chain = impersonation_chain
self.poll_interval = poll_interval
self.composer_airflow_version = composer_airflow_version
self.use_rest_api = use_rest_api

def serialize(self) -> tuple[str, dict[str, Any]]:
return (
Expand All @@ -219,31 +222,55 @@ def serialize(self) -> tuple[str, dict[str, Any]]:
"impersonation_chain": self.impersonation_chain,
"poll_interval": self.poll_interval,
"composer_airflow_version": self.composer_airflow_version,
"use_rest_api": self.use_rest_api,
},
)

async def _pull_dag_runs(self) -> list[dict]:
"""Pull the list of dag runs."""
cmd_parameters = (
["-d", self.composer_dag_id, "-o", "json"]
if self.composer_airflow_version < 3
else [self.composer_dag_id, "-o", "json"]
)
dag_runs_cmd = await self.gcp_hook.execute_airflow_command(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
command="dags",
subcommand="list-runs",
parameters=cmd_parameters,
)
cmd_result = await self.gcp_hook.wait_command_execution_result(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
execution_cmd_info=ExecuteAirflowCommandResponse.to_dict(dag_runs_cmd),
)
dag_runs = json.loads(cmd_result["output"][0]["content"])
if self.use_rest_api:
try:
environment = await self.gcp_hook.get_environment(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
)
except NotFound as not_found_err:
self.log.info("The Composer environment %s does not exist.", self.environment_id)
raise AirflowException(not_found_err)
composer_airflow_uri = environment.config.airflow_uri

self.log.info(
"Pulling the DAG %s runs from the %s environment...",
self.composer_dag_id,
self.environment_id,
)
dag_runs_response = await self.gcp_hook.get_dag_runs(
composer_airflow_uri=composer_airflow_uri,
composer_dag_id=self.composer_dag_id,
)
dag_runs = dag_runs_response["dag_runs"]
else:
cmd_parameters = (
["-d", self.composer_dag_id, "-o", "json"]
if self.composer_airflow_version < 3
else [self.composer_dag_id, "-o", "json"]
)
dag_runs_cmd = await self.gcp_hook.execute_airflow_command(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
command="dags",
subcommand="list-runs",
parameters=cmd_parameters,
)
cmd_result = await self.gcp_hook.wait_command_execution_result(
project_id=self.project_id,
region=self.region,
environment_id=self.environment_id,
execution_cmd_info=ExecuteAirflowCommandResponse.to_dict(dag_runs_cmd),
)
dag_runs = json.loads(cmd_result["output"][0]["content"])
return dag_runs

def _check_dag_runs_states(
Expand Down Expand Up @@ -271,7 +298,10 @@ def _get_async_hook(self) -> CloudComposerAsyncHook:

def _check_composer_dag_run_id_states(self, dag_runs: list[dict]) -> bool:
for dag_run in dag_runs:
if dag_run["run_id"] == self.composer_dag_run_id and dag_run["state"] in self.allowed_states:
if (
dag_run["dag_run_id" if self.use_rest_api else "run_id"] == self.composer_dag_run_id
and dag_run["state"] in self.allowed_states
):
return True
return False

Expand Down
Loading
Loading