diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index b64113e4a14..b638e56b4e2 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -57,6 +57,7 @@ class KubernetesJobRunner(AsynchronousJobRunner): k8s_default_requests_memory=dict(map=str, default=None), k8s_default_limits_cpu=dict(map=str, default=None), k8s_default_limits_memory=dict(map=str, default=None), + k8s_cleanup_job=dict(map=str, valid=lambda s: s in {"onsuccess", "always", "never"}, default="always"), k8s_pod_retries=dict(map=int, valid=lambda x: int >= 0, default=3), k8s_pod_retrials=dict(map=int, valid=lambda x: int >= 0, default=3), k8s_walltime_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=172800)) @@ -406,7 +407,6 @@ class KubernetesJobRunner(AsynchronousJobRunner): active = 0 failed = 0 - max_pod_retries = 1 if 'max_pod_retries' in job_destination.params: max_pod_retries = int(job_destination.params['max_pod_retries']) elif 'k8s_pod_retries' in self.runner_params: @@ -417,6 +417,8 @@ class KubernetesJobRunner(AsynchronousJobRunner): elif 'k8s_pod_retrials' in self.runner_params: # For backward compatibility max_pod_retries = int(self.runner_params['max_pod_retrials']) + else: + max_pod_retries = 1 if 'succeeded' in job.obj['status']: succeeded = job.obj['status']['succeeded'] @@ -436,7 +438,8 @@ class KubernetesJobRunner(AsynchronousJobRunner): job_state.job_wrapper.change_state(model.Job.states.RUNNING) return job_state elif job_state.job_wrapper.get_job().state == model.Job.states.DELETED: - # Job has been deleted via stop_job, cleanup and remove from watched_jobs by returning `None` + # Job has been deleted via stop_job and job has not been deleted, + # remove from watched_jobs by returning `None` if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None @@ -444,6 +447,12 @@ class KubernetesJobRunner(AsynchronousJobRunner): return self._handle_job_failure(job, job_state) elif len(jobs.response['items']) == 0: + if job_state.job_wrapper.get_job().state == model.Job.states.DELETED: + # Job has been deleted via stop_job and job has been deleted, + # cleanup and remove from watched_jobs by returning `None` + if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): + job_state.job_wrapper.cleanup() + return None # there is no job responding to this job_id, it is either lost or something happened. log.error("No Jobs are available under expected selector app=%s", job_state.job_id) with open(job_state.error_file, 'w') as error_file: @@ -474,9 +483,25 @@ class KubernetesJobRunner(AsynchronousJobRunner): job_state.fail_message = "More pods failed than allowed. See stdout for pods details." job_state.running = False self.mark_as_failed(job_state) - job.scale(replicas=0) + self.__cleanup_k8s_job(job) return None + def __cleanup_k8s_job(self, job): + k8s_cleanup_job = self.runner_params['k8s_cleanup_job'] + job_failed = (job.obj['status']['failed'] > 0 + if 'failed' in job.obj['status'] else False) + # Scale down the job just in case even if cleanup is never + job.scale(replicas=0) + if (k8s_cleanup_job == "always" or + (k8s_cleanup_job == "onsuccess" and not job_failed)): + delete_options = { + "apiVersion": "v1", + "kind": "DeleteOptions", + "propagationPolicy": "Background" + } + r = job.api.delete(json=delete_options, **job.api_kwargs()) + job.api.raise_for_status(r) + def __job_failed_due_to_walltime_limit(self, job): conditions = job.obj['status']['conditions'] return any(True for c in conditions if c['type'] == 'Failed' and c['reason'] == 'DeadlineExceeded') @@ -509,7 +534,7 @@ class KubernetesJobRunner(AsynchronousJobRunner): namespace=self.runner_params['k8s_namespace']) if len(jobs.response['items']) >= 0: job_to_delete = Job(self._pykube_api, jobs.response['items'][0]) - job_to_delete.scale(replicas=0) + self.__cleanup_k8s_job(job_to_delete) # TODO assert whether job parallelism == 0 # assert not job_to_delete.exists(), "Could not delete job,"+job.job_runner_external_id+" it still exists" log.debug("(%s/%s) Terminated at user's request" % (job.id, job.job_runner_external_id)) @@ -541,3 +566,12 @@ class KubernetesJobRunner(AsynchronousJobRunner): ajs.old_state = model.Job.states.QUEUED ajs.running = False self.monitor_queue.put(ajs) + + def finish_job(self, job_state): + super(KubernetesJobRunner, self).finish_job(job_state) + jobs = Job.objects(self._pykube_api).filter(selector="app=" + job_state.job_id, + namespace=self.runner_params['k8s_namespace']) + # If more than one job matches selector, leave all jobs intact as it's a configuration error + if len(jobs.response['items']) == 1: + job = Job(self._pykube_api, jobs.response['items'][0]) + self.__cleanup_k8s_job(job) diff --git a/test/integration/test_kubernetes_runner.py b/test/integration/test_kubernetes_runner.py index 2880f8e0e46..d948343fe17 100644 --- a/test/integration/test_kubernetes_runner.py +++ b/test/integration/test_kubernetes_runner.py @@ -8,6 +8,8 @@ import subprocess import tempfile import time +import pytest + from base import integration_util # noqa: I100,I202 from base.populators import skip_without_tool from .test_containerized_jobs import MulledJobTestCases # noqa: I201 @@ -96,6 +98,12 @@ def job_config(jobs_directory): gx-short-id 10 + + jobs-directory-claim:$jobs_directory,tool-directory-claim:$tool_directory + $k8s_config_path + gx-short-id + never + @@ -112,12 +120,20 @@ def job_config(jobs_directory): busybox:ubuntu-14.04 42 + + 1.9 + 10M + true + busybox:ubuntu-14.04 + 42 + + """) @@ -222,8 +238,10 @@ class BaseKubernetesIntegrationTestCase(BaseJobEnvironmentIntegrationTestCase, M # Wait for job to be cancelled in kubernetes time.sleep(2) - status = json.loads(subprocess.check_output(['kubectl', 'get', 'job', external_id, '-o', 'json'])) - assert 'active' not in status['status'] + # The default job config removes jobs, didn't find a better way to check that the job doesn't exist anymore + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.check_output(['kubectl', 'get', 'job', external_id, '-o', 'json'], stderr=subprocess.STDOUT) + assert "not found" in excinfo.value.output.decode() @skip_without_tool('job_properties') def test_exit_code_127(self): @@ -258,7 +276,7 @@ class BaseKubernetesIntegrationTestCase(BaseJobEnvironmentIntegrationTestCase, M @skip_without_tool('galaxy_slots_and_memory') def test_slots_and_memory(self): - self.dataset_populator.run_tool( + running_response = self.dataset_populator.run_tool( 'galaxy_slots_and_memory', {}, self.history_id, @@ -270,6 +288,13 @@ class BaseKubernetesIntegrationTestCase(BaseJobEnvironmentIntegrationTestCase, M MEM_PER_SLOT = '5' assert [CPU, MEM, MEM_PER_SLOT] == dataset_content.split('\n'), dataset_content + # Tool is mapped to destination without cleanup, make sure job still exists in kubernetes API + job_dict = running_response["jobs"][0] + job = self.galaxy_interactor.get("jobs/%s" % job_dict['id'], admin=True).json() + external_id = job['external_id'] + status = json.loads(subprocess.check_output(['kubectl', 'get', 'job', external_id, '-o', 'json'])) + assert 'active' not in status['status'] + @skip_without_tool('create_2') def test_walltime_limit(self): running_response = self.dataset_populator.run_tool(