Source code for reana_job_controller.kubernetes_job_manager

# This file is part of REANA.
# Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026 CERN.
#
# REANA is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Kubernetes Job Manager."""

import ast
import logging
import os
import traceback
from typing import Optional

from flask import current_app
from kubernetes import client
from kubernetes.client.models.v1_delete_options import V1DeleteOptions
from kubernetes.client.rest import ApiException
from reana_commons.config import (
    K8S_CERN_EOS_AVAILABLE,
    K8S_CERN_EOS_MOUNT_CONFIGURATION,
    K8S_USE_SECURITY_CONTEXT,
    KRB5_STATUS_FILE_LOCATION,
    REANA_JOB_HOSTPATH_MOUNTS,
    REANA_RUNTIME_KUBERNETES_NAMESPACE,
    REANA_RUNTIME_JOBS_KUBERNETES_NODE_LABEL,
    WORKFLOW_RUNTIME_USER_GID,
    WORKFLOW_RUNTIME_USER_UID,
)
from reana_commons.errors import (
    REANAKubernetesMemoryLimitExceeded,
    REANAKubernetesWrongMemoryFormat,
    REANAKubernetesCPULimitExceeded,
    REANAKubernetesWrongCPUFormat,
    REANAKubernetesRequestExceedsLimit,
    REANAKubernetesUIDBelowMinimum,
)
from reana_commons.job_utils import (
    validate_kubernetes_memory,
    kubernetes_memory_to_bytes,
    validate_kubernetes_cpu,
    kubernetes_cpu_to_millicores,
)
from reana_commons.k8s.api_client import (
    current_k8s_batchv1_api_client,
    current_k8s_corev1_api_client,
)
from reana_commons.k8s.kerberos import get_kerberos_k8s_config
from reana_commons.k8s.secrets import UserSecretsStore, UserSecrets
from reana_commons.k8s.volumes import (
    get_k8s_cvmfs_volumes,
    get_reana_shared_volume,
    get_workspace_volume,
)
from reana_commons.utils import build_unique_component_name
from retrying import retry

from reana_job_controller.config import (
    REANA_KUBERNETES_JOBS_CPU_REQUEST,
    REANA_KUBERNETES_JOBS_CPU_LIMIT,
    REANA_KUBERNETES_JOBS_MEMORY_REQUEST,
    REANA_KUBERNETES_JOBS_MEMORY_LIMIT,
    REANA_KUBERNETES_JOBS_MAX_USER_CPU_REQUEST,
    REANA_KUBERNETES_JOBS_MAX_USER_CPU_LIMIT,
    REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_REQUEST,
    REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT,
    REANA_KUBERNETES_JOBS_MIN_USER_UID,
    REANA_USER_ID,
    KUEUE_ENABLED,
    KUEUE_LOCAL_QUEUE_NAME,
)
from reana_job_controller.errors import ComputingBackendSubmissionError
from reana_job_controller.job_manager import JobManager


def _restricted_container_security_context(
    kubernetes_uid: Optional[int] = None,
    kubernetes_gid: int = WORKFLOW_RUNTIME_USER_GID,
) -> dict:
    """Return a PSA-restricted container security context."""
    security_context = {
        "runAsNonRoot": True,
        "allowPrivilegeEscalation": False,
        "capabilities": {"drop": ["ALL"]},
        "seccompProfile": {"type": "RuntimeDefault"},
    }
    if kubernetes_uid is not None:
        security_context["runAsUser"] = int(kubernetes_uid)
        security_context["runAsGroup"] = int(kubernetes_gid)
    return security_context


def _normalize_kerberos_container_security_context(
    kerberos_config, kubernetes_uid: int
):
    """Backfill missing Kerberos security-context fields from older commons releases."""
    if not K8S_USE_SECURITY_CONTEXT:
        return kerberos_config

    for container_name in ("init_container", "renew_container"):
        container = getattr(kerberos_config, container_name, None)
        if not container:
            continue

        expected_security_context = _restricted_container_security_context(
            kubernetes_uid
        )
        if "securityContext" not in container:
            container["securityContext"] = expected_security_context
            continue

        for field, value in expected_security_context.items():
            if field not in container["securityContext"]:
                container["securityContext"][field] = value

    return kerberos_config


def _get_compatible_kerberos_k8s_config(secrets, kubernetes_uid: int):
    """Return Kerberos k8s config across released and unreleased commons APIs."""
    try:
        kerberos_config = get_kerberos_k8s_config(
            secrets,
            kubernetes_uid=kubernetes_uid,
            use_security_context=K8S_USE_SECURITY_CONTEXT,
        )
    except TypeError as exc:
        if "unexpected keyword argument 'use_security_context'" not in str(exc):
            raise
        kerberos_config = get_kerberos_k8s_config(
            secrets,
            kubernetes_uid=kubernetes_uid,
        )
    return _normalize_kerberos_container_security_context(
        kerberos_config,
        kubernetes_uid,
    )


[docs] class KubernetesJobManager(JobManager): """Kubernetes job management.""" MAX_NUM_RESUBMISSIONS = 3 """Maximum number of job submission/creation tries """ MAX_NUM_JOB_RESTARTS = 0 """Maximum number of job restarts in case of internal failures.""" def __init__( self, docker_img=None, cmd=None, prettified_cmd=None, env_vars=None, workflow_uuid=None, workflow_workspace=None, cvmfs_mounts="false", shared_file_system=False, job_name=None, kerberos=False, kubernetes_uid=None, kubernetes_cpu_request=None, kubernetes_cpu_limit=None, kubernetes_memory_request=None, kubernetes_memory_limit=None, voms_proxy=False, rucio=False, kubernetes_job_timeout: Optional[int] = None, secrets: Optional[UserSecrets] = None, **kwargs, ): """Instantiate kubernetes job manager. :param docker_img: Docker image. :type docker_img: str :param cmd: Command to execute. :type cmd: list :param prettified_cmd: pretified version of command to execute. :type prettified_cmd: str :param env_vars: Environment variables. :type env_vars: dict :param workflow_uuid: Unique workflow id. :type workflow_uuid: str :param workflow_workspace: Workflow workspace path. :type workflow_workspace: str :param cvmfs_mounts: list of CVMFS mounts as a string. :type cvmfs_mounts: str :param shared_file_system: if shared file system is available. :type shared_file_system: bool :param job_name: Name of the job. :type job_name: str :param kerberos: Decides if kerberos should be provided for job. :type kerberos: bool :param kubernetes_uid: UID for job container. :type kubernetes_uid: int :param kubernetes_memory_limit: Memory limit for job container. :type kubernetes_memory_limit: str :param kubernetes_job_timeout: Job timeout in seconds. :type kubernetes_job_timeout: int :param voms_proxy: Decides if a voms-proxy certificate should be provided for job. :type voms_proxy: bool :param rucio: Decides if a rucio environment should be provided for job. :type rucio: bool :param secrets: User secrets, if none they will be fetched from k8s. :type secrets: Optional[UserSecrets] """ super(KubernetesJobManager, self).__init__( docker_img=docker_img, cmd=cmd, prettified_cmd=prettified_cmd, env_vars=env_vars, workflow_uuid=workflow_uuid, workflow_workspace=workflow_workspace, job_name=job_name, ) self.compute_backend = "Kubernetes" self.cvmfs_mounts = cvmfs_mounts self.shared_file_system = shared_file_system self.kerberos = kerberos self.voms_proxy = voms_proxy self.rucio = rucio self.set_user_id(kubernetes_uid) self.workflow_uuid = workflow_uuid self.kubernetes_job_timeout = kubernetes_job_timeout self._secrets: Optional[UserSecrets] = secrets self.set_cpu_request(kubernetes_cpu_request) self.set_cpu_limit(kubernetes_cpu_limit) self.set_memory_request(kubernetes_memory_request) self.set_memory_limit(kubernetes_memory_limit) @property def secrets(self): """Get cached secrets if present, otherwise fetch them from k8s.""" if self._secrets is None: self._secrets = UserSecretsStore.fetch(REANA_USER_ID) return self._secrets @JobManager.execution_hook def execute(self): """Execute a job in Kubernetes.""" backend_job_id = build_unique_component_name("run-job") self.job = { "kind": "Job", "apiVersion": "batch/v1", "metadata": { "name": backend_job_id, "namespace": REANA_RUNTIME_KUBERNETES_NAMESPACE, "labels": ( {"kueue.x-k8s.io/queue-name": KUEUE_LOCAL_QUEUE_NAME} if KUEUE_ENABLED else {} ), }, "spec": { "backoffLimit": KubernetesJobManager.MAX_NUM_JOB_RESTARTS, "autoSelector": True, "template": { "metadata": { "name": backend_job_id, "labels": { "reana-run-job-workflow-uuid": self.workflow_uuid, "user-uuid": REANA_USER_ID, }, }, "spec": { "automountServiceAccountToken": False, "containers": [ { "image": self.docker_img, "command": ["bash", "-c"], "args": [self.cmd], "name": "job", "env": [], "securityContext": {"allowPrivilegeEscalation": False}, "volumeMounts": [], } ], "initContainers": [], "volumes": [], "restartPolicy": "Never", # No need to wait a long time for jobs to gracefully terminate "terminationGracePeriodSeconds": 5, "enableServiceLinks": False, }, }, }, } if K8S_USE_SECURITY_CONTEXT: self.job["spec"]["template"]["spec"]["containers"][0][ "securityContext" ] = _restricted_container_security_context() secret_env_vars = self.secrets.get_env_secrets_as_k8s_spec() job_spec = self.job["spec"]["template"]["spec"] job_spec["containers"][0]["env"].extend(secret_env_vars) job_spec["volumes"].append(self.secrets.get_file_secrets_volume_as_k8s_specs()) secrets_volume_mount = self.secrets.get_secrets_volume_mount_as_k8s_spec() job_spec["containers"][0]["volumeMounts"].append(secrets_volume_mount) if self.env_vars: for var, value in self.env_vars.items(): job_spec["containers"][0]["env"].append({"name": var, "value": value}) self.add_resource_requests_and_limits(job_spec) self.add_hostpath_volumes() self.add_workspace_volume() self.add_shared_volume() self.add_eos_volume() self.add_image_pull_secrets() self.add_kubernetes_job_timeout() if self.cvmfs_mounts != "false": cvmfs_repositories = ast.literal_eval(self.cvmfs_mounts) volume_mounts, volumes = get_k8s_cvmfs_volumes(cvmfs_repositories) job_spec["containers"][0]["volumeMounts"].extend(volume_mounts) job_spec["volumes"].extend(volumes) if K8S_USE_SECURITY_CONTEXT: self.job["spec"]["template"]["spec"]["securityContext"] = ( client.V1PodSecurityContext( run_as_group=int(WORKFLOW_RUNTIME_USER_GID), run_as_user=int(self.kubernetes_uid), run_as_non_root=True, ) ) if self.kerberos: self._add_krb5_containers(self.secrets) if self.voms_proxy: self._add_voms_proxy_init_container(secrets_volume_mount, secret_env_vars) if self.rucio: self._add_rucio_init_container(secrets_volume_mount, secret_env_vars) if REANA_RUNTIME_JOBS_KUBERNETES_NODE_LABEL: self.job["spec"]["template"]["spec"][ "nodeSelector" ] = REANA_RUNTIME_JOBS_KUBERNETES_NODE_LABEL backend_job_id = self._submit() return backend_job_id @retry(stop_max_attempt_number=MAX_NUM_RESUBMISSIONS) def _submit(self): """Submit job and return its backend id.""" try: current_k8s_batchv1_api_client.create_namespaced_job( namespace=REANA_RUNTIME_KUBERNETES_NAMESPACE, body=self.job ) return self.job["metadata"]["name"] except ApiException: logging.exception( "An error has occurred while connecting to the Kubernetes API to submit a job" ) raise except Exception: logging.exception("Unexpected error while submitting a job") raise @classmethod def _get_containers_logs(cls, job_pod) -> Optional[str]: """Fetch the logs from all the containers in the given pod. :param job_pod: Pod resource coming from Kubernetes. """ try: pod_logs = "" container_statuses = (job_pod.status.container_statuses or []) + ( job_pod.status.init_container_statuses or [] ) logging.info(f"Grabbing pod {job_pod.metadata.name} logs ...") for container in container_statuses: # If we are here, it means that either all the containers have finished # running or there has been some sort of failure. For this reason we get # the logs of all containers, even if they are still running, as the job # will not continue running after this anyway. if container.state.terminated or container.state.running: # Read raw response body (``_preload_content=False``) and # decode it ourselves: kubernetes 36.x applies ``str()`` to # ``bytes`` payloads in its ``response_type='str'`` # deserialiser, producing ``b'...'`` repr strings instead # of UTF-8 text. pod_log_response = ( current_k8s_corev1_api_client.read_namespaced_pod_log( namespace=REANA_RUNTIME_KUBERNETES_NAMESPACE, name=job_pod.metadata.name, container=container.name, _preload_content=False, ) ) container_log = pod_log_response.data.decode( "utf-8", errors="replace" ) pod_logs += "{}: :\n {}\n".format(container.name, container_log) if hasattr(container.state.terminated, "reason"): if container.state.terminated.reason != "Completed": message = "Job pod {} was terminated, reason: {}, message: {}".format( job_pod.metadata.name, container.state.terminated.reason, container.state.terminated.message, ) logging.warn(message) pod_logs += "\n{}\n".format(container.state.terminated.reason) elif container.state.waiting: # No need to fetch logs, as the container has not started yet. message = "Container {} failed, error: {}".format( container.name, container.state.waiting.message ) logging.warn(message) pod_logs += message return pod_logs except client.rest.ApiException as e: logging.error(f"Error from Kubernetes API while getting job logs: {e}") return None except Exception as e: logging.error(traceback.format_exc()) logging.error("Unexpected error: {}".format(e)) return None
[docs] @classmethod def get_logs(cls, backend_job_id, **kwargs): """Return job logs. :param backend_job_id: ID of the job in the backend. :param kwargs: Additional parameters needed to fetch logs. In the case of Kubernetes, the ``job_pod`` parameter can be specified to avoid fetching the pod specification from Kubernetes. :return: String containing the job logs. """ if "job_pod" in kwargs: job_pod = kwargs["job_pod"] assert ( job_pod.metadata.labels["job-name"] == backend_job_id ), "Pod does not refer to correct job." else: job_pods = current_k8s_corev1_api_client.list_namespaced_pod( namespace=REANA_RUNTIME_KUBERNETES_NAMESPACE, label_selector=f"job-name={backend_job_id}", ) if not job_pods.items: logging.error(f"Could not find any pod for job {backend_job_id}") return None job_pod = job_pods.items[0] logs = cls._get_containers_logs(job_pod) if job_pod.status.reason == "DeadlineExceeded": if not logs: logs = "" message = ( f"{job_pod.status.reason}: The job was killed due to exceeding timeout" ) try: specified_timeout = job_pod.spec.active_deadline_seconds message += f" of {specified_timeout} seconds." except AttributeError: message += "." logging.error( f"Kubernetes job id: {backend_job_id}. Could not get job timeout from Job spec." ) logs += "\n{message}\n" logging.warn(message) logging.warn( f"Kubernetes job id: {backend_job_id} was killed due to timeout." ) return logs
[docs] def stop(backend_job_id, asynchronous=True): """Stop Kubernetes job execution. :param backend_job_id: Kubernetes job id. :param asynchronous: Whether the function waits for the action to be performed or does it asynchronously. """ try: propagation_policy = "Background" if asynchronous else "Foreground" delete_options = V1DeleteOptions(propagation_policy=propagation_policy) current_k8s_batchv1_api_client.delete_namespaced_job( backend_job_id, REANA_RUNTIME_KUBERNETES_NAMESPACE, body=delete_options ) except ApiException as e: logging.error( f"An error has occurred while connecting to Kubernetes API to stop a job: {e}" ) raise ComputingBackendSubmissionError(e.reason)
[docs] def add_kubernetes_job_timeout(self): """Add job timeout to the job spec.""" if self.kubernetes_job_timeout: self.job["spec"]["template"]["spec"][ "activeDeadlineSeconds" ] = self.kubernetes_job_timeout
[docs] def add_workspace_volume(self): """Add workspace volume to a given job spec.""" volume_mount, volume = get_workspace_volume(self.workflow_workspace) self.add_volumes([(volume_mount, volume)])
[docs] def add_shared_volume(self): """Add shared CephFS volume to a given job spec.""" if self.shared_file_system: shared_volume = get_reana_shared_volume() # check if shared_volume is not already added if not any( v["name"] == shared_volume["name"] for v in self.job["spec"]["template"]["spec"]["volumes"] ): self.job["spec"]["template"]["spec"]["volumes"].append(shared_volume)
[docs] def add_eos_volume(self): """Add EOS volume to a given job spec.""" if K8S_CERN_EOS_AVAILABLE: self.add_volumes( [ ( K8S_CERN_EOS_MOUNT_CONFIGURATION["volumeMounts"], K8S_CERN_EOS_MOUNT_CONFIGURATION["volume"], ) ] )
[docs] def add_image_pull_secrets(self): """Attach to the container the configured image pull secrets.""" image_pull_secrets = [] for secret_name in current_app.config["IMAGE_PULL_SECRETS"]: if secret_name: image_pull_secrets.append({"name": secret_name}) self.job["spec"]["template"]["spec"]["imagePullSecrets"] = image_pull_secrets
[docs] def validate_resources(self): """Validate that resource requests are less than or equal to limits.""" if self.kubernetes_cpu_request and self.kubernetes_cpu_limit: cpu_request = kubernetes_cpu_to_millicores(self.kubernetes_cpu_request) cpu_limit = kubernetes_cpu_to_millicores(self.kubernetes_cpu_limit) if cpu_request > cpu_limit: raise REANAKubernetesRequestExceedsLimit( f"ERROR: CPU request ({self.kubernetes_cpu_request}) cannot be greater than limit ({self.kubernetes_cpu_limit}). If you are overriding the values, please check the default and maximum values for requests and limits with 'reana-client info' command." ) if self.kubernetes_memory_request and self.kubernetes_memory_limit: memory_request = kubernetes_memory_to_bytes(self.kubernetes_memory_request) memory_limit = kubernetes_memory_to_bytes(self.kubernetes_memory_limit) if memory_request > memory_limit: raise REANAKubernetesRequestExceedsLimit( f"ERROR: Memory request ({self.kubernetes_memory_request}) cannot be greater than limit ({self.kubernetes_memory_limit}). If you are overriding the values, please check the default and maximum values for requests and limits with 'reana-client info' command." )
[docs] def add_resource_requests_and_limits(self, job_spec): """Add resource requests and limits to job accordingly.""" self.validate_resources() resources = {} if self.kubernetes_cpu_request or self.kubernetes_memory_request: resources["requests"] = {} if self.kubernetes_cpu_request: resources["requests"]["cpu"] = self.kubernetes_cpu_request if self.kubernetes_memory_request: resources["requests"]["memory"] = self.kubernetes_memory_request if self.kubernetes_cpu_limit or self.kubernetes_memory_limit: resources["limits"] = {} if self.kubernetes_cpu_limit: resources["limits"]["cpu"] = self.kubernetes_cpu_limit if self.kubernetes_memory_limit: resources["limits"]["memory"] = self.kubernetes_memory_limit if resources: job_spec["containers"][0]["resources"] = resources
[docs] def add_hostpath_volumes(self): """Add hostPath mounts from configuration to job.""" volumes_to_mount = [] for mount in REANA_JOB_HOSTPATH_MOUNTS: volume_mount = { "name": mount["name"], "mountPath": mount.get("mountPath", mount["hostPath"]), } volume = {"name": mount["name"], "hostPath": {"path": mount["hostPath"]}} volumes_to_mount.append((volume_mount, volume)) self.add_volumes(volumes_to_mount)
[docs] def add_volumes(self, volumes): """Add provided volumes to job. :param volumes: A list of tuple composed 1st of a Kubernetes volumeMount spec and 2nd of Kubernetes volume spec. """ for volume_mount, volume in volumes: self.job["spec"]["template"]["spec"]["containers"][0][ "volumeMounts" ].append(volume_mount) self.job["spec"]["template"]["spec"]["volumes"].append(volume)
def _add_krb5_containers(self, secrets): """Add krb5 init and renew containers for a job.""" krb5_config = _get_compatible_kerberos_k8s_config( secrets, kubernetes_uid=self.kubernetes_uid, ) self.job["spec"]["template"]["spec"]["volumes"].extend(krb5_config.volumes) self.job["spec"]["template"]["spec"]["containers"][0]["volumeMounts"].extend( krb5_config.volume_mounts ) # Add the Kerberos token cache file location to the job container # so every instance of Kerberos picks it up even if it doesn't read # the configuration file. self.job["spec"]["template"]["spec"]["containers"][0]["env"].extend( krb5_config.env ) # Add Kerberos init container used to generate ticket self.job["spec"]["template"]["spec"]["initContainers"].append( krb5_config.init_container ) # Add Kerberos renew container to renew ticket periodically for long-running jobs self.job["spec"]["template"]["spec"]["containers"].append( krb5_config.renew_container ) # Extend the main job command to create a file after it's finished self.job["spec"]["template"]["spec"]["containers"][0]["args"] = [ f"trap 'touch {KRB5_STATUS_FILE_LOCATION}' EXIT; " + self.cmd ] def _add_voms_proxy_init_container(self, secrets_volume_mount, secret_env_vars): """Add sidecar container for a job.""" ticket_cache_volume = {"name": "voms-proxy-cache", "emptyDir": {}} volume_mounts = [ { "name": ticket_cache_volume["name"], "mountPath": current_app.config["VOMSPROXY_CERT_CACHE_LOCATION"], } ] voms_proxy_file_path = os.path.join( current_app.config["VOMSPROXY_CERT_CACHE_LOCATION"], current_app.config["VOMSPROXY_CERT_CACHE_FILENAME"], ) voms_proxy_vo = os.environ.get("VONAME", "") voms_proxy_user_file = os.environ.get("VOMSPROXY_FILE", "") if voms_proxy_user_file: # multi-user deployment mode, where we rely on VOMS proxy file supplied by the user voms_proxy_container = { "image": current_app.config["VOMSPROXY_CONTAINER_IMAGE"], "command": ["/bin/bash"], "args": [ "-c", 'if [ ! -f "/etc/reana/secrets/{voms_proxy_user_file}" ]; then \ echo "[ERROR] VOMSPROXY_FILE {voms_proxy_user_file} does not exist in user secrets."; \ exit; \ fi; \ cp /etc/reana/secrets/{voms_proxy_user_file} {voms_proxy_file_path}'.format( voms_proxy_user_file=voms_proxy_user_file, voms_proxy_file_path=voms_proxy_file_path, ), ], "name": current_app.config["VOMSPROXY_CONTAINER_NAME"], "imagePullPolicy": "IfNotPresent", "volumeMounts": [secrets_volume_mount] + volume_mounts, "env": secret_env_vars, } if K8S_USE_SECURITY_CONTEXT: voms_proxy_container["securityContext"] = ( _restricted_container_security_context(self.kubernetes_uid) ) else: # single-user deployment mode, where we generate VOMS proxy file in the sidecar from user secrets voms_proxy_container = { "image": current_app.config["VOMSPROXY_CONTAINER_IMAGE"], "command": ["/bin/bash"], "args": [ "-c", 'if [ ! -f "/etc/reana/secrets/userkey.pem" ]; then \ echo "[ERROR] File userkey.pem does not exist in user secrets."; \ exit; \ fi; \ if [ ! -f "/etc/reana/secrets/usercert.pem" ]; then \ echo "[ERROR] File usercert.pem does not exist in user secrets."; \ exit; \ fi; \ if [ -z "$VOMSPROXY_PASS" ]; then \ echo "[ERROR] Environment variable VOMSPROXY_PASS is not set in user secrets."; \ exit; \ fi; \ if [ -z "$VONAME" ]; then \ echo "[ERROR] Environment variable VONAME is not set in user secrets."; \ exit; \ fi; \ cp /etc/reana/secrets/userkey.pem /tmp/userkey.pem; \ chmod 400 /tmp/userkey.pem; \ echo $VOMSPROXY_PASS | base64 -d | voms-proxy-init \ --voms {voms_proxy_vo} --key /tmp/userkey.pem \ --cert $(readlink -f /etc/reana/secrets/usercert.pem) \ --pwstdin --out {voms_proxy_file_path}'.format( voms_proxy_vo=voms_proxy_vo.lower(), voms_proxy_file_path=voms_proxy_file_path, ), ], "name": current_app.config["VOMSPROXY_CONTAINER_NAME"], "imagePullPolicy": "IfNotPresent", "volumeMounts": [secrets_volume_mount] + volume_mounts, "env": secret_env_vars, } if K8S_USE_SECURITY_CONTEXT: voms_proxy_container["securityContext"] = ( _restricted_container_security_context(self.kubernetes_uid) ) self.job["spec"]["template"]["spec"]["volumes"].extend([ticket_cache_volume]) self.job["spec"]["template"]["spec"]["containers"][0]["volumeMounts"].extend( volume_mounts ) # XrootD will look for a valid grid proxy in the location pointed to # by the environment variable $X509_USER_PROXY self.job["spec"]["template"]["spec"]["containers"][0]["env"].append( {"name": "X509_USER_PROXY", "value": voms_proxy_file_path} ) self.job["spec"]["template"]["spec"]["initContainers"].append( voms_proxy_container ) def _add_rucio_init_container(self, secrets_volume_mount, secret_env_vars): """Add sidecar container for a job.""" ticket_cache_volume = {"name": "rucio-cache", "emptyDir": {}} volume_mounts = [ { "name": ticket_cache_volume["name"], "mountPath": current_app.config["RUCIO_CACHE_LOCATION"], } ] rucio_config_file_path = os.path.join( current_app.config["RUCIO_CACHE_LOCATION"], current_app.config["RUCIO_CFG_CACHE_FILENAME"], ) cern_bundle_path = os.path.join( current_app.config["RUCIO_CACHE_LOCATION"], current_app.config["RUCIO_CERN_BUNDLE_CACHE_FILENAME"], ) rucio_account = os.environ.get("RUCIO_USERNAME", "") voms_proxy_vo = os.environ.get("VONAME", "") # Detect Rucio hosts from VO names if voms_proxy_vo == "atlas": rucio_host = "https://voatlasrucio-server-prod.cern.ch" rucio_auth_host = "https://voatlasrucio-auth-prod.cern.ch" else: rucio_host = f"https://{voms_proxy_vo}-rucio.cern.ch" rucio_auth_host = f"https://{voms_proxy_vo}-rucio-auth.cern.ch" # Allow overriding detected Rucio hosts by user-provided environment variables rucio_host = os.environ.get("RUCIO_RUCIO_HOST", rucio_host) rucio_auth_host = os.environ.get("RUCIO_AUTH_HOST", rucio_auth_host) rucio_config_container = { "image": current_app.config["RUCIO_CONTAINER_IMAGE"], "command": ["/bin/bash"], "args": [ "-c", 'if [ -z "$VONAME" ]; then \ echo "[ERROR] Environment variable VONAME is not set in user secrets."; \ exit; \ fi; \ if [ -z "$RUCIO_USERNAME" ]; then \ echo "[ERROR] Environment variable RUCIO_USERNAME is not set in user secrets."; \ exit; \ fi; \ export RUCIO_CFG_ACCOUNT={rucio_account} \ RUCIO_CFG_CLIENT_VO={voms_proxy_vo} \ RUCIO_CFG_RUCIO_HOST={rucio_host} \ RUCIO_CFG_AUTH_HOST={rucio_auth_host}; \ cp /etc/pki/tls/certs/CERN-bundle.pem {cern_bundle_path}; \ j2 /opt/user/rucio.cfg.j2 > {rucio_config_file_path}'.format( rucio_host=rucio_host, rucio_auth_host=rucio_auth_host, rucio_account=rucio_account, voms_proxy_vo=voms_proxy_vo, cern_bundle_path=cern_bundle_path, rucio_config_file_path=rucio_config_file_path, ), ], "name": current_app.config["RUCIO_CONTAINER_NAME"], "imagePullPolicy": "IfNotPresent", "volumeMounts": [secrets_volume_mount] + volume_mounts, "env": secret_env_vars, } if K8S_USE_SECURITY_CONTEXT: rucio_config_container["securityContext"] = ( _restricted_container_security_context(self.kubernetes_uid) ) self.job["spec"]["template"]["spec"]["volumes"].extend([ticket_cache_volume]) self.job["spec"]["template"]["spec"]["containers"][0]["volumeMounts"].extend( volume_mounts ) self.job["spec"]["template"]["spec"]["containers"][0]["env"].append( {"name": "RUCIO_CONFIG", "value": rucio_config_file_path} ) self.job["spec"]["template"]["spec"]["initContainers"].append( rucio_config_container )
[docs] def set_user_id(self, kubernetes_uid): """Set UID for job pods. UIDs below the cluster-configured minimum are refused for security. """ if kubernetes_uid is None: self.kubernetes_uid = int(WORKFLOW_RUNTIME_USER_UID) return kubernetes_uid = int(kubernetes_uid) min_user_uid = int(REANA_KUBERNETES_JOBS_MIN_USER_UID) if kubernetes_uid < min_user_uid: msg = ( f'The "kubernetes_uid" requested ({kubernetes_uid}) is below ' f"the minimum allowed UID ({min_user_uid})." ) raise REANAKubernetesUIDBelowMinimum(msg) self.kubernetes_uid = kubernetes_uid
[docs] def set_cpu_request(self, kubernetes_cpu_request): """Set CPU request for job pods. Validate if provided format is correct.""" if kubernetes_cpu_request: if not validate_kubernetes_cpu(kubernetes_cpu_request): msg = f'The "kubernetes_cpu_request" requested {kubernetes_cpu_request} has wrong format.' logging.error( "Error while validating Kubernetes CPU request: {}".format(msg) ) raise REANAKubernetesWrongCPUFormat(msg) if REANA_KUBERNETES_JOBS_MAX_USER_CPU_REQUEST: custom_job_cpu_request = kubernetes_cpu_to_millicores( kubernetes_cpu_request ) max_custom_job_cpu_request = kubernetes_cpu_to_millicores( REANA_KUBERNETES_JOBS_MAX_USER_CPU_REQUEST ) if custom_job_cpu_request > max_custom_job_cpu_request: msg = f'The "kubernetes_cpu_request" requested ({kubernetes_cpu_request}) exceeds the limit ({REANA_KUBERNETES_JOBS_MAX_USER_CPU_REQUEST}).' raise REANAKubernetesCPULimitExceeded(msg) self.kubernetes_cpu_request = ( kubernetes_cpu_request or REANA_KUBERNETES_JOBS_CPU_REQUEST )
[docs] def set_cpu_limit(self, kubernetes_cpu_limit): """Set CPU limit for job pods. Validate if provided format is correct.""" if kubernetes_cpu_limit: if not validate_kubernetes_cpu(kubernetes_cpu_limit): msg = f'The "kubernetes_cpu_limit" requested {kubernetes_cpu_limit} has wrong format.' logging.error( "Error while validating Kubernetes CPU limit: {}".format(msg) ) raise REANAKubernetesWrongCPUFormat(msg) if REANA_KUBERNETES_JOBS_MAX_USER_CPU_LIMIT: custom_job_cpu_limit = kubernetes_cpu_to_millicores( kubernetes_cpu_limit ) max_custom_job_cpu_limit = kubernetes_cpu_to_millicores( REANA_KUBERNETES_JOBS_MAX_USER_CPU_LIMIT ) if custom_job_cpu_limit > max_custom_job_cpu_limit: msg = f'The "kubernetes_cpu_limit" requested ({kubernetes_cpu_limit}) exceeds the limit ({REANA_KUBERNETES_JOBS_MAX_USER_CPU_LIMIT}).' raise REANAKubernetesCPULimitExceeded(msg) self.kubernetes_cpu_limit = ( kubernetes_cpu_limit or REANA_KUBERNETES_JOBS_CPU_LIMIT )
[docs] def set_memory_request(self, kubernetes_memory_request): """Set memory request for job pods. Validate if provided format is correct.""" if kubernetes_memory_request: if not validate_kubernetes_memory(kubernetes_memory_request): msg = f'The "kubernetes_memory_request" requested {kubernetes_memory_request} has wrong format.' logging.error( "Error while validating Kubernetes memory request: {}".format(msg) ) raise REANAKubernetesWrongMemoryFormat(msg) if REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_REQUEST: custom_job_memory_request_bytes = kubernetes_memory_to_bytes( kubernetes_memory_request ) max_custom_job_memory_request_bytes = kubernetes_memory_to_bytes( REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_REQUEST ) if ( custom_job_memory_request_bytes > max_custom_job_memory_request_bytes ): msg = f'The "kubernetes_memory_request" requested ({kubernetes_memory_request}) exceeds the limit ({REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_REQUEST}).' raise REANAKubernetesMemoryLimitExceeded(msg) self.kubernetes_memory_request = ( kubernetes_memory_request or REANA_KUBERNETES_JOBS_MEMORY_REQUEST )
[docs] def set_memory_limit(self, kubernetes_memory_limit): """Set memory limit for job pods. Validate if provided format is correct.""" if kubernetes_memory_limit: if not validate_kubernetes_memory(kubernetes_memory_limit): msg = f'The "kubernetes_memory_limit" requested {kubernetes_memory_limit} has wrong format.' logging.error( "Error while validating Kubernetes memory limit: {}".format(msg) ) raise REANAKubernetesWrongMemoryFormat(msg) if REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT: custom_job_memory_limit_bytes = kubernetes_memory_to_bytes( kubernetes_memory_limit ) max_custom_job_memory_limit_bytes = kubernetes_memory_to_bytes( REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT ) if custom_job_memory_limit_bytes > max_custom_job_memory_limit_bytes: msg = f'The "kubernetes_memory_limit" requested ({kubernetes_memory_limit}) exceeds the limit ({REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT}).' raise REANAKubernetesMemoryLimitExceeded(msg) self.kubernetes_memory_limit = ( kubernetes_memory_limit or REANA_KUBERNETES_JOBS_MEMORY_LIMIT )