Files
2026-07-13 13:22:34 +08:00

359 lines
13 KiB
Python

from unittest import mock
import kubernetes
import pytest
import yaml
from kubernetes.config.config_exception import ConfigException
from mlflow.entities import RunStatus
from mlflow.exceptions import ExecutionException
from mlflow.projects import kubernetes as kb
def test_run_command_creation():
command = [
"python train.py --alpha 0.5 --l1-ratio 0.1",
"--comment 'foo bar'",
'--comment-bis "bar foo"',
]
command = kb._get_run_command(command)
assert command == [
"python",
"train.py",
"--alpha",
"0.5",
"--l1-ratio",
"0.1",
"--comment",
"'foo bar'",
"--comment-bis",
"'bar foo'",
]
def test_valid_kubernetes_job_spec():
"""
Tests job specification for Kubernetes.
"""
custom_template = yaml.safe_load(
"apiVersion: batch/v1\n"
"kind: Job\n"
"metadata:\n"
" name: pi-with-ttl\n"
"spec:\n"
" ttlSecondsAfterFinished: 100\n"
" template:\n"
" spec:\n"
" containers:\n"
" - name: pi\n"
" image: perl\n"
" command: ['perl', '-Mbignum=bpi', '-wle']\n"
" env: \n"
" - name: DUMMY\n"
' value: "test_var"\n'
" restartPolicy: Never\n"
)
project_name = "mlflow-docker-example"
image_tag = "image_tag"
image_digest = "5e74a5a"
command = ["mlflow", "run", ".", "--env-manager", "local", "-P", "alpha=0.5"]
env_vars = {"RUN_ID": "1"}
job_definition = kb._get_kubernetes_job_definition(
project_name=project_name,
image_tag=image_tag,
image_digest=image_digest,
command=command,
env_vars=env_vars,
job_template=custom_template,
)
container_spec = job_definition["spec"]["template"]["spec"]["containers"][0]
assert container_spec["name"] == project_name
assert container_spec["image"] == image_tag + "@" + image_digest
assert container_spec["command"] == command
assert len(container_spec["env"]) == 2
assert container_spec["env"][0]["name"] == "DUMMY"
assert container_spec["env"][0]["value"] == "test_var"
assert container_spec["env"][1]["name"] == "RUN_ID"
assert container_spec["env"][1]["value"] == "1"
def test_run_kubernetes_job():
active_run = mock.Mock()
project_name = "mlflow-docker-example"
image_tag = "image_tag"
image_digest = "5e74a5a"
command = ["python train.py --alpha 0.5 --l1-ratio 0.1"]
env_vars = {"RUN_ID": "1"}
kube_context = "docker-for-desktop"
job_template = yaml.safe_load(
"apiVersion: batch/v1\n"
"kind: Job\n"
"metadata:\n"
" name: pi-with-ttl\n"
" namespace: mlflow\n"
"spec:\n"
" ttlSecondsAfterFinished: 100\n"
" template:\n"
" spec:\n"
" containers:\n"
" - name: pi\n"
" image: perl\n"
" command: ['perl', '-Mbignum=bpi', '-wle']\n"
" restartPolicy: Never\n"
)
with (
mock.patch("kubernetes.config.load_kube_config") as kube_config_mock,
mock.patch("kubernetes.client.BatchV1Api.create_namespaced_job") as kube_api_mock,
):
submitted_run_obj = kb.run_kubernetes_job(
project_name=project_name,
active_run=active_run,
image_tag=image_tag,
image_digest=image_digest,
command=command,
env_vars=env_vars,
job_template=job_template,
kube_context=kube_context,
)
assert submitted_run_obj._mlflow_run_id == active_run.info.run_id
assert submitted_run_obj._job_name.startswith(project_name)
assert submitted_run_obj._job_namespace == "mlflow"
assert kube_api_mock.call_count == 1
args = kube_config_mock.call_args_list
assert args[0][1]["context"] == kube_context
def test_run_kubernetes_job_current_kubecontext():
active_run = mock.Mock()
project_name = "mlflow-docker-example"
image_tag = "image_tag"
image_digest = "5e74a5a"
command = ["python train.py --alpha 0.5 --l1-ratio 0.1"]
env_vars = {"RUN_ID": "1"}
kube_context = None
job_template = yaml.safe_load(
"apiVersion: batch/v1\n"
"kind: Job\n"
"metadata:\n"
" name: pi-with-ttl\n"
" namespace: mlflow\n"
"spec:\n"
" ttlSecondsAfterFinished: 100\n"
" template:\n"
" spec:\n"
" containers:\n"
" - name: pi\n"
" image: perl\n"
" command: ['perl', '-Mbignum=bpi', '-wle']\n"
" restartPolicy: Never\n"
)
with (
mock.patch("kubernetes.config.load_kube_config") as kube_config_mock,
mock.patch("kubernetes.config.load_incluster_config") as incluster_kube_config_mock,
mock.patch("kubernetes.client.BatchV1Api.create_namespaced_job") as kube_api_mock,
):
submitted_run_obj = kb.run_kubernetes_job(
project_name=project_name,
active_run=active_run,
image_tag=image_tag,
image_digest=image_digest,
command=command,
env_vars=env_vars,
job_template=job_template,
kube_context=kube_context,
)
assert submitted_run_obj._mlflow_run_id == active_run.info.run_id
assert submitted_run_obj._job_name.startswith(project_name)
assert submitted_run_obj._job_namespace == "mlflow"
assert kube_api_mock.call_count == 1
assert kube_config_mock.call_count == 1
assert incluster_kube_config_mock.call_count == 0
def test_run_kubernetes_job_in_cluster():
active_run = mock.Mock()
project_name = "mlflow-docker-example"
image_tag = "image_tag"
image_digest = "5e74a5a"
command = ["python train.py --alpha 0.5 --l1-ratio 0.1"]
env_vars = {"RUN_ID": "1"}
kube_context = None
job_template = yaml.safe_load(
"apiVersion: batch/v1\n"
"kind: Job\n"
"metadata:\n"
" name: pi-with-ttl\n"
" namespace: mlflow\n"
"spec:\n"
" ttlSecondsAfterFinished: 100\n"
" template:\n"
" spec:\n"
" containers:\n"
" - name: pi\n"
" image: perl\n"
" command: ['perl', '-Mbignum=bpi', '-wle']\n"
" restartPolicy: Never\n"
)
with mock.patch("kubernetes.config.load_kube_config") as kube_config_mock:
kube_config_mock.side_effect = ConfigException()
with (
mock.patch("kubernetes.config.load_incluster_config") as incluster_kube_config_mock,
mock.patch("kubernetes.client.BatchV1Api.create_namespaced_job") as kube_api_mock,
):
submitted_run_obj = kb.run_kubernetes_job(
project_name=project_name,
active_run=active_run,
image_tag=image_tag,
image_digest=image_digest,
command=command,
env_vars=env_vars,
job_template=job_template,
kube_context=kube_context,
)
assert submitted_run_obj._mlflow_run_id == active_run.info.run_id
assert submitted_run_obj._job_name.startswith(project_name)
assert submitted_run_obj._job_namespace == "mlflow"
assert kube_api_mock.call_count == 1
assert kube_config_mock.call_count == 1
assert incluster_kube_config_mock.call_count == 1
def test_push_image_to_registry():
image_uri = "dockerhub_account/mlflow-kubernetes-example"
with mock.patch("docker.from_env") as docker_mock:
client = mock.MagicMock()
docker_mock.return_value = client
kb.push_image_to_registry(image_uri)
assert client.images.push.call_count == 1
args = client.images.push.call_args_list
assert args[0][1]["repository"] == image_uri
def test_push_image_to_registry_handling_errors():
image_uri = "dockerhub_account/mlflow-kubernetes-example"
with pytest.raises(
ExecutionException,
match="Error while pushing to docker registry: An image does not exist locally",
):
kb.push_image_to_registry(image_uri)
def test_submitted_run_get_status_killed():
mlflow_run_id = 1
job_name = "job-name"
job_namespace = "job-namespace"
with mock.patch("kubernetes.client.BatchV1Api.delete_namespaced_job") as kube_api_mock:
submitted_run = kb.KubernetesSubmittedRun(mlflow_run_id, job_name, job_namespace)
submitted_run.cancel()
assert RunStatus.KILLED == submitted_run.get_status()
assert kube_api_mock.call_count == 1
args = kube_api_mock.call_args_list
assert args[0][1]["name"] == job_name
assert args[0][1]["namespace"] == job_namespace
def test_submitted_run_get_status_failed():
mlflow_run_id = 1
job_name = "job-name"
job_namespace = "job-namespace"
condition = kubernetes.client.models.V1JobCondition(type="Failed", status="True")
job_status = kubernetes.client.models.V1JobStatus(
active=1,
completion_time=None,
conditions=[condition],
failed=1,
start_time=1,
succeeded=None,
)
job = kubernetes.client.models.V1Job(status=job_status)
with mock.patch(
"kubernetes.client.BatchV1Api.read_namespaced_job_status", return_value=job
) as kube_api_mock:
submitted_run = kb.KubernetesSubmittedRun(mlflow_run_id, job_name, job_namespace)
assert RunStatus.FAILED == submitted_run.get_status()
assert kube_api_mock.call_count == 1
args = kube_api_mock.call_args_list
assert args[0][1]["name"] == job_name
assert args[0][1]["namespace"] == job_namespace
def test_submitted_run_get_status_succeeded():
mlflow_run_id = 1
job_name = "job-name"
job_namespace = "job-namespace"
condition = kubernetes.client.models.V1JobCondition(type="Complete", status="True")
job_status = kubernetes.client.models.V1JobStatus(
active=None,
completion_time=None,
conditions=[condition],
failed=None,
start_time=None,
succeeded=1,
)
job = kubernetes.client.models.V1Job(status=job_status)
with mock.patch(
"kubernetes.client.BatchV1Api.read_namespaced_job_status", return_value=job
) as kube_api_mock:
submitted_run = kb.KubernetesSubmittedRun(mlflow_run_id, job_name, job_namespace)
assert RunStatus.FINISHED == submitted_run.get_status()
assert kube_api_mock.call_count == 1
args = kube_api_mock.call_args_list
assert args[0][1]["name"] == job_name
assert args[0][1]["namespace"] == job_namespace
def test_submitted_run_get_status_running():
mlflow_run_id = 1
job_name = "job-name"
job_namespace = "job-namespace"
job_status = kubernetes.client.models.V1JobStatus(
active=1, completion_time=None, conditions=None, failed=1, start_time=1, succeeded=1
)
job = kubernetes.client.models.V1Job(status=job_status)
with mock.patch(
"kubernetes.client.BatchV1Api.read_namespaced_job_status", return_value=job
) as kube_api_mock:
submitted_run = kb.KubernetesSubmittedRun(mlflow_run_id, job_name, job_namespace)
assert RunStatus.RUNNING == submitted_run.get_status()
assert kube_api_mock.call_count == 1
args = kube_api_mock.call_args_list
assert args[0][1]["name"] == job_name
assert args[0][1]["namespace"] == job_namespace
def test_state_transitions():
mlflow_run_id = 1
job_name = "job-name"
job_namespace = "job-namespace"
submitted_run = kb.KubernetesSubmittedRun(mlflow_run_id, job_name, job_namespace)
with mock.patch("kubernetes.client.BatchV1Api.read_namespaced_job_status") as kube_api_mock:
def set_return_value(**kwargs):
job_status = kubernetes.client.models.V1JobStatus(**kwargs)
kube_api_mock.return_value = kubernetes.client.models.V1Job(status=job_status)
set_return_value()
assert RunStatus.SCHEDULED == submitted_run.get_status()
set_return_value(start_time=1)
assert RunStatus.RUNNING == submitted_run.get_status()
set_return_value(start_time=1, failed=1)
assert RunStatus.RUNNING == submitted_run.get_status()
set_return_value(start_time=1, failed=1)
assert RunStatus.RUNNING == submitted_run.get_status()
set_return_value(start_time=1, failed=1, active=1)
assert RunStatus.RUNNING == submitted_run.get_status()
set_return_value(start_time=1, failed=1, succeeded=1)
assert RunStatus.RUNNING == submitted_run.get_status()
set_return_value(start_time=1, failed=1, succeeded=1, completion_time=2)
assert RunStatus.RUNNING == submitted_run.get_status()
condition = kubernetes.client.models.V1JobCondition(type="Complete", status="True")
set_return_value(
conditions=[condition], failed=1, start_time=1, completion_time=2, succeeded=1
)
assert RunStatus.FINISHED == submitted_run.get_status()