blob: ba9c33a36e4219f9a2d76b070f0a82e95408178f [file]
# 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.
from __future__ import annotations
import pathlib
import random
import re
import string
import sys
from datetime import datetime, timedelta
from unittest import mock
import pytest
import yaml
from kubernetes.client import models as k8s
from kubernetes.client.rest import ApiException
from urllib3 import HTTPResponse
from airflow import AirflowException
from airflow.exceptions import PodReconciliationError
from airflow.models.taskinstancekey import TaskInstanceKey
from airflow.operators.bash import BashOperator
from airflow.operators.empty import EmptyOperator
from airflow.utils import timezone
from airflow.utils.state import State, TaskInstanceState
from tests.test_utils.config import conf_vars
try:
from airflow.executors.kubernetes_executor import (
POD_EXECUTOR_DONE_KEY,
AirflowKubernetesScheduler,
KubernetesExecutor,
KubernetesJobWatcher,
ResourceVersion,
create_pod_id,
get_base_pod_from_template,
)
from airflow.kubernetes import pod_generator
from airflow.kubernetes.kubernetes_helper_functions import (
annotations_for_logging_task_metadata,
annotations_to_key,
get_logs_task_metadata,
)
from airflow.kubernetes.pod_generator import PodGenerator
except ImportError:
AirflowKubernetesScheduler = None # type: ignore
class TestAirflowKubernetesScheduler:
@staticmethod
def _gen_random_string(seed, str_len):
char_list = []
for char_seed in range(str_len):
random.seed(str(seed) * char_seed)
char_list.append(random.choice(string.printable))
return "".join(char_list)
def _cases(self):
cases = [
("my_dag_id", "my-task-id"),
("my.dag.id", "my.task.id"),
("MYDAGID", "MYTASKID"),
("my_dag_id", "my_task_id"),
("mydagid" * 200, "my_task_id" * 200),
("my_dág_id", "my_tásk_id"),
("Компьютер", "niedołężność"),
("影師嗎", "中華民國;$"),
]
cases.extend(
[(self._gen_random_string(seed, 200), self._gen_random_string(seed, 200)) for seed in range(100)]
)
return cases
@staticmethod
def _is_valid_pod_id(name):
regex = r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"
return len(name) <= 253 and all(ch.lower() == ch for ch in name) and re.match(regex, name)
@staticmethod
def _is_safe_label_value(value):
regex = r"^[^a-z0-9A-Z]*|[^a-zA-Z0-9_\-\.]|[^a-z0-9A-Z]*$"
return len(value) <= 63 and re.match(regex, value)
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
def test_create_pod_id(self):
for dag_id, task_id in self._cases():
pod_name = PodGenerator.make_unique_pod_id(create_pod_id(dag_id, task_id))
assert self._is_valid_pod_id(pod_name)
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.kubernetes.pod_generator.PodGenerator")
@mock.patch("airflow.executors.kubernetes_executor.KubeConfig")
def test_get_base_pod_from_template(self, mock_kubeconfig, mock_generator):
# Provide non-existent file path,
# so None will be passed to deserialize_model_dict().
pod_template_file_path = "/bar/biz"
get_base_pod_from_template(pod_template_file_path, None)
assert "deserialize_model_dict" == mock_generator.mock_calls[0][0]
assert mock_generator.mock_calls[0][1][0] is None
mock_kubeconfig.pod_template_file = "/foo/bar"
get_base_pod_from_template(None, mock_kubeconfig)
assert "deserialize_model_dict" == mock_generator.mock_calls[1][0]
assert mock_generator.mock_calls[1][1][0] is None
# Provide existent file path,
# so loaded YAML file content should be used to call deserialize_model_dict(), rather than None.
path = sys.path[0] + "/tests/kubernetes/pod.yaml"
with open(path) as stream:
expected_pod_dict = yaml.safe_load(stream)
pod_template_file_path = path
get_base_pod_from_template(pod_template_file_path, None)
assert "deserialize_model_dict" == mock_generator.mock_calls[2][0]
assert mock_generator.mock_calls[2][1][0] == expected_pod_dict
mock_kubeconfig.pod_template_file = path
get_base_pod_from_template(None, mock_kubeconfig)
assert "deserialize_model_dict" == mock_generator.mock_calls[3][0]
assert mock_generator.mock_calls[3][1][0] == expected_pod_dict
def test_make_safe_label_value(self):
for dag_id, task_id in self._cases():
safe_dag_id = pod_generator.make_safe_label_value(dag_id)
assert self._is_safe_label_value(safe_dag_id)
safe_task_id = pod_generator.make_safe_label_value(task_id)
assert self._is_safe_label_value(safe_task_id)
dag_id = "my_dag_id"
assert dag_id == pod_generator.make_safe_label_value(dag_id)
dag_id = "my_dag_id_" + "a" * 64
assert "my_dag_id_" + "a" * 43 + "-0ce114c45" == pod_generator.make_safe_label_value(dag_id)
def test_execution_date_serialize_deserialize(self):
datetime_obj = datetime.now()
serialized_datetime = pod_generator.datetime_to_label_safe_datestring(datetime_obj)
new_datetime_obj = pod_generator.label_safe_datestring_to_datetime(serialized_datetime)
assert datetime_obj == new_datetime_obj
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.client")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
def test_delete_pod_successfully(self, mock_watcher, mock_client, mock_kube_client):
pod_name = "my-pod-1"
namespace = "my-namespace-1"
mock_delete_namespace = mock.MagicMock()
mock_kube_client.return_value.delete_namespaced_pod = mock_delete_namespace
kube_executor = KubernetesExecutor()
kube_executor.job_id = 1
kube_executor.start()
try:
kube_executor.kube_scheduler.delete_pod(pod_name, namespace)
mock_delete_namespace.assert_called_with(pod_name, namespace, body=mock_client.V1DeleteOptions())
finally:
kube_executor.end()
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.client")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
def test_delete_pod_raises_404(self, mock_watcher, mock_client, mock_kube_client):
pod_name = "my-pod-1"
namespace = "my-namespace-2"
mock_delete_namespace = mock.MagicMock()
mock_kube_client.return_value.delete_namespaced_pod = mock_delete_namespace
# ApiException is raised because status is not 404
mock_kube_client.return_value.delete_namespaced_pod.side_effect = ApiException(status=400)
kube_executor = KubernetesExecutor()
kube_executor.job_id = 1
kube_executor.start()
with pytest.raises(ApiException):
kube_executor.kube_scheduler.delete_pod(pod_name, namespace)
mock_delete_namespace.assert_called_with(pod_name, namespace, body=mock_client.V1DeleteOptions())
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.client")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
def test_delete_pod_404_not_raised(self, mock_watcher, mock_client, mock_kube_client):
pod_name = "my-pod-1"
namespace = "my-namespace-3"
mock_delete_namespace = mock.MagicMock()
mock_kube_client.return_value.delete_namespaced_pod = mock_delete_namespace
# ApiException not raised because the status is 404
mock_kube_client.return_value.delete_namespaced_pod.side_effect = ApiException(status=404)
kube_executor = KubernetesExecutor()
kube_executor.job_id = 1
kube_executor.start()
try:
kube_executor.kube_scheduler.delete_pod(pod_name, namespace)
mock_delete_namespace.assert_called_with(pod_name, namespace, body=mock_client.V1DeleteOptions())
finally:
kube_executor.end()
class TestKubernetesExecutor:
"""
Tests if an ApiException from the Kube Client will cause the task to
be rescheduled.
"""
def setup_method(self) -> None:
self.kubernetes_executor = KubernetesExecutor()
self.kubernetes_executor.job_id = 5
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@pytest.mark.parametrize(
"status, should_requeue",
[
pytest.param(403, True, id="403 Forbidden"),
pytest.param(12345, True, id="12345 fake-unhandled-reason"),
pytest.param(422, False, id="422 Unprocessable Entity"),
pytest.param(400, False, id="400 BadRequest"),
],
)
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_run_next_exception_requeue(
self, mock_get_kube_client, mock_kubernetes_job_watcher, status, should_requeue
):
"""
When pod scheduling fails with either reason 'Forbidden', or any reason not yet
handled in the relevant try-except block, the task should stay in the ``task_queue``
and be attempted on a subsequent executor sync. When reason is 'Unprocessable Entity'
or 'BadRequest', the task should be failed without being re-queued.
Note on error scenarios:
- 403 Forbidden will be returned when your request exceeds namespace quota.
- 422 Unprocessable Entity is returned when your parameters are valid but unsupported
e.g. limits lower than requests.
- 400 BadRequest is returned when your parameters are invalid e.g. asking for cpu=100ABC123.
"""
path = sys.path[0] + "/tests/kubernetes/pod_generator_base_with_secrets.yaml"
response = HTTPResponse(body='{"message": "any message"}', status=status)
# A mock kube_client that throws errors when making a pod
mock_kube_client = mock.patch("kubernetes.client.CoreV1Api", autospec=True)
mock_kube_client.create_namespaced_pod = mock.MagicMock(side_effect=ApiException(http_resp=response))
mock_get_kube_client.return_value = mock_kube_client
mock_api_client = mock.MagicMock()
mock_api_client.sanitize_for_serialization.return_value = {}
mock_kube_client.api_client = mock_api_client
config = {
("kubernetes", "pod_template_file"): path,
}
with conf_vars(config):
kubernetes_executor = self.kubernetes_executor
kubernetes_executor.start()
try:
# Execute a task while the Api Throws errors
try_number = 1
task_instance_key = TaskInstanceKey("dag", "task", "run_id", try_number)
kubernetes_executor.execute_async(
key=task_instance_key,
queue=None,
command=["airflow", "tasks", "run", "true", "some_parameter"],
)
kubernetes_executor.sync()
assert mock_kube_client.create_namespaced_pod.call_count == 1
if should_requeue:
assert not kubernetes_executor.task_queue.empty()
# Disable the ApiException
mock_kube_client.create_namespaced_pod.side_effect = None
# Execute the task without errors should empty the queue
mock_kube_client.create_namespaced_pod.reset_mock()
kubernetes_executor.sync()
assert mock_kube_client.create_namespaced_pod.called
assert kubernetes_executor.task_queue.empty()
else:
assert kubernetes_executor.task_queue.empty()
assert kubernetes_executor.event_buffer[task_instance_key][0] == State.FAILED
finally:
kubernetes_executor.end()
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.settings.pod_mutation_hook")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_run_next_pmh_error(self, mock_get_kube_client, mock_pmh):
"""
Exception during Pod Mutation Hook execution should be handled gracefully.
"""
exception_in_pmh = Exception("Purposely generate error for test")
mock_pmh.side_effect = exception_in_pmh
mock_kube_client = mock.patch("kubernetes.client.CoreV1Api", autospec=True)
mock_kube_client.create_namespaced_pod = mock.MagicMock()
mock_get_kube_client.return_value = mock_kube_client
kubernetes_executor = self.kubernetes_executor
kubernetes_executor.start()
try:
try_number = 1
task_instance_key = TaskInstanceKey("dag", "task", "run_id", try_number)
kubernetes_executor.execute_async(
key=task_instance_key,
queue=None,
command=["airflow", "tasks", "run", "true", "some_parameter"],
)
kubernetes_executor.sync()
# The pod_mutation_hook should have been called once.
assert mock_pmh.call_count == 1
# There should be no pod creation request sent
assert mock_kube_client.create_namespaced_pod.call_count == 0
# The task is not re-queued and there is the failed record in event_buffer
assert kubernetes_executor.task_queue.empty()
assert kubernetes_executor.event_buffer[task_instance_key][0] == State.FAILED
assert kubernetes_executor.event_buffer[task_instance_key][1].__cause__ == exception_in_pmh
finally:
kubernetes_executor.end()
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_run_next_pod_reconciliation_error(self, mock_get_kube_client, mock_kubernetes_job_watcher):
"""
When construct_pod raises PodReconciliationError, we should fail the task.
"""
path = sys.path[0] + "/tests/kubernetes/pod_generator_base_with_secrets.yaml"
mock_kube_client = mock.patch("kubernetes.client.CoreV1Api", autospec=True)
fail_msg = "test message"
mock_kube_client.create_namespaced_pod = mock.MagicMock(side_effect=PodReconciliationError(fail_msg))
mock_get_kube_client.return_value = mock_kube_client
mock_api_client = mock.MagicMock()
mock_api_client.sanitize_for_serialization.return_value = {}
mock_kube_client.api_client = mock_api_client
config = {("kubernetes", "pod_template_file"): path}
with conf_vars(config):
kubernetes_executor = self.kubernetes_executor
kubernetes_executor.start()
try:
# Execute a task while the Api Throws errors
try_number = 1
task_instance_key = TaskInstanceKey("dag", "task", "run_id", try_number)
kubernetes_executor.execute_async(
key=task_instance_key,
queue=None,
command=["airflow", "tasks", "run", "true", "some_parameter"],
)
kubernetes_executor.sync()
assert kubernetes_executor.task_queue.empty()
assert kubernetes_executor.event_buffer[task_instance_key][0] == State.FAILED
assert kubernetes_executor.event_buffer[task_instance_key][1].args[0] == fail_msg
finally:
kubernetes_executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubeConfig")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor.sync")
@mock.patch("airflow.executors.base_executor.BaseExecutor.trigger_tasks")
@mock.patch("airflow.executors.base_executor.Stats.gauge")
def test_gauge_executor_metrics(self, mock_stats_gauge, mock_trigger_tasks, mock_sync, mock_kube_config):
executor = self.kubernetes_executor
executor.heartbeat()
calls = [
mock.call(
"executor.open_slots", value=mock.ANY, tags={"status": "open", "name": "KubernetesExecutor"}
),
mock.call(
"executor.queued_tasks",
value=mock.ANY,
tags={"status": "queued", "name": "KubernetesExecutor"},
),
mock.call(
"executor.running_tasks",
value=mock.ANY,
tags={"status": "running", "name": "KubernetesExecutor"},
),
]
mock_stats_gauge.assert_has_calls(calls)
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_invalid_executor_config(self, mock_get_kube_client, mock_kubernetes_job_watcher):
executor = self.kubernetes_executor
executor.start()
try:
assert executor.event_buffer == {}
executor.execute_async(
key=("dag", "task", datetime.utcnow(), 1),
queue=None,
command=["airflow", "tasks", "run", "true", "some_parameter"],
executor_config=k8s.V1Pod(
spec=k8s.V1PodSpec(
containers=[k8s.V1Container(name="base", image="myimage", image_pull_policy="Always")]
)
),
)
assert list(executor.event_buffer.values())[0][1] == "Invalid executor_config passed"
finally:
executor.end()
@pytest.mark.execution_timeout(10)
@pytest.mark.skipif(
AirflowKubernetesScheduler is None, reason="kubernetes python package is not installed"
)
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler.run_pod_async")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_pod_template_file_override_in_executor_config(self, mock_get_kube_client, mock_run_pod_async):
current_folder = pathlib.Path(__file__).parent.resolve()
template_file = str(
(current_folder / "kubernetes_executor_template_files" / "basic_template.yaml").resolve()
)
mock_kube_client = mock.patch("kubernetes.client.CoreV1Api", autospec=True)
mock_get_kube_client.return_value = mock_kube_client
with conf_vars({("kubernetes", "pod_template_file"): ""}):
executor = self.kubernetes_executor
executor.start()
try:
assert executor.event_buffer == {}
assert executor.task_queue.empty()
executor.execute_async(
key=TaskInstanceKey("dag", "task", "run_id", 1),
queue=None,
command=["airflow", "tasks", "run", "true", "some_parameter"],
executor_config={
"pod_template_file": template_file,
"pod_override": k8s.V1Pod(
metadata=k8s.V1ObjectMeta(labels={"release": "stable"}),
spec=k8s.V1PodSpec(
containers=[k8s.V1Container(name="base", image="airflow:3.6")],
),
),
},
)
assert not executor.task_queue.empty()
task = executor.task_queue.get_nowait()
_, _, expected_executor_config, expected_pod_template_file = task
executor.task_queue.task_done()
# Test that the correct values have been put to queue
assert expected_executor_config.metadata.labels == {"release": "stable"}
assert expected_pod_template_file == template_file
self.kubernetes_executor.kube_scheduler.run_next(task)
mock_run_pod_async.assert_called_once_with(
k8s.V1Pod(
api_version="v1",
kind="Pod",
metadata=k8s.V1ObjectMeta(
name=mock.ANY,
namespace="default",
annotations={
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
},
labels={
"airflow-worker": "5",
"airflow_version": mock.ANY,
"dag_id": "dag",
"run_id": "run_id",
"kubernetes_executor": "True",
"mylabel": "foo",
"release": "stable",
"task_id": "task",
"try_number": "1",
},
),
spec=k8s.V1PodSpec(
containers=[
k8s.V1Container(
name="base",
image="airflow:3.6",
args=["airflow", "tasks", "run", "true", "some_parameter"],
env=[k8s.V1EnvVar(name="AIRFLOW_IS_K8S_EXECUTOR_POD", value="True")],
)
],
image_pull_secrets=[k8s.V1LocalObjectReference(name="airflow-registry")],
scheduler_name="default-scheduler",
security_context=k8s.V1PodSecurityContext(fs_group=50000, run_as_user=50000),
),
)
)
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_change_state_running(self, mock_get_kube_client, mock_kubernetes_job_watcher):
executor = self.kubernetes_executor
executor.start()
try:
key = ("dag_id", "task_id", "run_id", "try_number1")
executor.running = {key}
executor._change_state(key, State.RUNNING, "pod_name", "default")
assert executor.event_buffer[key][0] == State.RUNNING
assert executor.running == {key}
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler.delete_pod")
def test_change_state_success(self, mock_delete_pod, mock_get_kube_client, mock_kubernetes_job_watcher):
executor = self.kubernetes_executor
executor.start()
try:
key = ("dag_id", "task_id", "run_id", "try_number2")
executor.running = {key}
executor._change_state(key, State.SUCCESS, "pod_name", "default")
assert executor.event_buffer[key][0] == State.SUCCESS
assert executor.running == set()
mock_delete_pod.assert_called_once_with(pod_name="pod_name", namespace="default")
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler")
def test_change_state_failed_no_deletion(
self, mock_kubescheduler, mock_get_kube_client, mock_kubernetes_job_watcher
):
mock_delete_pod = mock_kubescheduler.return_value.delete_pod
mock_patch_pod = mock_kubescheduler.return_value.patch_pod_executor_done
executor = self.kubernetes_executor
executor.kube_config.delete_worker_pods = False
executor.kube_config.delete_worker_pods_on_failure = False
executor.start()
try:
key = ("dag_id", "task_id", "run_id", "try_number3")
executor.running = {key}
executor._change_state(key, State.FAILED, "pod_id", "test-namespace")
assert executor.event_buffer[key][0] == State.FAILED
assert executor.running == set()
mock_delete_pod.assert_not_called()
mock_patch_pod.assert_called_once_with(pod_name="pod_id", namespace="test-namespace")
finally:
executor.end()
@pytest.mark.parametrize(
"ti_state", [TaskInstanceState.SUCCESS, TaskInstanceState.FAILED, TaskInstanceState.DEFERRED]
)
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler.delete_pod")
def test_change_state_none(
self,
mock_delete_pod,
mock_get_kube_client,
mock_kubernetes_job_watcher,
ti_state,
create_task_instance,
):
"""Ensure that when change_state gets state=None, it looks up the TI state from the db"""
executor = self.kubernetes_executor
executor.start()
try:
ti = create_task_instance(state=ti_state)
key = ti.key
executor.running = {key}
executor._change_state(key, None, "pod_name", "default")
assert executor.event_buffer[key][0] == ti_state
assert executor.running == set()
mock_delete_pod.assert_called_once_with(pod_name="pod_name", namespace="default")
finally:
executor.end()
@pytest.mark.parametrize(
"multi_namespace_mode_namespace_list, watchers_keys",
[
pytest.param(["A", "B", "C"], ["A", "B", "C"]),
pytest.param(None, ["ALL_NAMESPACES"]),
],
)
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_watchers_under_multi_namespace_mode(
self, mock_get_kube_client, multi_namespace_mode_namespace_list, watchers_keys
):
executor = self.kubernetes_executor
executor.kube_config.multi_namespace_mode = True
executor.kube_config.multi_namespace_mode_namespace_list = multi_namespace_mode_namespace_list
executor.start()
try:
assert list(executor.kube_scheduler.kube_watchers.keys()) == watchers_keys
assert all(
isinstance(v, KubernetesJobWatcher) for v in executor.kube_scheduler.kube_watchers.values()
)
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler")
def test_change_state_skip_pod_deletion(
self, mock_kubescheduler, mock_get_kube_client, mock_kubernetes_job_watcher
):
mock_delete_pod = mock_kubescheduler.return_value.delete_pod
mock_patch_pod = mock_kubescheduler.return_value.patch_pod_executor_done
executor = self.kubernetes_executor
executor.kube_config.delete_worker_pods = False
executor.kube_config.delete_worker_pods_on_failure = False
executor.start()
try:
key = ("dag_id", "task_id", "run_id", "try_number2")
executor.running = {key}
executor._change_state(key, State.SUCCESS, "pod_name", "test-namespace")
assert executor.event_buffer[key][0] == State.SUCCESS
assert executor.running == set()
mock_delete_pod.assert_not_called()
mock_patch_pod.assert_called_once_with(pod_name="pod_name", namespace="test-namespace")
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesJobWatcher")
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler")
def test_change_state_failed_pod_deletion(
self, mock_kubescheduler, mock_get_kube_client, mock_kubernetes_job_watcher
):
mock_delete_pod = mock_kubescheduler.return_value.delete_pod
mock_patch_pod = mock_kubescheduler.return_value.patch_pod_executor_done
executor = self.kubernetes_executor
executor.kube_config.delete_worker_pods_on_failure = True
executor.start()
try:
key = ("dag_id", "task_id", "run_id", "try_number2")
executor.running = {key}
executor._change_state(key, State.FAILED, "pod_name", "test-namespace")
assert executor.event_buffer[key][0] == State.FAILED
assert executor.running == set()
mock_delete_pod.assert_called_once_with(pod_name="pod_name", namespace="test-namespace")
mock_patch_pod.assert_not_called()
finally:
executor.end()
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor.adopt_launched_task")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor._adopt_completed_pods")
def test_try_adopt_task_instances(self, mock_adopt_completed_pods, mock_adopt_launched_task):
executor = self.kubernetes_executor
executor.scheduler_job_id = "10"
ti_key = annotations_to_key(
{
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
)
mock_ti = mock.MagicMock(queued_by_job_id="1", external_executor_id="1", key=ti_key)
pod = k8s.V1Pod(metadata=k8s.V1ObjectMeta(name="foo"))
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value.items = [pod]
executor.kube_client = mock_kube_client
# First adoption
reset_tis = executor.try_adopt_task_instances([mock_ti])
mock_kube_client.list_namespaced_pod.assert_called_once_with(
namespace="default",
field_selector="status.phase!=Succeeded",
label_selector="kubernetes_executor=True,airflow-worker=1,airflow_executor_done!=True",
)
mock_adopt_launched_task.assert_called_once_with(mock_kube_client, pod, {ti_key: mock_ti})
mock_adopt_completed_pods.assert_called_once()
assert reset_tis == [mock_ti] # assume failure adopting when checking return
# Second adoption (queued_by_job_id and external_executor_id no longer match)
mock_kube_client.reset_mock()
mock_adopt_launched_task.reset_mock()
mock_adopt_completed_pods.reset_mock()
mock_ti.queued_by_job_id = "10" # scheduler_job would have updated this after the first adoption
executor.scheduler_job_id = "20"
# assume success adopting, `adopt_launched_task` pops `ti_key` from `tis_to_flush_by_key`
mock_adopt_launched_task.side_effect = (
lambda client, pod, tis_to_flush_by_key: tis_to_flush_by_key.pop(ti_key)
)
reset_tis = executor.try_adopt_task_instances([mock_ti])
mock_kube_client.list_namespaced_pod.assert_called_once_with(
namespace="default",
field_selector="status.phase!=Succeeded",
label_selector="kubernetes_executor=True,airflow-worker=10,airflow_executor_done!=True",
)
mock_adopt_launched_task.assert_called_once() # Won't check args this time around as they get mutated
mock_adopt_completed_pods.assert_called_once()
assert reset_tis == [] # This time our return is empty - no TIs to reset
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor._adopt_completed_pods")
def test_try_adopt_task_instances_multiple_scheduler_ids(self, mock_adopt_completed_pods):
"""We try to find pods only once per scheduler id"""
executor = self.kubernetes_executor
mock_kube_client = mock.MagicMock()
executor.kube_client = mock_kube_client
mock_tis = [
mock.MagicMock(queued_by_job_id="10", external_executor_id="1", dag_id="dag", task_id="task"),
mock.MagicMock(queued_by_job_id="40", external_executor_id="1", dag_id="dag", task_id="task2"),
mock.MagicMock(queued_by_job_id="40", external_executor_id="1", dag_id="dag", task_id="task3"),
]
executor.try_adopt_task_instances(mock_tis)
assert mock_kube_client.list_namespaced_pod.call_count == 2
mock_kube_client.list_namespaced_pod.assert_has_calls(
[
mock.call(
namespace="default",
field_selector="status.phase!=Succeeded",
label_selector="kubernetes_executor=True,airflow-worker=10,airflow_executor_done!=True",
),
mock.call(
namespace="default",
field_selector="status.phase!=Succeeded",
label_selector="kubernetes_executor=True,airflow-worker=40,airflow_executor_done!=True",
),
],
any_order=True,
)
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor.adopt_launched_task")
@mock.patch("airflow.executors.kubernetes_executor.KubernetesExecutor._adopt_completed_pods")
def test_try_adopt_task_instances_no_matching_pods(
self, mock_adopt_completed_pods, mock_adopt_launched_task
):
executor = self.kubernetes_executor
mock_ti = mock.MagicMock(queued_by_job_id="1", external_executor_id="1", dag_id="dag", task_id="task")
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value.items = []
executor.kube_client = mock_kube_client
tis_to_flush = executor.try_adopt_task_instances([mock_ti])
assert tis_to_flush == [mock_ti]
mock_adopt_launched_task.assert_not_called()
mock_adopt_completed_pods.assert_called_once()
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_adopt_launched_task(self, mock_kube_client):
executor = self.kubernetes_executor
executor.scheduler_job_id = "modified"
annotations = {
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
ti_key = annotations_to_key(annotations)
pod = k8s.V1Pod(
metadata=k8s.V1ObjectMeta(name="foo", labels={"airflow-worker": "bar"}, annotations=annotations)
)
tis_to_flush_by_key = {ti_key: {}}
executor.adopt_launched_task(mock_kube_client, pod=pod, tis_to_flush_by_key=tis_to_flush_by_key)
mock_kube_client.patch_namespaced_pod.assert_called_once_with(
body={"metadata": {"labels": {"airflow-worker": "modified"}}},
name="foo",
namespace=None,
)
assert tis_to_flush_by_key == {}
assert executor.running == {ti_key}
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_adopt_launched_task_api_exception(self, mock_kube_client):
"""We shouldn't think we are running the task if aren't able to patch the pod"""
executor = self.kubernetes_executor
executor.scheduler_job_id = "modified"
annotations = {
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
ti_key = annotations_to_key(annotations)
pod = k8s.V1Pod(metadata=k8s.V1ObjectMeta(name="foo", annotations=annotations))
tis_to_flush_by_key = {ti_key: {}}
mock_kube_client.patch_namespaced_pod.side_effect = ApiException(status=400)
executor.adopt_launched_task(mock_kube_client, pod=pod, tis_to_flush_by_key=tis_to_flush_by_key)
mock_kube_client.patch_namespaced_pod.assert_called_once_with(
body={"metadata": {"labels": {"airflow-worker": "modified"}}},
name="foo",
namespace=None,
)
assert tis_to_flush_by_key == {ti_key: {}}
assert executor.running == set()
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_adopt_completed_pods(self, mock_kube_client):
"""We should adopt all completed pods from other schedulers"""
executor = self.kubernetes_executor
executor.scheduler_job_id = "modified"
executor.kube_client = mock_kube_client
executor.kube_config.kube_namespace = "somens"
pod_names = ["one", "two"]
def get_annotations(pod_name):
return {
"dag_id": "dag",
"run_id": "run_id",
"task_id": pod_name,
"try_number": "1",
}
mock_kube_client.list_namespaced_pod.return_value.items = [
k8s.V1Pod(
metadata=k8s.V1ObjectMeta(
name=pod_name,
labels={"airflow-worker": pod_name},
annotations=get_annotations(pod_name),
namespace="somens",
)
)
for pod_name in pod_names
]
expected_running_ti_keys = {annotations_to_key(get_annotations(pod_name)) for pod_name in pod_names}
executor._adopt_completed_pods(mock_kube_client)
mock_kube_client.list_namespaced_pod.assert_called_once_with(
namespace="somens",
field_selector="status.phase=Succeeded",
label_selector="kubernetes_executor=True,airflow-worker!=modified,airflow_executor_done!=True",
)
assert len(pod_names) == mock_kube_client.patch_namespaced_pod.call_count
mock_kube_client.patch_namespaced_pod.assert_has_calls(
[
mock.call(
body={"metadata": {"labels": {"airflow-worker": "modified"}}},
name=pod_name,
namespace="somens",
)
for pod_name in pod_names
],
any_order=True,
)
assert executor.running == expected_running_ti_keys
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
def test_not_adopt_unassigned_task(self, mock_kube_client):
"""
We should not adopt any tasks that were not assigned by the scheduler.
This ensures that there is no contention over pod management.
"""
executor = self.kubernetes_executor
executor.scheduler_job_id = "modified"
tis_to_flush_by_key = {"foobar": {}}
pod = k8s.V1Pod(
metadata=k8s.V1ObjectMeta(
name="foo",
labels={"airflow-worker": "bar"},
annotations={
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
},
)
)
executor.adopt_launched_task(mock_kube_client, pod=pod, tis_to_flush_by_key=tis_to_flush_by_key)
assert not mock_kube_client.patch_namespaced_pod.called
assert tis_to_flush_by_key == {"foobar": {}}
@mock.patch("airflow.executors.kubernetes_executor.get_kube_client")
@mock.patch("airflow.executors.kubernetes_executor.AirflowKubernetesScheduler.delete_pod")
def test_cleanup_stuck_queued_tasks(self, mock_delete_pod, mock_kube_client, dag_maker, session):
"""Delete any pods associated with a task stuck in queued."""
executor = KubernetesExecutor()
executor.start()
executor.scheduler_job_id = "123"
with dag_maker(dag_id="test_cleanup_stuck_queued_tasks"):
op = BashOperator(task_id="bash", bash_command=["echo 0", "echo 1"])
dag_run = dag_maker.create_dagrun()
ti = dag_run.get_task_instance(op.task_id, session)
ti.retries = 1
ti.state = State.QUEUED
ti.queued_dttm = timezone.utcnow() - timedelta(minutes=30)
ti.refresh_from_db()
tis = [ti]
executor.cleanup_stuck_queued_tasks(tis)
mock_delete_pod.assert_called_once()
assert executor.running == set()
executor.end()
@pytest.mark.parametrize(
"raw_multi_namespace_mode, raw_value_namespace_list, expected_value_in_kube_config",
[
pytest.param("true", "A,B,C", ["A", "B", "C"]),
pytest.param("true", "", None),
pytest.param("false", "A,B,C", None),
pytest.param("false", "", None),
],
)
def test_kube_config_get_namespace_list(
self, raw_multi_namespace_mode, raw_value_namespace_list, expected_value_in_kube_config
):
config = {
("kubernetes", "multi_namespace_mode"): raw_multi_namespace_mode,
("kubernetes", "multi_namespace_mode_namespace_list"): raw_value_namespace_list,
}
with conf_vars(config):
executor = KubernetesExecutor()
assert executor.kube_config.multi_namespace_mode_namespace_list == expected_value_in_kube_config
def test_clear_not_launched_queued_tasks_not_launched(self, dag_maker, create_dummy_dag, session):
"""If a pod isn't found for a TI, reset the state to scheduled"""
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value = k8s.V1PodList(items=[])
create_dummy_dag(dag_id="test_clear", task_id="task1", with_dagrun_type=None)
dag_run = dag_maker.create_dagrun()
ti = dag_run.task_instances[0]
ti.state = State.QUEUED
ti.queued_by_job_id = 1
session.flush()
executor = self.kubernetes_executor
executor.job_id = 1
executor.kube_client = mock_kube_client
executor.clear_not_launched_queued_tasks(session=session)
ti.refresh_from_db()
assert ti.state == State.SCHEDULED
assert mock_kube_client.list_namespaced_pod.call_count == 2
mock_kube_client.list_namespaced_pod.assert_any_call(
namespace="default", label_selector="dag_id=test_clear,task_id=task1,airflow-worker=1,run_id=test"
)
# also check that we fall back to execution_date if we didn't find the pod with run_id
execution_date_label = pod_generator.datetime_to_label_safe_datestring(ti.execution_date)
mock_kube_client.list_namespaced_pod.assert_called_with(
namespace="default",
label_selector=(
f"dag_id=test_clear,task_id=task1,airflow-worker=1,execution_date={execution_date_label}"
),
)
@pytest.mark.parametrize(
"task_queue, kubernetes_queue",
[
pytest.param("default", None),
pytest.param("kubernetes", None),
pytest.param("kubernetes", "kubernetes"),
],
)
def test_clear_not_launched_queued_tasks_launched(
self, dag_maker, create_dummy_dag, session, task_queue, kubernetes_queue
):
"""Leave the state alone if a pod already exists"""
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value = k8s.V1PodList(items=["something"])
create_dummy_dag(dag_id="test_clear", task_id="task1", with_dagrun_type=None)
dag_run = dag_maker.create_dagrun()
ti = dag_run.task_instances[0]
ti.state = State.QUEUED
ti.queued_by_job_id = 1
ti.queue = task_queue
session.flush()
executor = self.kubernetes_executor
executor.job_id = 1
executor.kubernetes_queue = kubernetes_queue
executor.kube_client = mock_kube_client
executor.clear_not_launched_queued_tasks(session=session)
ti.refresh_from_db()
assert ti.state == State.QUEUED
mock_kube_client.list_namespaced_pod.assert_called_once_with(
namespace="default", label_selector="dag_id=test_clear,task_id=task1,airflow-worker=1,run_id=test"
)
def test_clear_not_launched_queued_tasks_mapped_task(self, dag_maker, session):
"""One mapped task has a launched pod - other does not."""
def list_namespaced_pod(*args, **kwargs):
if "map_index=0" in kwargs["label_selector"]:
return k8s.V1PodList(items=["something"])
else:
return k8s.V1PodList(items=[])
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.side_effect = list_namespaced_pod
with dag_maker(dag_id="test_clear"):
op = BashOperator.partial(task_id="bash").expand(bash_command=["echo 0", "echo 1"])
dag_run = dag_maker.create_dagrun()
ti0 = dag_run.get_task_instance(op.task_id, session, map_index=0)
ti0.state = State.QUEUED
ti0.queued_by_job_id = 1
ti1 = dag_run.get_task_instance(op.task_id, session, map_index=1)
ti1.state = State.QUEUED
ti1.queued_by_job_id = 1
session.flush()
executor = self.kubernetes_executor
executor.job_id = 1
executor.kube_client = mock_kube_client
executor.clear_not_launched_queued_tasks(session=session)
ti0.refresh_from_db()
ti1.refresh_from_db()
assert ti0.state == State.QUEUED
assert ti1.state == State.SCHEDULED
assert mock_kube_client.list_namespaced_pod.call_count == 3
execution_date_label = pod_generator.datetime_to_label_safe_datestring(dag_run.execution_date)
mock_kube_client.list_namespaced_pod.assert_has_calls(
[
mock.call(
namespace="default",
label_selector="dag_id=test_clear,task_id=bash,airflow-worker=1,map_index=0,run_id=test",
),
mock.call(
namespace="default",
label_selector="dag_id=test_clear,task_id=bash,airflow-worker=1,map_index=1,run_id=test",
),
mock.call(
namespace="default",
label_selector=f"dag_id=test_clear,task_id=bash,airflow-worker=1,map_index=1,"
f"execution_date={execution_date_label}",
),
],
any_order=True,
)
def test_clear_not_launched_queued_tasks_not_launched_other_queue(
self, dag_maker, create_dummy_dag, session
):
"""Queued TI has no pod, but it is not queued for the k8s executor"""
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value = k8s.V1PodList(items=[])
create_dummy_dag(dag_id="test_clear", task_id="task1", with_dagrun_type=None)
dag_run = dag_maker.create_dagrun()
ti = dag_run.task_instances[0]
ti.state = State.QUEUED
ti.queued_by_job_id = 1
session.flush()
executor = self.kubernetes_executor
executor.job_id = 1
executor.kubernetes_queue = "kubernetes"
executor.kube_client = mock_kube_client
executor.clear_not_launched_queued_tasks(session=session)
ti.refresh_from_db()
assert ti.state == State.QUEUED
assert mock_kube_client.list_namespaced_pod.call_count == 0
def test_clear_not_launched_queued_tasks_clear_only_by_job_id(self, dag_maker, create_dummy_dag, session):
"""clear only not launched queued tasks which are queued by the same executor job"""
mock_kube_client = mock.MagicMock()
mock_kube_client.list_namespaced_pod.return_value = k8s.V1PodList(items=[])
create_dummy_dag(dag_id="test_clear_0", task_id="task0", with_dagrun_type=None)
dag_run = dag_maker.create_dagrun()
ti0 = dag_run.task_instances[0]
ti0.state = State.QUEUED
ti0.queued_by_job_id = 1
session.flush()
create_dummy_dag(dag_id="test_clear_1", task_id="task1", with_dagrun_type=None)
dag_run = dag_maker.create_dagrun()
ti1 = dag_run.task_instances[0]
ti1.state = State.QUEUED
ti1.queued_by_job_id = 2
session.flush()
executor = self.kubernetes_executor
executor.job_id = 1
executor.kube_client = mock_kube_client
executor.clear_not_launched_queued_tasks(session=session)
ti0.refresh_from_db()
ti1.refresh_from_db()
assert ti0.state == State.SCHEDULED
assert ti1.state == State.QUEUED
@mock.patch("airflow.kubernetes.kube_client.get_kube_client")
def test_get_task_log(self, mock_get_kube_client, create_task_instance_of_operator):
"""fetch task log from pod"""
mock_kube_client = mock_get_kube_client.return_value
mock_kube_client.read_namespaced_pod_log.return_value = [b"a_", b"b_", b"c_"]
mock_pod = mock.Mock()
mock_pod.metadata.name = "x"
mock_kube_client.list_namespaced_pod.return_value.items = [mock_pod]
ti = create_task_instance_of_operator(EmptyOperator, dag_id="test_k8s_log_dag", task_id="test_task")
executor = KubernetesExecutor()
messages, logs = executor.get_task_log(ti=ti, try_number=1)
mock_kube_client.read_namespaced_pod_log.assert_called_once()
assert messages == [
"Attempting to fetch logs from pod through kube API",
"Found logs through kube API",
]
assert logs[0] == "a_\nb_\nc_"
mock_kube_client.reset_mock()
mock_kube_client.read_namespaced_pod_log.side_effect = Exception("error_fetching_pod_log")
messages, logs = executor.get_task_log(ti=ti, try_number=1)
assert logs == [""]
assert messages == [
"Attempting to fetch logs from pod through kube API",
"Reading from k8s pod logs failed: error_fetching_pod_log",
]
def test_supports_pickling(self):
assert KubernetesExecutor.supports_pickling
def test_supports_sentry(self):
assert not KubernetesExecutor.supports_sentry
def test_annotations_for_logging_task_metadata(self):
annotations_test = {
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
get_logs_task_metadata.cache_clear()
with conf_vars({("kubernetes", "logs_task_metadata"): "True"}):
expected_annotations = {
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
annotations_actual = annotations_for_logging_task_metadata(annotations_test)
assert annotations_actual == expected_annotations
get_logs_task_metadata.cache_clear()
def test_annotations_for_logging_task_metadata_fallback(self):
annotations_test = {
"dag_id": "dag",
"run_id": "run_id",
"task_id": "task",
"try_number": "1",
}
get_logs_task_metadata.cache_clear()
with conf_vars({("kubernetes", "logs_task_metadata"): "False"}):
expected_annotations = "<omitted>"
annotations_actual = annotations_for_logging_task_metadata(annotations_test)
assert annotations_actual == expected_annotations
get_logs_task_metadata.cache_clear()
class TestKubernetesJobWatcher:
test_namespace = "airflow"
def setup_method(self):
self.watcher = KubernetesJobWatcher(
namespace=self.test_namespace,
watcher_queue=mock.MagicMock(),
resource_version="0",
scheduler_job_id="123",
kube_config=mock.MagicMock(),
)
self.kube_client = mock.MagicMock()
self.core_annotations = {
"dag_id": "dag",
"task_id": "task",
"run_id": "run_id",
"try_number": "1",
"execution_date": None,
}
self.pod = k8s.V1Pod(
metadata=k8s.V1ObjectMeta(
name="foo",
annotations={"airflow-worker": "bar", **self.core_annotations},
namespace="airflow",
resource_version="456",
labels={},
),
status=k8s.V1PodStatus(phase="Pending"),
)
self.events = []
def _run(self):
with mock.patch("airflow.executors.kubernetes_executor.watch") as mock_watch:
mock_watch.Watch.return_value.stream.return_value = self.events
latest_resource_version = self.watcher._run(
self.kube_client,
self.watcher.resource_version,
self.watcher.scheduler_job_id,
self.watcher.kube_config,
)
assert self.pod.metadata.resource_version == latest_resource_version
def assert_watcher_queue_called_once_with_state(self, state):
self.watcher.watcher_queue.put.assert_called_once_with(
(
self.pod.metadata.name,
self.watcher.namespace,
state,
self.core_annotations,
self.pod.metadata.resource_version,
)
)
def test_process_status_pending(self):
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
def test_process_status_pending_deleted(self):
self.events.append({"type": "DELETED", "object": self.pod})
self.pod.metadata.deletion_timestamp = datetime.utcnow()
self._run()
self.assert_watcher_queue_called_once_with_state(State.FAILED)
def test_process_status_failed(self):
self.pod.status.phase = "Failed"
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.assert_watcher_queue_called_once_with_state(State.FAILED)
def test_process_status_succeeded(self):
self.pod.status.phase = "Succeeded"
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
# We don't know the TI state, so we send in None
self.assert_watcher_queue_called_once_with_state(None)
def test_process_status_succeeded_dedup_label(self):
self.pod.status.phase = "Succeeded"
self.pod.metadata.labels[POD_EXECUTOR_DONE_KEY] = "True"
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
def test_process_status_succeeded_dedup_timestamp(self):
self.pod.status.phase = "Succeeded"
self.pod.metadata.deletion_timestamp = datetime.utcnow()
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
def test_process_status_succeeded_type_delete(self):
self.pod.status.phase = "Succeeded"
self.events.append({"type": "DELETED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
def test_process_status_running_deleted(self):
self.pod.status.phase = "Running"
self.pod.metadata.deletion_timestamp = datetime.utcnow()
self.events.append({"type": "DELETED", "object": self.pod})
self._run()
self.assert_watcher_queue_called_once_with_state(State.FAILED)
def test_process_status_running(self):
self.pod.status.phase = "Running"
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
def test_process_status_catchall(self):
self.pod.status.phase = "Unknown"
self.events.append({"type": "MODIFIED", "object": self.pod})
self._run()
self.watcher.watcher_queue.put.assert_not_called()
@mock.patch.object(KubernetesJobWatcher, "process_error")
def test_process_error_event_for_410(self, mock_process_error):
message = "too old resource version: 27272 (43334)"
self.pod.status.phase = "Pending"
self.pod.metadata.resource_version = "0"
mock_process_error.return_value = "0"
raw_object = {"code": 410, "message": message}
self.events.append({"type": "ERROR", "object": self.pod, "raw_object": raw_object})
self._run()
mock_process_error.assert_called_once_with(self.events[0])
def test_process_error_event_for_raise_if_not_410(self):
message = "Failure message"
self.pod.status.phase = "Pending"
raw_object = {"code": 422, "message": message, "reason": "Test"}
self.events.append({"type": "ERROR", "object": self.pod, "raw_object": raw_object})
error_message = (
rf"Kubernetes failure for {raw_object['reason']} "
rf"with code {raw_object['code']} and message: {raw_object['message']}"
)
with pytest.raises(AirflowException, match=error_message):
self._run()
def test_recover_from_resource_too_old(self):
# too old resource
mock_underscore_run = mock.MagicMock()
def effect():
yield "500"
while True:
yield Exception("sentinel")
mock_underscore_run.side_effect = effect()
self.watcher._run = mock_underscore_run
with mock.patch("airflow.executors.kubernetes_executor.get_kube_client"):
try:
# self.watcher._run() is mocked and return "500" as last resource_version
self.watcher.run()
assert False, "Should have raised Exception"
except Exception as e:
assert e.args == ("sentinel",)
# both resource_version should be 0 after _run raises an exception
assert self.watcher.resource_version == "0"
assert ResourceVersion().resource_version[self.test_namespace] == "0"
# check that in the next run, _run is invoked with resource_version = 0
mock_underscore_run.reset_mock()
try:
self.watcher.run()
except Exception as e:
assert e.args == ("sentinel",)
mock_underscore_run.assert_called_once_with(mock.ANY, "0", mock.ANY, mock.ANY)