# Copyright (c) 2019-2020 SAP SE or an SAP affiliate company. All rights reserved. This file is # licensed under the Apache Software License, v. 2 except as noted otherwise in the LICENSE file # # Licensed 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. import base64 import json import time from urllib3.exceptions import ProtocolError import kubernetes from kubernetes import watch from kubernetes.client import ( AppsV1Api, CoreV1Api, ExtensionsV1beta1Api, V1ConfigMap, V1Deployment, V1LocalObjectReference, V1Namespace, V1ObjectMeta, V1Secret, V1Service, V1ServiceAccount, ExtensionsV1beta1Ingress as V1beta1Ingress, ) from kubernetes.client.rest import ApiException from kubernetes.stream import stream from ensure import ensure_annotations from ci.util import info, not_empty, not_none, fail class KubernetesSecretHelper(object): '''Helper class for handling kubernetes secret objects''' @ensure_annotations def __init__(self, core_api: CoreV1Api): self.core_api = core_api def create_gcr_secret( self, namespace: str, name: str, password: str, email: str, user_name: str='_json_key', server_url: str='https://eu.gcr.io' ): metadata = V1ObjectMeta(name=name, namespace=namespace) secret = V1Secret(metadata=metadata) auth = '{user}:{gcr_secret}'.format( user=user_name, gcr_secret=password ) docker_config = { server_url: { 'username': user_name, 'email': email, 'password': password, 'auth': base64.b64encode(auth.encode('utf-8')).decode('utf-8') } } encoded_docker_config = base64.b64encode( json.dumps(docker_config).encode('utf-8') ).decode('utf-8') secret.data = { '.dockercfg': encoded_docker_config } secret.type = 'kubernetes.io/dockercfg' self.core_api.create_namespaced_secret(namespace=namespace, body=secret) def put_secret(self, name: str, data: dict, namespace: str='default'): '''creates or updates (replaces) the specified secret. the secret's contents are expected in a dictionary containing only scalar values. In particular, each value is converted into a str; the result returned from to-str conversion is encoded as a utf-8 byte array. Thus such a conversion must not have done before. ''' ne = not_empty metadata = V1ObjectMeta(name=ne(name), namespace=ne(namespace)) secret_data = { k: base64.b64encode(str(v).encode('utf-8')).decode('utf-8') for k,v in data.items() } secret = V1Secret(metadata=metadata, data=secret_data) # find out whether we have to replace or to create try: self.core_api.read_namespaced_secret(name=name, namespace=namespace) secret_exists = True except ApiException as ae: # only 404 is expected if not ae.status == 404: raise ae secret_exists = False if secret_exists: self.core_api.replace_namespaced_secret(name=name, namespace=namespace, body=secret) else: self.core_api.create_namespaced_secret(namespace=namespace, body=secret) def get_secret(self, name: str, namespace: str) -> V1Secret: '''Returns the `V1Secret` with the given name in the given namespace, or `None`''' try: secret = self.core_api.read_namespaced_secret(name=name, namespace=namespace) except ApiException as ae: if not ae.status == 404: raise ae else: return None return secret class KubernetesServiceAccountHelper(object): '''Helper class for kubernetes service-account objects''' def __init__(self, core_api: CoreV1Api): self.core_api = core_api def patch_image_pull_secret_into_service_account( self, name: str, namespace: str, image_pull_secret_name: str ): '''Patches the given (by name) image-pull-secret into the specified service-account.''' service_account = V1ServiceAccount() reference = V1LocalObjectReference() reference.name = image_pull_secret_name service_account.image_pull_secrets = [reference] self.core_api.patch_namespaced_service_account( name=name, namespace=namespace, body=service_account ) class KubernetesNamespaceHelper(object): '''Helper class for kubernetes namespace objects''' @ensure_annotations def __init__(self, core_api: CoreV1Api): self.core_api = core_api def create_namespace(self, namespace: str): '''Creates a new namespace and returns it''' not_empty(namespace) metadata = V1ObjectMeta(name=namespace) ns = V1Namespace(metadata=metadata) return self.core_api.create_namespace(ns) def create_if_absent(self, namespace: str): '''Create a new namespace iff it does not already exist''' not_empty(namespace) existing_namespace = self.get_namespace(namespace) if not existing_namespace: self.create_namespace(namespace) @ensure_annotations def delete_namespace(self, namespace: str): not_empty(namespace) self.core_api.delete_namespace(name=namespace, body={}) def get_namespace(self, namespace: str): '''Returns the `V1Namespace` corresponding to the given name, or `None`''' for ns in self.core_api.list_namespace().items: # check if 'tis our namespace name = ns.metadata.name if not name == namespace: continue return ns return None class KubernetesServiceHelper(object): def __init__(self, core_api: CoreV1Api): self.core_api = core_api def replace_or_create_service(self, namespace: str, service: V1Service): '''Create a service in a given namespace. If the service already exists, the previous version will be deleted beforehand ''' not_empty(namespace) not_none(service) service_name = service.metadata.name existing_service = self.get_service(namespace=namespace, name=service_name) if existing_service: delete_options = kubernetes.client.V1DeleteOptions() delete_options.grace_period_seconds = 0 self.core_api.delete_namespaced_service( namespace=namespace, name=service_name, body=delete_options, ) self.create_service(namespace=namespace, service=service) def create_service(self, namespace: str, service: V1Service): '''Create a service in a given namespace. Raises an `ApiException` if such a Service already exists. ''' not_empty(namespace) not_none(service) self.core_api.create_namespaced_service(namespace=namespace, body=service) def get_service(self, namespace: str, name: str) -> V1Service: '''Return the `V1Service` with the given name in the given namespace, or `None` if no such service exists. ''' not_empty(namespace) not_empty(name) try: service = self.core_api.read_namespaced_service(name=name, namespace=namespace) except ApiException as ae: if ae.status == 404: return None raise ae return service class KubernetesDeploymentHelper(object): def __init__(self, apps_api: AppsV1Api): self.apps_api = apps_api def replace_or_create_deployment(self, namespace: str, deployment: V1Deployment): '''Create a deployment in a given namespace. If the deployment already exists, the previous version will be deleted beforehand. ''' not_empty(namespace) not_none(deployment) deployment_name = deployment.metadata.name existing_deployment = self.get_deployment(namespace=namespace, name=deployment_name) if existing_deployment: self.apps_api.delete_namespaced_deployment( namespace=namespace, name=deployment_name, body=kubernetes.client.V1DeleteOptions() ) self.create_deployment(namespace=namespace, deployment=deployment) def create_deployment(self, namespace: str, deployment: V1Deployment): '''Create a deployment in a given namespace. Raises an `ApiException` if such a deployment already exists.''' not_empty(namespace) not_none(deployment) self.apps_api.create_namespaced_deployment(namespace=namespace, body=deployment) def get_deployment(self, namespace: str, name: str) -> V1Deployment: '''Return the `V1Deployment` with the given name in the given namespace, or `None` if no such deployment exists.''' not_empty(namespace) not_empty(name) try: deployment = self.apps_api.read_namespaced_deployment(name=name, namespace=namespace) except ApiException as ae: if ae.status == 404: return None raise ae return deployment def patch_deployment(self, name: str, namespace: str, body: dict): '''Patches a deployment with a given name in the given namespace.''' not_empty(name) not_empty(namespace) not_empty(body) if not self.get_deployment(namespace, name): fail(f'Deployment {name} in namespace {namespace} does not exist') self.apps_api.patch_namespaced_deployment(name, namespace, body) def wait_until_deployment_available(self, namespace: str, name: str, timeout_seconds: int=60): '''Block until the given deployment has at least one available replica (or timeout) Return `True` if the deployment is available, `False` if a timeout occured. ''' not_empty(namespace) not_empty(name) w = watch.Watch() # Work around IncompleteRead errors resulting in ProtocolErrors - no fault of our own start_time = int(time.time()) while (start_time + timeout_seconds) > time.time(): try: for event in w.stream( self.apps_api.list_namespaced_deployment, namespace=namespace, timeout_seconds=timeout_seconds ): deployment_spec = event['object'] if deployment_spec is not None: if deployment_spec.metadata.name == name: if deployment_spec.status.available_replicas is not None \ and deployment_spec.status.available_replicas > 0: return True # Check explicitly if timeout occurred if (start_time + timeout_seconds) < time.time(): return False # Regular Watch.stream() timeout occurred, no need for further checks return False except ProtocolError: info('http connection error - ignored') class KubernetesIngressHelper(object): def __init__(self, extensions_v1beta1_api: ExtensionsV1beta1Api): self.extensions_v1beta1_api = extensions_v1beta1_api def replace_or_create_ingress(self, namespace: str, ingress: V1beta1Ingress): '''Create an ingress in a given namespace. If the ingress already exists, the previous version will be deleted beforehand. ''' not_empty(namespace) not_none(ingress) ingress_name = ingress.metadata.name existing_ingress = self.get_ingress(namespace=namespace, name=ingress_name) if existing_ingress: self.extensions_v1beta1_api.delete_namespaced_ingress( namespace=namespace, name=ingress_name, body=kubernetes.client.V1DeleteOptions() ) self.create_ingress(namespace=namespace, ingress=ingress) def create_ingress(self, namespace: str, ingress: V1beta1Ingress): '''Create an ingress in a given namespace. Raises an `ApiException` if such an ingress already exists.''' not_empty(namespace) not_none(ingress) self.extensions_v1beta1_api.create_namespaced_ingress(namespace=namespace, body=ingress) def get_ingress(self, namespace: str, name: str) -> V1beta1Ingress: '''Return the `V1beta1Ingress` with the given name in the given namespace, or `None` if no such ingress exists.''' not_empty(namespace) not_empty(name) try: ingress = self.extensions_v1beta1_api.read_namespaced_ingress( name=name, namespace=namespace ) except ApiException as ae: if ae.status == 404: return None raise ae return ingress class KubernetesConfigMapHelper(object): def __init__(self, core_api: CoreV1Api): self.core_api = core_api def create_config_map(self, namespace: str, name: str, data: dict): not_empty(namespace) not_empty(name) not_none(data) self.core_api.create_namespaced_config_map( namespace=namespace, body=V1ConfigMap( data=data, metadata=V1ObjectMeta(name=name, namespace=namespace), ), ) def replace_config_map(self, namespace: str, name: str, data: dict): not_empty(namespace) not_empty(name) not_none(data) self.core_api.replace_namespaced_config_map( namespace=namespace, name=name, body=V1ConfigMap( data=data, metadata=V1ObjectMeta(name=name, namespace=namespace), ), ) def create_or_update_config_map(self, namespace: str, name: str, data: dict): not_empty(namespace) not_empty(name) not_none(data) if self.read_config_map(namespace=namespace, name=name): self.replace_config_map(namespace=namespace, name=name, data=data) else: self.create_config_map(namespace=namespace, name=name, data=data) def read_config_map(self, namespace: str, name: str): '''Return the `V1ConfigMap` with the given name in the given namespace, or `None` if no such config map exists.''' not_empty(namespace) not_empty(name) try: config_map = self.core_api.read_namespaced_config_map(namespace=namespace, name=name) except ApiException as ae: if ae.status == 404: return None raise ae return config_map class KubernetesPodHelper(object): def __init__(self, core_api: CoreV1Api): self.core_api = core_api def list_pods(self, namespace: str, label_selector: str='', field_selector: str=''): '''Find all pods matching given labels and/or fields in the given namespace''' not_empty(namespace) try: pods = self.core_api.list_namespaced_pod( namespace, label_selector=label_selector, field_selector=field_selector, ) except ApiException as ae: if ae.status == 404: return None raise ae return pods def delete_pod(self, name: str, namespace: str, grace_period_seconds: int=0): '''Delete a pod in the given namespace. grace_period_seconds: the duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. ''' not_empty(namespace) not_empty(name) body = kubernetes.client.V1DeleteOptions() try: self.core_api.delete_namespaced_pod( name, namespace, body=body, grace_period_seconds=grace_period_seconds ) except ApiException as ae: if ae.status == 404: return None raise ae def execute( self, name: str, namespace: str, command:[str], container:str='', stderr:bool=True, stdout:bool=True, stdin='Not implemented', tty='Not implemented', ): '''Exec a command on a given pod in a given namespace. Does not support redirection of stdin or allocation of a tty. ''' not_empty(name) not_empty(namespace) not_empty(command) if stdin != 'Not implemented' or tty != 'Not implemented': raise NotImplementedError try: response = stream( self.core_api.connect_post_namespaced_pod_exec, name, namespace, command=command, container=container, stderr=stderr, stdin=False, stdout=stdout, tty=False, ) except ApiException as ae: if ae.status == 404: return None raise ae return response