Source code for airflow.providers.cncf.kubernetes.utils.pod_manager
# 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."""Launches PODs."""from__future__importannotationsimportenumimportitertoolsimportjsonimportloggingimportmathimporttimeimportwarningsfromcollections.abcimportIterablefromcontextlibimportclosing,suppressfromdataclassesimportdataclassfromdatetimeimporttimedeltafromtypingimportTYPE_CHECKING,Generator,Protocol,castimportpendulumimporttenacityfromkubernetesimportclient,watchfromkubernetes.client.restimportApiExceptionfromkubernetes.streamimportstreamaskubernetes_streamfrompendulumimportDateTimefrompendulum.parsing.exceptionsimportParserErrorfromtenacityimportbefore_logfromtyping_extensionsimportLiteralfromurllib3.exceptionsimportHTTPErrorasBaseHTTPErrorfromairflow.exceptionsimportAirflowException,AirflowProviderDeprecationWarningfromairflow.providers.cncf.kubernetes.pod_generatorimportPodDefaultsfromairflow.utils.log.logging_mixinimportLoggingMixinfromairflow.utils.timezoneimportutcnowifTYPE_CHECKING:fromkubernetes.client.models.core_v1_event_listimportCoreV1EventListfromkubernetes.client.models.v1_container_statusimportV1ContainerStatusfromkubernetes.client.models.v1_podimportV1Podfromurllib3.responseimportHTTPResponse
[docs]classPodLaunchFailedException(AirflowException):"""When pod launching fails in KubernetesPodOperator."""
[docs]defshould_retry_start_pod(exception:BaseException)->bool:"""Check if an Exception indicates a transient error and warrants retrying."""ifisinstance(exception,ApiException):returnexception.status==409returnFalse
[docs]classPodPhase:""" Possible pod phases. See https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase. """
[docs]classPodOperatorHookProtocol(Protocol):""" Protocol to define methods relied upon by KubernetesPodOperator. Subclasses of KubernetesPodOperator, such as GKEStartPodOperator, may use hooks that don't extend KubernetesHook. We use this protocol to document the methods used by KPO and ensure that these methods exist on such other hooks. """@property
[docs]defis_in_cluster(self)->bool:"""Expose whether the hook is configured with ``load_incluster_config`` or not."""
[docs]defget_pod(self,name:str,namespace:str)->V1Pod:"""Read pod object from kubernetes API."""
[docs]defget_namespace(self)->str|None:"""Returns the namespace that defined in the connection."""
[docs]defget_xcom_sidecar_container_image(self)->str|None:"""Returns the xcom sidecar image that defined in the connection."""
[docs]defget_xcom_sidecar_container_resources(self)->str|None:"""Returns the xcom sidecar resources that defined in the connection."""
[docs]defget_container_status(pod:V1Pod,container_name:str)->V1ContainerStatus|None:"""Retrieves container status."""container_statuses=pod.status.container_statusesifpodandpod.statuselseNoneifcontainer_statuses:# In general the variable container_statuses can store multiple items matching different containers.# The following generator expression yields all items that have name equal to the container_name.# The function next() here calls the generator to get only the first value. If there's nothing found# then None is returned.returnnext((xforxincontainer_statusesifx.name==container_name),None)returnNone
[docs]defcontainer_is_running(pod:V1Pod,container_name:str)->bool:""" Examines V1Pod ``pod`` to determine whether ``container_name`` is running. If that container is present and running, returns True. Returns False otherwise. """container_status=get_container_status(pod,container_name)ifnotcontainer_status:returnFalsereturncontainer_status.state.runningisnotNone
[docs]defcontainer_is_completed(pod:V1Pod,container_name:str)->bool:""" Examines V1Pod ``pod`` to determine whether ``container_name`` is completed. If that container is present and completed, returns True. Returns False otherwise. """container_status=get_container_status(pod,container_name)ifnotcontainer_status:returnFalsereturncontainer_status.state.terminatedisnotNone
[docs]defcontainer_is_succeeded(pod:V1Pod,container_name:str)->bool:""" Examines V1Pod ``pod`` to determine whether ``container_name`` is completed and succeeded. If that container is present and completed and succeeded, returns True. Returns False otherwise. """ifnotcontainer_is_completed(pod,container_name):returnFalsecontainer_status=get_container_status(pod,container_name)ifnotcontainer_status:returnFalsereturncontainer_status.state.terminated.exit_code==0
[docs]defcontainer_is_terminated(pod:V1Pod,container_name:str)->bool:""" Examines V1Pod ``pod`` to determine whether ``container_name`` is terminated. If that container is present and terminated, returns True. Returns False otherwise. """container_statuses=pod.status.container_statusesifpodandpod.statuselseNoneifnotcontainer_statuses:returnFalsecontainer_status=next((xforxincontainer_statusesifx.name==container_name),None)ifnotcontainer_status:returnFalsereturncontainer_status.state.terminatedisnotNone
classPodLogsConsumer:""" Responsible for pulling pod logs from a stream with checking a container status before reading data. This class is a workaround for the issue https://github.com/apache/airflow/issues/23497. :param response: HTTP response with logs :param pod: Pod instance from Kubernetes client :param pod_manager: Pod manager instance :param container_name: Name of the container that we're reading logs from :param post_termination_timeout: (Optional) The period of time in seconds representing for how long time logs are available after the container termination. :param read_pod_cache_timeout: (Optional) The container's status cache lifetime. The container status is cached to reduce API calls. :meta private: """def__init__(self,response:HTTPResponse,pod:V1Pod,pod_manager:PodManager,container_name:str,post_termination_timeout:int=120,read_pod_cache_timeout:int=120,):self.response=responseself.pod=podself.pod_manager=pod_managerself.container_name=container_nameself.post_termination_timeout=post_termination_timeoutself.last_read_pod_at=Noneself.read_pod_cache=Noneself.read_pod_cache_timeout=read_pod_cache_timeoutdef__iter__(self)->Generator[bytes,None,None]:r"""The generator yields log items divided by the '\n' symbol."""incomplete_log_item:list[bytes]=[]ifself.logs_available():fordata_chunkinself.response.stream(amt=None,decode_content=True):ifb"\n"indata_chunk:log_items=data_chunk.split(b"\n")yield fromself._extract_log_items(incomplete_log_item,log_items)incomplete_log_item=self._save_incomplete_log_item(log_items[-1])else:incomplete_log_item.append(data_chunk)ifnotself.logs_available():breakifincomplete_log_item:yieldb"".join(incomplete_log_item)@staticmethoddef_extract_log_items(incomplete_log_item:list[bytes],log_items:list[bytes]):yieldb"".join(incomplete_log_item)+log_items[0]+b"\n"forxinlog_items[1:-1]:yieldx+b"\n"@staticmethoddef_save_incomplete_log_item(sub_chunk:bytes):return[sub_chunk]if[sub_chunk]else[]deflogs_available(self):remote_pod=self.read_pod()ifcontainer_is_running(pod=remote_pod,container_name=self.container_name):returnTruecontainer_status=get_container_status(pod=remote_pod,container_name=self.container_name)state=container_status.stateifcontainer_statuselseNoneterminated=state.terminatedifstateelseNoneifterminated:termination_time=terminated.finished_atiftermination_time:returntermination_time+timedelta(seconds=self.post_termination_timeout)>utcnow()returnFalsedefread_pod(self):_now=utcnow()if(self.read_pod_cacheisNoneorself.last_read_pod_at+timedelta(seconds=self.read_pod_cache_timeout)<_now):self.read_pod_cache=self.pod_manager.read_pod(self.pod)self.last_read_pod_at=_nowreturnself.read_pod_cache@dataclass
[docs]classPodLoggingStatus:"""Used for returning the status of the pod and last log time when exiting from `fetch_container_logs`."""
[docs]classPodManager(LoggingMixin):"""Create, monitor, and otherwise interact with Kubernetes pods for use with the KubernetesPodOperator."""def__init__(self,kube_client:client.CoreV1Api,):""" Creates the launcher. :param kube_client: kubernetes client """super().__init__()self._client=kube_clientself._watch=watch.Watch()
[docs]defrun_pod_async(self,pod:V1Pod,**kwargs)->V1Pod:"""Runs POD asynchronously."""sanitized_pod=self._client.api_client.sanitize_for_serialization(pod)json_pod=json.dumps(sanitized_pod,indent=2)self.log.debug("Pod Creation Request: \n%s",json_pod)try:resp=self._client.create_namespaced_pod(body=sanitized_pod,namespace=pod.metadata.namespace,**kwargs)self.log.debug("Pod Creation Response: %s",resp)exceptExceptionase:self.log.exception("Exception when attempting to create Namespaced Pod: %s",str(json_pod).replace("\n"," "))raiseereturnresp
[docs]defdelete_pod(self,pod:V1Pod)->None:"""Deletes POD."""try:self._client.delete_namespaced_pod(pod.metadata.name,pod.metadata.namespace,body=client.V1DeleteOptions())exceptApiExceptionase:# If the pod is already deletedife.status!=404:raise
[docs]defcreate_pod(self,pod:V1Pod)->V1Pod:"""Launches the pod asynchronously."""returnself.run_pod_async(pod)
[docs]defawait_pod_start(self,pod:V1Pod,startup_timeout:int=120)->None:""" Waits for the pod to reach phase other than ``Pending``. :param pod: :param startup_timeout: Timeout (in seconds) for startup of the pod (if pod is pending for too long, fails task) :return: """curr_time=time.time()whileTrue:remote_pod=self.read_pod(pod)ifremote_pod.status.phase!=PodPhase.PENDING:breakself.log.warning("Pod not yet started: %s",pod.metadata.name)iftime.time()-curr_time>=startup_timeout:msg=(f"Pod took longer than {startup_timeout} seconds to start. ""Check the pod events in kubernetes to determine why.")raisePodLaunchFailedException(msg)time.sleep(1)
[docs]deffollow_container_logs(self,pod:V1Pod,container_name:str)->PodLoggingStatus:warnings.warn("Method `follow_container_logs` is deprecated. Use `fetch_container_logs` instead""with option `follow=True`.",AirflowProviderDeprecationWarning,)returnself.fetch_container_logs(pod=pod,container_name=container_name,follow=True)
[docs]deffetch_container_logs(self,pod:V1Pod,container_name:str,*,follow=False,since_time:DateTime|None=None,post_termination_timeout:int=120,)->PodLoggingStatus:""" Follows the logs of container and streams to airflow logging. Returns when container exits. Between when the pod starts and logs being available, there might be a delay due to CSR not approved and signed yet. In such situation, ApiException is thrown. This is why we are retrying on this specific exception. """@tenacity.retry(retry=tenacity.retry_if_exception_type(ApiException),stop=tenacity.stop_after_attempt(10),wait=tenacity.wait_fixed(1),before=before_log(self.log,logging.INFO),)defconsume_logs(*,since_time:DateTime|None=None,follow:bool=True,termination_timeout:int=120)->DateTime|None:""" Tries to follow container logs until container completes. For a long-running container, sometimes the log read may be interrupted Such errors of this kind are suppressed. Returns the last timestamp observed in logs. """last_captured_timestamp=Nonetry:logs=self.read_pod_logs(pod=pod,container_name=container_name,timestamps=True,since_seconds=(math.ceil((pendulum.now()-since_time).total_seconds())ifsince_timeelseNone),follow=follow,post_termination_timeout=termination_timeout,)forraw_lineinlogs:line=raw_line.decode("utf-8",errors="backslashreplace")line_timestamp,message=self.parse_log_line(line)ifline_timestampisnotNone:last_captured_timestamp=line_timestampself.log.info("[%s] %s",container_name,message)exceptBaseHTTPErrorase:self.log.warning("Reading of logs interrupted for container %r with error %r; will retry. ""Set log level to DEBUG for traceback.",container_name,e,)self.log.debug("Traceback for interrupted logs read for pod %r",pod.metadata.name,exc_info=True,)returnlast_captured_timestamporsince_time# note: `read_pod_logs` follows the logs, so we shouldn't necessarily *need* to# loop as we do here. But in a long-running process we might temporarily lose connectivity.# So the looping logic is there to let us resume following the logs.last_log_time=since_timewhileTrue:last_log_time=consume_logs(since_time=last_log_time,follow=follow,termination_timeout=post_termination_timeout)ifnotself.container_is_running(pod,container_name=container_name):returnPodLoggingStatus(running=False,last_log_time=last_log_time)ifnotfollow:returnPodLoggingStatus(running=True,last_log_time=last_log_time)else:self.log.warning("Pod %s log read interrupted but container %s still running",pod.metadata.name,container_name,)time.sleep(1)
[docs]deffetch_requested_container_logs(self,pod:V1Pod,container_logs:Iterable[str]|str|Literal[True],follow_logs=False)->list[PodLoggingStatus]:""" Follow the logs of containers in the specified pod and publish it to airflow logging. Returns when all the containers exit. """pod_logging_statuses=[]all_containers=self.get_container_names(pod)ifall_containers:ifisinstance(container_logs,str):# fetch logs only for requested container if only one container is providedifcontainer_logsinall_containers:status=self.fetch_container_logs(pod=pod,container_name=container_logs,follow=follow_logs)pod_logging_statuses.append(status)else:self.log.error("container %s whose logs were requested not found in the pod %s",container_logs,pod.metadata.name,)elifisinstance(container_logs,bool):# if True is provided, get logs for all the containersifcontainer_logsisTrue:forcontainer_nameinall_containers:status=self.fetch_container_logs(pod=pod,container_name=container_name,follow=follow_logs)pod_logging_statuses.append(status)else:self.log.error("False is not a valid value for container_logs",)else:# if a sequence of containers are provided, iterate for every container in the podifisinstance(container_logs,Iterable):forcontainerincontainer_logs:ifcontainerinall_containers:status=self.fetch_container_logs(pod=pod,container_name=container,follow=follow_logs)pod_logging_statuses.append(status)else:self.log.error("Container %s whose logs were requests not found in the pod %s",container,pod.metadata.name,)else:self.log.error("Invalid type %s specified for container names input parameter",type(container_logs))else:self.log.error("Could not retrieve containers for the pod: %s",pod.metadata.name)returnpod_logging_statuses
[docs]defawait_container_completion(self,pod:V1Pod,container_name:str)->None:""" Waits for the given container in the given pod to be completed. :param pod: pod spec that will be monitored :param container_name: name of the container within the pod to monitor """whileTrue:remote_pod=self.read_pod(pod)terminated=container_is_completed(remote_pod,container_name)ifterminated:breakself.log.info("Waiting for container '%s' state to be completed",container_name)time.sleep(1)
[docs]defawait_pod_completion(self,pod:V1Pod,istio_enabled:bool=False,container_name:str="base")->V1Pod:""" Monitors a pod and returns the final state. :param istio_enabled: whether istio is enabled in the namespace :param pod: pod spec that will be monitored :param container_name: name of the container within the pod :return: tuple[State, str | None] """whileTrue:remote_pod=self.read_pod(pod)ifremote_pod.status.phaseinPodPhase.terminal_states:breakifistio_enabledandcontainer_is_completed(remote_pod,container_name):breakself.log.info("Pod %s has phase %s",pod.metadata.name,remote_pod.status.phase)time.sleep(2)returnremote_pod
[docs]defparse_log_line(self,line:str)->tuple[DateTime|None,str]:""" Parse K8s log line and returns the final state. :param line: k8s log line :return: timestamp and log message """timestamp,sep,message=line.strip().partition(" ")ifnotsep:self.log.error("Error parsing timestamp (no timestamp in message %r). ""Will continue execution but won't update timestamp",line,)returnNone,linetry:last_log_time=cast(DateTime,pendulum.parse(timestamp))exceptParserError:self.log.error("Error parsing timestamp. Will continue execution but won't update timestamp")returnNone,linereturnlast_log_time,message
[docs]defcontainer_is_running(self,pod:V1Pod,container_name:str)->bool:"""Reads pod and checks if container is running."""remote_pod=self.read_pod(pod)returncontainer_is_running(pod=remote_pod,container_name=container_name)
[docs]defcontainer_is_terminated(self,pod:V1Pod,container_name:str)->bool:"""Reads pod and checks if container is terminated."""remote_pod=self.read_pod(pod)returncontainer_is_terminated(pod=remote_pod,container_name=container_name)
[docs]defread_pod_logs(self,pod:V1Pod,container_name:str,tail_lines:int|None=None,timestamps:bool=False,since_seconds:int|None=None,follow=True,post_termination_timeout:int=120,)->PodLogsConsumer:"""Reads log from the POD."""additional_kwargs={}ifsince_seconds:additional_kwargs["since_seconds"]=since_secondsiftail_lines:additional_kwargs["tail_lines"]=tail_linestry:logs=self._client.read_namespaced_pod_log(name=pod.metadata.name,namespace=pod.metadata.namespace,container=container_name,follow=follow,timestamps=timestamps,_preload_content=False,**additional_kwargs,)exceptBaseHTTPError:self.log.exception("There was an error reading the kubernetes API.")raisereturnPodLogsConsumer(response=logs,pod=pod,pod_manager=self,container_name=container_name,post_termination_timeout=post_termination_timeout,)
[docs]defget_container_names(self,pod:V1Pod)->list[str]:"""Return container names from the POD except for the airflow-xcom-sidecar container."""pod_info=self.read_pod(pod)return[container_spec.nameforcontainer_specinpod_info.spec.containersifcontainer_spec.name!=PodDefaults.SIDECAR_CONTAINER_NAME]
[docs]defread_pod_events(self,pod:V1Pod)->CoreV1EventList:"""Reads events from the POD."""try:returnself._client.list_namespaced_event(namespace=pod.metadata.namespace,field_selector=f"involvedObject.name={pod.metadata.name}")exceptBaseHTTPErrorase:raiseAirflowException(f"There was an error reading the kubernetes API: {e}")
[docs]defread_pod(self,pod:V1Pod)->V1Pod:"""Read POD information."""try:returnself._client.read_namespaced_pod(pod.metadata.name,pod.metadata.namespace)exceptBaseHTTPErrorase:raiseAirflowException(f"There was an error reading the kubernetes API: {e}")
[docs]defawait_xcom_sidecar_container_start(self,pod:V1Pod)->None:self.log.info("Checking if xcom sidecar container is started.")forattemptinitertools.count():ifself.container_is_running(pod,PodDefaults.SIDECAR_CONTAINER_NAME):self.log.info("The xcom sidecar container is started.")breakifnotattempt:self.log.warning("The xcom sidecar container is not yet started.")time.sleep(1)
[docs]defextract_xcom(self,pod:V1Pod)->str:"""Retrieves XCom value and kills xcom sidecar container."""try:result=self.extract_xcom_json(pod)returnresultfinally:self.extract_xcom_kill(pod)
[docs]defextract_xcom_json(self,pod:V1Pod)->str:"""Retrieves XCom value and also checks if xcom json is valid."""withclosing(kubernetes_stream(self._client.connect_get_namespaced_pod_exec,pod.metadata.name,pod.metadata.namespace,container=PodDefaults.SIDECAR_CONTAINER_NAME,command=["/bin/sh"],stdin=True,stdout=True,stderr=True,tty=False,_preload_content=False,))asresp:result=self._exec_pod_command(resp,f"if [ -s {PodDefaults.XCOM_MOUNT_PATH}/return.json ]; then cat {PodDefaults.XCOM_MOUNT_PATH}/return.json; else echo __airflow_xcom_result_empty__; fi",# noqa)ifresultandresult.rstrip()!="__airflow_xcom_result_empty__":# Note: result string is parsed to check if its valid json.# This function still returns a string which is converted into json in the calling method.json.loads(result)ifresultisNone:raiseAirflowException(f"Failed to extract xcom from pod: {pod.metadata.name}")returnresult