This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 0ec26723858 Add GKEPodExecOperator for existing Pods (#72577)
0ec26723858 is described below
commit 0ec2672385898b4e60af516f7fb94da4b14bf877
Author: Alejandro Morgante <[email protected]>
AuthorDate: Thu Oct 1 18:52:44 2026 -0300
Add GKEPodExecOperator for existing Pods (#72577)
---
providers/google/docs/index.rst | 8 +-
.../docs/operators/cloud/kubernetes_engine.rst | 20 +++
providers/google/pyproject.toml | 2 +-
.../google/cloud/operators/kubernetes_engine.py | 114 ++++++++++++++++
.../kubernetes_engine/example_kubernetes_engine.py | 105 ++++++++++++++-
.../cloud/operators/test_kubernetes_engine.py | 146 ++++++++++++++++++++-
6 files changed, 388 insertions(+), 7 deletions(-)
diff --git a/providers/google/docs/index.rst b/providers/google/docs/index.rst
index 2bfceb0ed7f..0ac1470c55f 100644
--- a/providers/google/docs/index.rst
+++ b/providers/google/docs/index.rst
@@ -243,11 +243,11 @@ Install them when installing from PyPI. For example:
pip install apache-airflow-providers-google[apache.beam]
-==================== ====================================================
+==================== =====================================================
Extra Dependencies
-==================== ====================================================
+==================== =====================================================
``apache.beam`` ``apache-airflow-providers-apache-beam>=6.2.2``
-``cncf.kubernetes`` ``apache-airflow-providers-cncf-kubernetes>=10.1.0``
+``cncf.kubernetes`` ``apache-airflow-providers-cncf-kubernetes>=10.22.0``
``fab`` ``apache-airflow-providers-fab>=2.0.0``
``leveldb`` ``plyvel>=1.5.1; python_version < '3.13'``
``oracle`` ``apache-airflow-providers-oracle>=3.1.0``
@@ -269,7 +269,7 @@ Extra Dependencies
``standard`` ``apache-airflow-providers-standard``
``common.ai`` ``apache-airflow-providers-common-ai>=0.10.0``
``common.messaging`` ``apache-airflow-providers-common-messaging>=2.0.0``
-==================== ====================================================
+==================== =====================================================
Downloading official packages
-----------------------------
diff --git a/providers/google/docs/operators/cloud/kubernetes_engine.rst
b/providers/google/docs/operators/cloud/kubernetes_engine.rst
index 9baaa0ee6ad..35c26e68d50 100644
--- a/providers/google/docs/operators/cloud/kubernetes_engine.rst
+++ b/providers/google/docs/operators/cloud/kubernetes_engine.rst
@@ -210,6 +210,26 @@ lot less resources wasted on idle Operators or Sensors:
:end-before: [END howto_operator_gke_start_pod_xcom_async]
+.. _howto/operator:GKEPodExecOperator:
+
+Execute a command in an existing Pod
+""""""""""""""""""""""""""""""""""""
+
+Use
:class:`~airflow.providers.google.cloud.operators.kubernetes_engine.GKEPodExecOperator`
to execute a
+command in a running container of an existing Pod using Google Cloud
credentials. The operator discovers the
+GKE cluster endpoint, so a Kubernetes connection or ``kube_config`` file is
not required.
+
+The Pod and container must already exist and be running. The operator streams
the command output and waits for
+its exit code, but it does not create, restart, or delete the Pod. For more
information about command execution,
+output, and XCom behavior, see :ref:`howto/operator:KubernetesPodExecOperator`.
+
+.. exampleinclude::
/../../google/tests/system/google/cloud/kubernetes_engine/example_kubernetes_engine.py
+ :language: python
+ :dedent: 4
+ :start-after: [START howto_operator_gke_pod_exec]
+ :end-before: [END howto_operator_gke_pod_exec]
+
+
.. _howto/operator:GKEStartJobOperator:
Run a Job on a GKE cluster
diff --git a/providers/google/pyproject.toml b/providers/google/pyproject.toml
index 451fdbd2f83..363c1a6f25e 100644
--- a/providers/google/pyproject.toml
+++ b/providers/google/pyproject.toml
@@ -163,7 +163,7 @@ dependencies = [
"apache-airflow-providers-apache-beam>=6.2.2",
]
"cncf.kubernetes" = [
- "apache-airflow-providers-cncf-kubernetes>=10.1.0",
+ "apache-airflow-providers-cncf-kubernetes>=10.22.0",
]
"fab" = [
"apache-airflow-providers-fab>=2.0.0",
diff --git
a/providers/google/src/airflow/providers/google/cloud/operators/kubernetes_engine.py
b/providers/google/src/airflow/providers/google/cloud/operators/kubernetes_engine.py
index ebcf080d34d..cfdb01d28d6 100644
---
a/providers/google/src/airflow/providers/google/cloud/operators/kubernetes_engine.py
+++
b/providers/google/src/airflow/providers/google/cloud/operators/kubernetes_engine.py
@@ -61,6 +61,16 @@ from
airflow.providers.google.cloud.triggers.kubernetes_engine import (
from airflow.providers.google.common.hooks.base_google import
PROVIDE_PROJECT_ID
from airflow.providers_manager import ProvidersManager
+try:
+ from airflow.providers.cncf.kubernetes.operators.pod_exec import
KubernetesPodExecOperator
+except ImportError:
+ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+ raise AirflowOptionalProviderFeatureException(
+ "Failed to import KubernetesPodExecOperator. This operator is only
available in cncf-kubernetes "
+ "provider version >=10.22.0"
+ )
+
try:
from airflow.providers.cncf.kubernetes.operators.job import
KubernetesDeleteJobOperator
except ImportError:
@@ -689,6 +699,110 @@ class
GKEStartKueueInsideClusterOperator(GKEOperatorMixin, KubernetesInstallKueu
)
+class GKEPodExecOperator(GKEOperatorMixin, KubernetesPodExecOperator):
+ """
+ Execute a command in a running container of an existing Pod on Google
Kubernetes Engine.
+
+ The operator authenticates with Google Cloud and delegates command
execution to
+
:class:`~airflow.providers.cncf.kubernetes.operators.pod_exec.KubernetesPodExecOperator`.
+ It does not create, restart, or delete the target Pod.
+
+ .. seealso::
+ For more information on how to use this operator, take a look at the
guide:
+ :ref:`howto/operator:GKEPodExecOperator`
+
+ :param location: The Google Kubernetes Engine zone or region in which the
cluster resides.
+ (templated)
+ :param cluster_name: The name of the Google Kubernetes Engine cluster.
(templated)
+ :param pod_name: Name of the existing Kubernetes Pod. (templated)
+ :param command: Command and arguments to execute in the container.
(templated)
+ :param namespace: Namespace containing the Pod. Defaults to ``default``.
(templated)
+ :param container_name: Name of the container in which to execute the
command. When omitted, the
+ ``kubectl.kubernetes.io/default-container`` annotation or the first
container is used.
+ Defaults to ``None``. (templated)
+ :param use_internal_ip: Use the internal IP address as the endpoint.
Defaults to ``False``.
+ (templated)
+ :param use_dns_endpoint: Use the DNS address as the endpoint. Defaults to
``False``. This must be
+ set to ``True`` for Sovereign Cloud from Google. (templated)
+ :param project_id: The Google Cloud project ID. Defaults to the project
inferred from the Google
+ Cloud connection. (templated)
+ :param gcp_conn_id: The Google Cloud connection ID to use. Defaults to
``google_cloud_default``.
+ (templated)
+ :param impersonation_chain: Optional service account to impersonate using
short-term credentials,
+ or a sequence of accounts required to impersonate the final account.
Defaults to ``None``.
+ (templated)
+ :param do_xcom_push: Return standard output through XCom when ``True``.
Defaults to ``False``.
+ :param max_xcom_output_size: Maximum UTF-8 byte size retained for XCom.
Defaults to 49,344 bytes.
+ """
+
+ template_fields: Sequence[str] = tuple(
+ set(GKEOperatorMixin.template_fields)
+ | (
+ set(KubernetesPodExecOperator.template_fields)
+ - {"cluster_context", "config_file", "kubernetes_conn_id"}
+ )
+ )
+ operator_extra_links = (KubernetesEnginePodLink(),)
+
+ def __init__(
+ self,
+ *,
+ location: str,
+ cluster_name: str,
+ pod_name: str,
+ command: Sequence[str],
+ namespace: str = "default",
+ container_name: str | None = None,
+ use_internal_ip: bool = False,
+ use_dns_endpoint: bool = False,
+ project_id: str = PROVIDE_PROJECT_ID,
+ gcp_conn_id: str = "google_cloud_default",
+ impersonation_chain: str | Sequence[str] | None = None,
+ **kwargs,
+ ) -> None:
+ for parameter in ("config_file", "kubernetes_conn_id", "in_cluster",
"cluster_context"):
+ if parameter in kwargs:
+ raise ValueError(
+ f"`{parameter}` is not allowed for GKEPodExecOperator
because authentication is managed "
+ "through `gcp_conn_id`."
+ )
+ if gcp_conn_id is None:
+ raise ValueError(
+ "`gcp_conn_id` must not be None. To use Application Default
Credentials, configure an "
+ "empty `google_cloud_default` connection."
+ )
+ super().__init__(
+ pod_name=pod_name,
+ command=command,
+ namespace=namespace,
+ container_name=container_name,
+ kubernetes_conn_id=None,
+ in_cluster=False,
+ cluster_context=None,
+ config_file=None,
+ **kwargs,
+ )
+ self.project_id = project_id
+ self.location = location
+ self.cluster_name = cluster_name
+ self.gcp_conn_id = gcp_conn_id
+ self.use_internal_ip = use_internal_ip
+ self.use_dns_endpoint = use_dns_endpoint
+ self.impersonation_chain = impersonation_chain
+
+ def execute(self, context: Context) -> str | None:
+ namespace = self._resolve_namespace()
+ KubernetesEnginePodLink.persist(
+ context=context,
+ project_id=self.project_id,
+ location=self.location,
+ cluster_name=self.cluster_name,
+ namespace=namespace,
+ pod_name=self.pod_name,
+ )
+ return super().execute(context)
+
+
class GKEStartPodOperator(GKEOperatorMixin, KubernetesPodOperator):
"""
Executes a task in a Kubernetes pod in the specified Google Kubernetes
Engine cluster.
diff --git
a/providers/google/tests/system/google/cloud/kubernetes_engine/example_kubernetes_engine.py
b/providers/google/tests/system/google/cloud/kubernetes_engine/example_kubernetes_engine.py
index 54de4a904ba..77b04def7ad 100644
---
a/providers/google/tests/system/google/cloud/kubernetes_engine/example_kubernetes_engine.py
+++
b/providers/google/tests/system/google/cloud/kubernetes_engine/example_kubernetes_engine.py
@@ -24,18 +24,28 @@ from __future__ import annotations
import os
from datetime import datetime
+from kubernetes.client.rest import ApiException
+
from airflow.models.dag import DAG
+from airflow.providers.cncf.kubernetes.utils.container import
container_is_running
+from airflow.providers.cncf.kubernetes.utils.pod_manager import PodPhase
+from airflow.providers.google.cloud.hooks.kubernetes_engine import GKEHook,
GKEKubernetesHook
from airflow.providers.google.cloud.operators.kubernetes_engine import (
+ GKEClusterAuthDetails,
GKECreateClusterOperator,
+ GKECreateCustomResourceOperator,
GKEDeleteClusterOperator,
+ GKEDeleteCustomResourceOperator,
+ GKEPodExecOperator,
GKEStartPodOperator,
)
from airflow.providers.standard.operators.bash import BashOperator
try:
- from airflow.sdk import TriggerRule
+ from airflow.sdk import TriggerRule, task
except ImportError:
# Compatibility for Airflow < 3.1
+ from airflow.decorators import task # type: ignore[attr-defined,no-redef]
from airflow.utils.trigger_rule import TriggerRule # type:
ignore[no-redef,attr-defined]
from system.google import DEFAULT_GCP_SYSTEM_TEST_PROJECT_ID
@@ -49,11 +59,60 @@ GCP_LOCATION = "europe-west1"
CLUSTER_NAME_BASE = f"cluster-{DAG_ID}".replace("_", "-")
CLUSTER_NAME_FULL = CLUSTER_NAME_BASE + f"-{ENV_ID}".replace("_", "-")
CLUSTER_NAME = CLUSTER_NAME_BASE if len(CLUSTER_NAME_FULL) >= 33 else
CLUSTER_NAME_FULL
+EXEC_POD_NAME = f"existing-pod-{ENV_ID}".replace("_", "-")
+EXEC_CONTAINER_NAME = "main"
+EXPECTED_EXEC_OUTPUT = "command executed in existing GKE Pod"
# [START howto_operator_gcp_gke_create_cluster_definition]
CLUSTER = {"name": CLUSTER_NAME, "initial_node_count": 1, "autopilot":
{"enabled": True}}
# [END howto_operator_gcp_gke_create_cluster_definition]
+EXEC_POD = f"""
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {EXEC_POD_NAME}
+ namespace: default
+spec:
+ restartPolicy: Never
+ containers:
+ - name: {EXEC_CONTAINER_NAME}
+ image: busybox:1.38.0
+ command: ["sleep", "3600"]
+"""
+
+
[email protected](poke_interval=10, timeout=300, mode="reschedule")
+def wait_for_running_exec_pod() -> bool:
+ cluster_hook = GKEHook(location=GCP_LOCATION)
+ cluster_url, ssl_ca_cert = GKEClusterAuthDetails(
+ cluster_name=CLUSTER_NAME,
+ project_id=GCP_PROJECT_ID,
+ use_internal_ip=False,
+ use_dns_endpoint=False,
+ cluster_hook=cluster_hook,
+ ).fetch_cluster_info()
+ hook = GKEKubernetesHook(
+ gcp_conn_id="google_cloud_default",
+ cluster_url=cluster_url,
+ ssl_ca_cert=ssl_ca_cert,
+ )
+ try:
+ pod = hook.get_pod(name=EXEC_POD_NAME, namespace="default")
+ except ApiException as error:
+ if error.status == 404:
+ return False
+ raise
+ return bool(
+ pod.status and pod.status.phase == PodPhase.RUNNING and
container_is_running(pod, EXEC_CONTAINER_NAME)
+ )
+
+
+@task
+def verify_exec_output(output: str) -> None:
+ if output != EXPECTED_EXEC_OUTPUT:
+ raise ValueError(f"Unexpected command output: {output!r}")
+
with DAG(
DAG_ID,
@@ -99,6 +158,41 @@ with DAG(
)
# [END howto_operator_gke_start_pod_xcom]
+ create_exec_pod = GKECreateCustomResourceOperator(
+ task_id="create_exec_pod",
+ project_id=GCP_PROJECT_ID,
+ location=GCP_LOCATION,
+ cluster_name=CLUSTER_NAME,
+ yaml_conf=EXEC_POD,
+ )
+
+ exec_pod_is_running = wait_for_running_exec_pod()
+
+ # [START howto_operator_gke_pod_exec]
+ exec_in_existing_pod = GKEPodExecOperator(
+ task_id="exec_in_existing_pod",
+ project_id=GCP_PROJECT_ID,
+ location=GCP_LOCATION,
+ cluster_name=CLUSTER_NAME,
+ pod_name=EXEC_POD_NAME,
+ namespace="default",
+ container_name=EXEC_CONTAINER_NAME,
+ command=["sh", "-c", f"printf '{EXPECTED_EXEC_OUTPUT}'"],
+ do_xcom_push=True,
+ )
+ # [END howto_operator_gke_pod_exec]
+
+ exec_output_is_valid = verify_exec_output(exec_in_existing_pod.output)
+
+ delete_exec_pod = GKEDeleteCustomResourceOperator(
+ task_id="delete_exec_pod",
+ project_id=GCP_PROJECT_ID,
+ location=GCP_LOCATION,
+ cluster_name=CLUSTER_NAME,
+ yaml_conf=EXEC_POD,
+ trigger_rule=TriggerRule.ALL_DONE,
+ )
+
# [START howto_operator_gke_xcom_result]
pod_task_xcom_result = BashOperator(
task_id="pod_task_xcom_result",
@@ -124,6 +218,15 @@ with DAG(
delete_cluster.trigger_rule = TriggerRule.ALL_DONE
create_cluster >> [pod_task, pod_task_xcom] >> delete_cluster
+ (
+ create_cluster
+ >> create_exec_pod
+ >> exec_pod_is_running
+ >> exec_in_existing_pod
+ >> exec_output_is_valid
+ >> delete_exec_pod
+ >> delete_cluster
+ )
pod_task_xcom >> pod_task_xcom_result
from tests_common.test_utils.watcher import watcher
diff --git
a/providers/google/tests/unit/google/cloud/operators/test_kubernetes_engine.py
b/providers/google/tests/unit/google/cloud/operators/test_kubernetes_engine.py
index 4c92ab65a79..0008fc31c37 100644
---
a/providers/google/tests/unit/google/cloud/operators/test_kubernetes_engine.py
+++
b/providers/google/tests/unit/google/cloud/operators/test_kubernetes_engine.py
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import runpy
from copy import deepcopy
from unittest import mock
from unittest.mock import PropertyMock, call
@@ -36,12 +37,13 @@ from airflow.providers.cncf.kubernetes.operators.kueue
import (
KubernetesStartKueueJobOperator,
)
from airflow.providers.cncf.kubernetes.operators.pod import
KubernetesPodOperator
+from airflow.providers.cncf.kubernetes.operators.pod_exec import
KubernetesPodExecOperator
from airflow.providers.cncf.kubernetes.operators.resource import (
KubernetesCreateResourceOperator,
KubernetesDeleteResourceOperator,
)
from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction
-from airflow.providers.common.compat.sdk import AirflowException
+from airflow.providers.common.compat.sdk import AirflowException,
AirflowOptionalProviderFeatureException
from airflow.providers.google.cloud.links.base import BASE_LINK
from airflow.providers.google.cloud.links.kubernetes_engine import (
KUBERNETES_JOB_LINK,
@@ -49,6 +51,7 @@ from airflow.providers.google.cloud.links.kubernetes_engine
import (
KubernetesEngineJobLink,
KubernetesEnginePodLink,
)
+from airflow.providers.google.cloud.operators import kubernetes_engine
from airflow.providers.google.cloud.operators.kubernetes_engine import (
GKEClusterAuthDetails,
GKECreateClusterOperator,
@@ -59,6 +62,7 @@ from
airflow.providers.google.cloud.operators.kubernetes_engine import (
GKEDescribeJobOperator,
GKEListJobsOperator,
GKEOperatorMixin,
+ GKEPodExecOperator,
GKEResumeJobOperator,
GKEStartJobOperator,
GKEStartKueueInsideClusterOperator,
@@ -825,6 +829,146 @@ class TestGKEStartKueueInsideClusterOperator:
)
[email protected]("sys.modules",
{"airflow.providers.cncf.kubernetes.operators.pod_exec": None})
+def
test_missing_kubernetes_pod_exec_operator_reports_required_provider_version():
+ with pytest.raises(
+ AirflowOptionalProviderFeatureException, match="cncf-kubernetes
provider version >=10.22.0"
+ ):
+ runpy.run_path(kubernetes_engine.__file__)
+
+
+class TestGKEPodExecOperator:
+ def setup_method(self):
+ self.operator = GKEPodExecOperator(
+ task_id=TEST_TASK_ID,
+ project_id=TEST_PROJECT_ID,
+ location=TEST_LOCATION,
+ cluster_name=GKE_CLUSTER_NAME,
+ pod_name=K8S_POD_NAME,
+ namespace=K8S_NAMESPACE,
+ container_name="worker",
+ command=["dbt", "run"],
+ gcp_conn_id=TEST_CONN_ID,
+ impersonation_chain=TEST_IMPERSONATION_CHAIN,
+ do_xcom_push=True,
+ max_xcom_output_size=1024,
+ )
+
+ def test_constructor_forces_gke_auth_and_forwards_exec_options(self):
+ assert self.operator.kubernetes_conn_id is None
+ assert self.operator.in_cluster is False
+ assert self.operator.cluster_context is None
+ assert self.operator.config_file is None
+ assert self.operator.do_xcom_push is True
+ assert self.operator.max_xcom_output_size == 1024
+
+ def test_default_namespace_when_omitted(self):
+ operator = GKEPodExecOperator(
+ task_id=TEST_TASK_ID,
+ location=TEST_LOCATION,
+ cluster_name=GKE_CLUSTER_NAME,
+ pod_name=K8S_POD_NAME,
+ command=["true"],
+ )
+
+ assert operator.namespace == "default"
+
+ @pytest.mark.parametrize(
+ ("kwargs", "expected_message"),
+ [
+ (
+ {"config_file": "/path/to/kubeconfig"},
+ "`config_file` is not allowed for GKEPodExecOperator",
+ ),
+ (
+ {"config_file": None},
+ "`config_file` is not allowed for GKEPodExecOperator",
+ ),
+ (
+ {"kubernetes_conn_id": "kubernetes_default"},
+ "`kubernetes_conn_id` is not allowed for GKEPodExecOperator",
+ ),
+ (
+ {"in_cluster": True},
+ "`in_cluster` is not allowed for GKEPodExecOperator",
+ ),
+ (
+ {"cluster_context": "test-context"},
+ "`cluster_context` is not allowed for GKEPodExecOperator",
+ ),
+ (
+ {"gcp_conn_id": None},
+ "`gcp_conn_id` must not be None",
+ ),
+ ],
+ ids=[
+ "config-file",
+ "config-file-none",
+ "kubernetes-connection",
+ "in-cluster",
+ "cluster-context",
+ "missing-gcp-connection",
+ ],
+ )
+ def test_invalid_auth_parameters(self, kwargs, expected_message):
+ with pytest.raises(ValueError, match=expected_message):
+ GKEPodExecOperator(
+ task_id=TEST_TASK_ID,
+ location=TEST_LOCATION,
+ cluster_name=GKE_CLUSTER_NAME,
+ pod_name=K8S_POD_NAME,
+ command=["true"],
+ **kwargs,
+ )
+
+ def test_template_fields(self):
+ expected_template_fields = set(GKEOperatorMixin.template_fields) | (
+ set(KubernetesPodExecOperator.template_fields)
+ - {"cluster_context", "config_file", "kubernetes_conn_id"}
+ )
+
+ assert set(GKEPodExecOperator.template_fields) ==
expected_template_fields
+
+ @mock.patch(GKE_OPERATORS_PATH.format("GKEKubernetesHook"), autospec=True)
+ @mock.patch(GKE_OPERATORS_PATH.format("GKEOperatorMixin.cluster_info"),
new_callable=PropertyMock)
+ def test_hook_uses_gke_authentication(self, mock_cluster_info, mock_hook):
+ mock_cluster_info.return_value = (GKE_CLUSTER_URL, GKE_SSL_CA_CERT)
+
+ result = self.operator.hook
+
+ assert result == mock_hook.return_value
+ mock_hook.assert_called_once_with(
+ gcp_conn_id=TEST_CONN_ID,
+ impersonation_chain=TEST_IMPERSONATION_CHAIN,
+ cluster_url=GKE_CLUSTER_URL,
+ ssl_ca_cert=GKE_SSL_CA_CERT,
+ enable_tcp_keepalive=False,
+ use_dns_endpoint=False,
+ )
+
+ @mock.patch(GKE_OPERATORS_PATH.format("KubernetesEnginePodLink.persist"),
autospec=True)
+ @mock.patch(
+ GKE_OPERATORS_PATH.format("KubernetesPodExecOperator.execute"),
+ autospec=True,
+ return_value="command output",
+ )
+ def test_execute_persists_link_and_returns_output(self, mock_execute,
mock_persist_link):
+ context = {}
+
+ result = self.operator.execute(context)
+
+ assert result == "command output"
+ mock_persist_link.assert_called_once_with(
+ context=context,
+ project_id=TEST_PROJECT_ID,
+ location=TEST_LOCATION,
+ cluster_name=GKE_CLUSTER_NAME,
+ namespace=K8S_NAMESPACE,
+ pod_name=K8S_POD_NAME,
+ )
+ mock_execute.assert_called_once_with(self.operator, context)
+
+
class TestGKEStartPodOperator:
def setup_method(self):
self.operator = GKEStartPodOperator(