Skip to content
Closed
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
245 changes: 245 additions & 0 deletions airflow/providers/google/cloud/hooks/cloud_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""This module contains a Google Cloud Run Hook."""
from __future__ import annotations

import json
import time
from typing import Any, Callable, Dict, List, Sequence, Union, cast

from google.api_core.client_options import ClientOptions
from googleapiclient.discovery import build

from airflow.providers.google.common.hooks.base_google import GoogleBaseHook
from airflow.utils.log.logging_mixin import LoggingMixin


class CloudRunJobSteps:
"""
Helper class with Cloud Run job status.
Reference: https://cloud.google.com/run/docs/reference/rest/v1/namespaces.jobs#JobStatus

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I highly recommend using the V2 of the Cloud run API, which is much easier to work with (clearer attributes, less Kubernetes-like more like any other GCP API) : https://cloud.google.com/run/docs/reference/rest/v2/projects.locations.jobs#Job

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree, but I cannot get the official client to build.

(Putting here the linked issue again #29643)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the V2 API doesn't mean using the Cloud Run Python client library.
Here, it seems like you are calling the API via HTTP directly?
You could hit the /v2/ endpoint instead of the /v1/, it's much easier to use.

This is optional, since you have it working on v1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VinceLegendre can you clarify what is the blocker to use V2?
@lwyszomi is working on updating the google libs in #30067

"""

EXEC_STEP_COMPLETED = "Completed"
EXEC_STEP_RESOURCES_AVAILABLE = "ResourcesAvailable"
EXEC_STEP_STARTED = "Started"
AWAITING_STEPS = {EXEC_STEP_STARTED, EXEC_STEP_RESOURCES_AVAILABLE}
ALL_STEPS = [EXEC_STEP_COMPLETED, EXEC_STEP_RESOURCES_AVAILABLE, EXEC_STEP_STARTED]


class _CloudRunJobExecutionController(LoggingMixin):
"""
Interface for communication with Google API.

:param cloud_run: Discovery resource
:param project_id: The Google Cloud Project ID.
:param execution_id: ID of a Cloud Run Job execution.
:param poll_sleep: The status refresh rate for pending operations.
:param num_retries: Maximum number of retries in case of connection problems.
:param wait_until_finished: If True, wait for the end of pipeline execution
before exiting. If False, it only submits job and check once if
the job is not in terminal state.
"""

def __init__(
self,
cloud_run: Any,
project_id: str,
region: str,
wait_until_finished: bool,
execution_id: str | None = None,
poll_sleep: float = 10.0,
num_retries: int = 0,
job_exec_cold_start: float = 10.0,
) -> None:

super().__init__()
self._cloud_run = cloud_run
self._project_id = project_id
self._region = region
self._exec_id = execution_id
self._poll_sleep = poll_sleep
self._num_retries = num_retries
self._execution: dict | None = None
self._execution_state: str | None = None
self._job_exec_cold_start = job_exec_cold_start
self._wait_until_finished = wait_until_finished

def _fetch_execution_by_id(self):
"""
Helper method to fetch the execution with the specified execution ID.

:return: the Cloud Run job execution
"""
self.log.debug("Fetching information for job execution %s", self._exec_id)
return (
self._cloud_run.namespaces()
.executions()
.get(name=f"namespaces/{self._project_id}/executions/{self._exec_id}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no way to use the official Python client library?
I am not a Python developer, but I would assume it can be added to any Python project, no? https://cloud.google.com/python/docs/reference/run/latest

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot install the official client without breaking dependencies : #29643

Would appreciate any help to solve this issue 🙏 🙂

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basically requires to upgrade all Google "properties" to the latest relesed versions of their clients. See for example #27292 CC: @lwyszomi @bhirsz @VladaZakharova @mhenc @kosteev. I think there is no other way around it.

We know what needs to be done, it just needs to be done :).

@VinceLegendre VinceLegendre Feb 21, 2023

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @potiuk 🙂

So far my lead is modifying airflow/providers/google/provider.yaml. I understand that precommits and/or Github actions will take care of generating the rest.

My concerns are more about all the potential changes brought by Google clients upgrades. Could we have insights from the Google team to anticipate the amount of work to be done and traps to avoid?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot install the official client without breaking dependencies : #29643

#29643 (comment)

@steren steren Feb 22, 2023

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Python Google Cloud client library of a given GCP service is entirely automatically generated from this service's API. APIs are versioned and should not introduce backward incompatible changes without a proper deprecation process.

Client libraries can evolve as the language it's written in itself evolve or the way they are generated changes, but this would result in a new major version of the library.

@potiuk potiuk Feb 22, 2023

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI. There were backwards incompatible changes in 2.* versions of pretty much all python clients for google services @steren . There was a major change in how the libraries were generated, that introduced incompatible changes vs. previous versions of the librarires). Those changes often required to significantly change the code that uses the libraries (and we still have quite a few of pre 2.0 libraries of google used in google provider.

Example for bigquery client: https://cloud.google.com/python/docs/reference/bigquerystorage/latest/upgrading (but it's been the same for pretty much all other libraries - there are some ~ 60 of them, many used in Airflow from pre-2 version times).

The general comment for those libraries is this:

The 2.0 release of the <CLIENT> client is a significant upgrade based on a next-gen code generator, and includes substantial interface changes. Existing code written for earlier versions of this library will likely require updates to use this version. This document describes the changes that have been made, and what you need to do to update your usage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So - this is not API change, it's the generator change that made backwards-incompatible changes in the libraries we've been using.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, a breaking change due to the client library generation process would be reflected as a new major version of the client library.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elakdal no reason not to use the v2 API if recent packages bumps solved the issue 🙂

Currently away from work, I can try to migrate to using the official Cloud Run client when I get back.

If this is urgent matter, please feel free to initiate new developments and I'll be happy to contribute

.execute(num_retries=self._num_retries)
)

def _check_execution_state(self) -> bool:
"""
Helper method to check the state of job execution
if execution failed raise exception

:return: True if execution is done.
:raise: Exception
"""
logs_uri = (
f"https://console.cloud.google.com/run/jobs/executions"
f"/details/{self._region}/{self._exec_id}/logs?project={self._project_id}"
)
job_exec_exception = Exception(
f"An error occurred when starting Google Cloud Run job execution {self._exec_id}."
f"\nSee details at {logs_uri} "
)
if not isinstance(self._execution, dict):
raise job_exec_exception
execution = cast(Dict[str, Union[str, dict, int]], self._execution)
if "status" not in execution.keys():
raise job_exec_exception
status = cast(Dict[str, Union[int, str, List[dict]]], execution["status"])
if "conditions" not in status.keys():
raise job_exec_exception
conditions = cast(List[Dict[str, str]], status["conditions"])
steps = []
for c in conditions:
step = c["type"]
verdict = c["status"]
if step not in CloudRunJobSteps.ALL_STEPS:
raise Exception(
f"Unknown state {step} found for Google Cloud Run job execution {self._exec_id} "
f"See details at {logs_uri}."
)
if verdict == "False":
# This verdict means that a step failed,
# therefore the execution has failed
raise Exception(
f"Cloud Run Job execution {self._exec_id} has failed. See details at {logs_uri} "
)
if verdict == "Unknown":
# This verdict means that the step is not completed yet,
# therefore the execution cannot be finished yet
return False
if verdict == "True":
steps.append(step)
return CloudRunJobSteps.EXEC_STEP_COMPLETED in steps

def wait_for_done(self) -> None:
"""Helper method to wait for result of job execution."""
# Wait a few seconds for the job execution status to be available
# otherwise it fails
time.sleep(self._job_exec_cold_start)
self._execution = self._fetch_execution_by_id()
if self._wait_until_finished:
self.log.info("Starting to poll status for Google Cloud Run job execution %s", self._exec_id)
self.log.info(json.dumps(self._execution))
while not self._check_execution_state():
self.log.info("Waiting for execution completion. Sleeping %d seconds", self._poll_sleep)
time.sleep(self._poll_sleep)
self._execution = self._fetch_execution_by_id()
else:
# Check execution state only once to ensure the job has started
# Otherwise, _check_execution_state method would have raised an error
self._check_execution_state()
return


class CloudRunJobHook(GoogleBaseHook):
"""
Hook for Google Cloud Run.

All the methods in the hook where project_id is used must be called with
keyword arguments rather than positional.
:param gcp_conn_id: The Airflow connection used for GCP credentials.
:param wait_until_finished: If True, wait for the end of pipeline
execution before exiting. If False, it only submits job
and check once is job not in terminal state.
"""

DEFAULT_CLOUD_RUN_REGION = "us-central1"

def __init__(
self,
gcp_conn_id: str = "google_cloud_default",
region: str = DEFAULT_CLOUD_RUN_REGION,
delegate_to: str | None = None,
impersonation_chain: str | Sequence[str] | None = None,
delete_timeout: int | None = 5 * 60,
wait_until_finished: bool = False,
) -> None:
self.region = region
self.delete_timeout = delete_timeout
self.wait_until_finished = wait_until_finished
self.job_id: str | None = None
super().__init__(
gcp_conn_id=gcp_conn_id, delegate_to=delegate_to, impersonation_chain=impersonation_chain
)

def get_conn(self) -> build:
"""Returns a Google Cloud Run service object."""
http_authorized = self._authorize()

# Use a regional endpoint since job execution is not possible from the global endpoint
client_options = ClientOptions(api_endpoint=f"https://{self.region}-run.googleapis.com")
return build("run", "v1", client_options=client_options, http=http_authorized, cache_discovery=False)

@GoogleBaseHook.fallback_to_default_project_id
def execute_cloud_run_job(
self, project_id: str, job_name: str, on_new_execution_callback: Callable[[str], None] | None = None
) -> dict:
"""
Executes a Cloud Run job.

:param project_id: Optional, the Google Cloud project ID in which
to start a job. If set to None or missing, the default
project_id from the Google Cloud connection is used.
:param job_name: The name of the job
:param on_new_execution_callback: A callback that is called
when a job execution is detected.
"""
service = self.get_conn()

request = service.namespaces().jobs().run(name=f"namespaces/{project_id}/jobs/{job_name}")

execution = request.execute(num_retries=self.num_retries)
exec_id = execution["metadata"]["name"]
self.log.info("Requested job execution with response:\n%s", json.dumps(execution))
if on_new_execution_callback:
on_new_execution_callback(execution)

execution_controller = _CloudRunJobExecutionController(
cloud_run=service,
project_id=project_id,
region=self.region,
execution_id=exec_id,
num_retries=self.num_retries,
wait_until_finished=self.wait_until_finished,
)
self.log.info(
"Job execution details available at "
"https://console.cloud.google.com/run/jobs/executions/details/%s/%s?project=%s ",
self.region,
exec_id,
self.project_id,
)

execution_controller.wait_for_done()
return execution
77 changes: 77 additions & 0 deletions airflow/providers/google/cloud/links/cloud_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""This module contains Google Cloud Run links."""
from __future__ import annotations

from typing import TYPE_CHECKING

from airflow.models import BaseOperator
from airflow.providers.google.cloud.links.base import BaseGoogleLink

if TYPE_CHECKING:
from airflow.utils.context import Context

CLOUD_RUN_JOBS_BASE_LINK = "/run/jobs"
CLOUD_RUN_JOB_EXEC_LINK = (
CLOUD_RUN_JOBS_BASE_LINK + "/executions/details/{region}/{exec_id}/general?project={project_id}"
)
CLOUD_RUN_JOB_LINK = "/details/{region}/{job_name}/executions?project={project_id}"


class CloudRunJobLink(BaseGoogleLink):
"""Helper class for constructing Cloud Run job Link"""

name = "Cloud Run job"
key = "cloud_run_job_config"
format_str = CLOUD_RUN_JOB_LINK

@staticmethod
def persist(
operator_instance: BaseOperator,
context: Context,
project_id: str | None,
region: str | None,
job_name: str | None,
):
operator_instance.xcom_push(
context,
key=CloudRunJobLink.key,
value={"project_id": project_id, "location": region, "job_name": job_name},
)


class CloudRunJobExecutionLink(BaseGoogleLink):
"""Helper class for constructing Cloud Run job execution Link"""

name = "Cloud Run job execution"
key = "cloud_run_job_execution"
format_str = CLOUD_RUN_JOB_EXEC_LINK

@staticmethod
def persist(
operator_instance: BaseOperator,
context: Context,
project_id: str | None,
region: str | None,
execution_id: str | None,
):
operator_instance.xcom_push(
context,
key=CloudRunJobExecutionLink.key,
value={"project_id": project_id, "location": region, "execution_id": execution_id},
)
Loading