# -*- coding: utf-8 -*- from kubernetes import config, client from kubernetes.stream import stream import yaml import psycopg2 from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.addons.smile_log.tools import SmileDBLogger class KubernetesApi(models.AbstractModel): _name = 'kk_odoo_saas.kubernetes.api' # Deployment ======================================================================================================= def deploy_api_main(self, namespace='default'): self.ensure_one() # Apply config try: cluster_config = yaml.safe_load(self.configuration.config_file) config.load_kube_config_from_dict(cluster_config) except config.config_exception.ConfigException as e: self._logger.error(str(e)) raise UserError("Unable to Connect K8s Cluster") # Deploy self.deploy_api_pv_claim(namespace=namespace) self.deploy_api_deployment(namespace=namespace) self.deploy_api_service(namespace=namespace) self.deploy_api_ingress_http(namespace=namespace) def deploy_api_pv_claim(self, namespace='default'): specs = client.V1PersistentVolumeClaimSpec( access_modes=['ReadWriteOnce'], storage_class_name="gp2", resources=client.V1ResourceRequirements( requests={'storage': '1Gi'} ) ) meta_data = client.V1ObjectMeta( name=self._name_pv_claim, labels={'app': self.app_name} ) # ============================================================================ dep = client.V1PersistentVolumeClaim( api_version='v1', kind='PersistentVolumeClaim', metadata=meta_data, spec=specs ) try: resp = self.core_v1_api.create_namespaced_persistent_volume_claim(body=dep, namespace=namespace) self._logger.info(f'Volume created. status={resp.metadata.name}') except client.exceptions.ApiException as e: self._logger.error(msg=str(e)) def deploy_api_deployment(self, namespace='default'): res_limits = {'ephemeral-storage': '1Gi'} image_pull_secrets = [] if self.docker_image.is_pvt_dkr_repo and self.docker_image.b64_dkr_config: if self.deploy_api_repo_secret(namespace='default'): sec_name = f'{self.app_name}-dkr-registry-key' image_pull_secrets.append(client.V1LocalObjectReference(name=sec_name)) meta_data = client.V1ObjectMeta(name=self._name_deployment, labels={'app': self.app_name}) # ODOO ARGS ++++++++++++++++++++++++++++++++++++++++++++++++ args_odoo = [f'--database={self.sub_domain_name}'] if self.demo_data: args_odoo.append('--without-demo=False') else: args_odoo.append('--without-demo=True') if self.module_ids: module_names = ','.join(self.module_ids.mapped('name')) args_odoo.append(f'--init={module_names}') # ODOO ARGS ++++++++++++++++++++++++++++++++++++++++++++++++ limits = client.V1ResourceRequirements(limits=res_limits) node_selector = {} if self and self.is_dedicated_node and self.node_id: node_selector['kubernetes.io/hostname'] = self.node_id.name odoo_container = client.V1Container( name='odoo', image=self._name_image, env=[ client.V1EnvVar(name="HOST", value=self.db_server_id.server_url), client.V1EnvVar(name="USER", value=self.db_server_id.master_username), client.V1EnvVar(name="PASSWORD", value=self.db_server_id.master_pass), client.V1EnvVar(name="PORT", value=self.db_server_id.server_port), client.V1EnvVar(name="ODOO_HTTP_SOCKET_TIMEOUT", value="100"), ], ports=[client.V1ContainerPort(container_port=8069, name="odoo-port")], args=args_odoo, image_pull_policy='Always', resources=limits, volume_mounts=[ client.V1VolumeMount(name=f'{self.app_name}-odoo-web-pv-storage', mount_path='/var/lib/odoo/') ] ) volume_claim = client.V1PersistentVolumeClaimVolumeSource(claim_name=f'{self.app_name}-odoo-web-pv-claim') volume = client.V1Volume(name=f'{self.app_name}-odoo-web-pv-storage', persistent_volume_claim=volume_claim) strategy = client.V1DeploymentStrategy(type='Recreate') spec = client.V1PodSpec( containers=[odoo_container], volumes=[volume], image_pull_secrets=image_pull_secrets, security_context=client.V1PodSecurityContext( run_as_group=101, run_as_user=101, fs_group=101, fs_group_change_policy='Always' ), node_selector=node_selector, ) template = client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={'app': self.app_name, 'tier': "backend"}), spec=spec ) selector = client.V1LabelSelector(match_labels={'app': self.app_name, 'tier': 'backend'}) # Spec specs = client.V1DeploymentSpec(replicas=1, strategy=strategy, selector=selector, template=template) # ================================================= deployment = client.V1Deployment(api_version="apps/v1", kind="Deployment", metadata=meta_data, spec=specs) k8s_apps_v1 = client.AppsV1Api() try: resp = k8s_apps_v1.create_namespaced_deployment(body=deployment, namespace=namespace) self._logger.info("Deployment created. name='%s'" % resp.metadata.name) except client.exceptions.ApiException as e: self._logger.error(str(e)) def deploy_api_service(self, namespace='default'): specs = client.V1ServiceSpec( selector={'app': self.app_name, 'tier': 'backend'}, ports=[client.V1ServicePort(name='odoo-port', protocol='TCP', port=8069, target_port=8069)], type='NodePort' ) metadata = client.V1ObjectMeta(name=self._name_service, labels={'app': self.app_name}) # ============================================ body = client.V1Service(api_version='v1', kind='Service', metadata=metadata, spec=specs) try: service = self.core_v1_api.create_namespaced_service(namespace=namespace, body=body) self._logger.info(f'Service created. status={service.metadata.name}') except client.exceptions.ApiException as e: self._logger.error(str(e)) def deploy_api_ingress_http(self, namespace='default'): odoo_urls = [self._name_host] # === Main rule === rules = [ client.V1IngressRule( host=self._name_host, http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( path='/', path_type='Prefix', # ImplementationSpecific backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( port=client.V1ServiceBackendPort(number=8069), name=self._name_service, ) ) ), ] ) ) ] # === Additional rules === if self and self.custom_domain_ids: for custom_domain in self.custom_domain_ids: rules.append( client.V1IngressRule( host=custom_domain.name, http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( path='/', path_type='Prefix', backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( port=client.V1ServiceBackendPort(number=80), name=self._name_service, ) ) ), ] ) ) ) odoo_urls.append(custom_domain.name) body = client.V1Ingress( kind='Ingress', metadata=client.V1ObjectMeta( name=self._name_ingress, labels={'app': self.app_name}, annotations={ 'kubernetes.io/ingress.class': 'nginx', 'cert-manager.io/cluster-issuer': 'letsencrypt-prod', }, ), spec=client.V1IngressSpec(rules=rules) ) try: self.networking_v1_api.create_namespaced_ingress(namespace=namespace, body=body) except client.exceptions.ApiException as e: self._logger.error(str(e)) def deploy_api_ingress_https(self, namespace='default'): odoo_urls = [self._name_host] # === Main rule === rules = [ client.V1IngressRule( host=self._name_host, http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( path='/', path_type='Prefix', # ImplementationSpecific backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( port=client.V1ServiceBackendPort(number=8069), name=self._name_service, ) ) ), ] ) ) ] # === Additional rules === if self and self.custom_domain_ids: for custom_domain in self.custom_domain_ids: rules.append( client.V1IngressRule( host=custom_domain.name, http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( path='/', path_type='Prefix', backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( port=client.V1ServiceBackendPort(number=80), name=self._name_service, ) ) ), ] ) ) ) odoo_urls.append(custom_domain.name) body = client.V1Ingress( kind='Ingress', metadata=client.V1ObjectMeta( name=self._name_ingress, labels={'app': self.app_name}, annotations={ 'kubernetes.io/ingress.class': 'nginx', 'cert-manager.io/cluster-issuer': 'cert-manager', # 'letsencrypt-prod', }, ), spec=client.V1IngressSpec( rules=rules, tls=[client.V1IngressTLS(hosts=odoo_urls, secret_name=self._name_secret)]) ) try: self.networking_v1_api.create_namespaced_ingress(namespace=namespace, body=body) except client.exceptions.ApiException as e: self._logger.error(str(e)) def deploy_api_repo_secret(self, namespace="default"): secret = client.V1Secret( metadata=client.V1ObjectMeta( name=self._name_secret, labels={ "app": self.app_name, "tier": "backend" } ), data={ '.dockerconfigjson': self.docker_image.b64_dkr_config }, type='kubernetes.io/dockerconfigjson', ) try: resp = self.core_v1_api.create_namespaced_secret(body=secret, namespace=namespace) self._logger.info("Secret created. name='%s'" % resp.metadata.name) return True except client.exceptions.ApiException as e: self._logger.error(str(e)) return False # Remove =========================================================================================================== def remove_api_main(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment, namespace='default'): try: cluster_config = yaml.safe_load(self.configuration.config_file) config.load_kube_config_from_dict(cluster_config) except config.config_exception.ConfigException as e: self._logger.error(str(e)) raise UserError("Unable to Connect K8s Cluster") if delete_pv: self.remove_api_pv_claim(namespace=namespace) if delete_svc: self.remove_api_service(namespace=namespace) if delete_deployment: self.remove_api_deployment(namespace=namespace) if delete_ing: self.remove_api_ingress(namespace=namespace) if delete_db: self.remove_api_db() self.delete_job_task() def remove_api_deployment(self, namespace): try: deployment = self.apps_v1_api.delete_namespaced_deployment(name=self._name_deployment, namespace=namespace) if self.is_custom_image and self.docker_image: if self.docker_image.is_pvt_dkr_repo and self.docker_image.b64_dkr_config: self.remove_api_repo_secret(namespace) self._logger.info(str(deployment)) except client.exceptions.ApiException as e: self._logger.error(str(e)) def remove_api_ingress(self, namespace): try: ing = self.networking_v1_api.delete_namespaced_ingress(name=self._name_ingress, namespace=namespace) self._logger.info(str(ing)) except client.exceptions.ApiException as e: self._logger.error(str(e)) def remove_api_service(self, namespace): try: service = self.core_v1_api.delete_namespaced_service(name=self._name_service, namespace=namespace) self._logger.info(service) except client.exceptions.ApiException as e: self._logger.error(str(e)) def remove_api_pv_claim(self, namespace): try: pv = self.core_v1_api.delete_namespaced_persistent_volume_claim( name=self._name_pv_claim, namespace=namespace ) self._logger.info(str(pv)) except client.exceptions.ApiException as e: self._logger.error(str(e)) def remove_api_repo_secret(self, namespace): try: resp = self.core_v1_api.delete_namespaced_secret(self._name_secret, namespace=namespace) self._logger.info(str(resp)) return True except client.exceptions.ApiException as e: self._logger.error(str(e)) return False def remove_api_db(self): db = self.db_server_id connection = psycopg2.connect( dbname='postgres', user=db.master_username, password=db.master_pass, host=db.server_url, port=db.server_port ) connection.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) cursor = connection.cursor() cursor.execute(f'DROP DATABASE "{self.client_db_name}";') connection.commit() connection.close() # Getters ========================================================================================================== """ Kubernetes lib api """ @property def apps_v1_api(self): return client.AppsV1Api() @property def core_v1_api(self): return client.CoreV1Api() @property def networking_v1_api(self): return client.NetworkingV1Api() """ Names """ @property def _name_image(self): return f'{self.docker_image.name}:{self.docker_image.tag}' @property def _name_pv_claim(self): return f'{self.app_name}-odoo-web-pv-claim' @property def _name_deployment(self): return f'{self.app_name}-odoo-deployment' @property def _name_service(self): return f'{self.app_name}-odoo-service' @property def _name_ingress(self): return f'{self.app_name}-ingress' @property def _name_secret(self): return f'{self.app_name}tls' @property def _name_host(self): return f'{self.sub_domain_name}.{self.domain_name}' @property def _logger(self): return SmileDBLogger(self.env.cr.dbname, self._name, self.id, self._uid) # Technical ======================================================================================================== def delete_job_task(self): job_q_env = self.env['queue.job'] jobs = job_q_env.search([ "|", "|", "|", ("state", "=", "pending"), ("state", "=", "enqueued"), ("state", "=", "started"), ("state", "=", "failed"), ('func_string', '=', "kk_odoo_saas.app({0},).post_init_tasks()".format(self.id))]) for job in jobs: job.button_done() # TODO def deploy_apps_from_git(self): """ To pull code from github inside running container """ try: cluster_config = yaml.safe_load(self.configuration.config_file) config.load_kube_config_from_dict(cluster_config) except config.config_exception.ConfigException as e: self._logger.error(str(e)) raise UserError("Unable to Connect K8s Cluster") pod = self.core_v1_api.list_namespaced_pod(namespace='default', label_selector='app={}'.format(self.app_name)) if self.is_extra_addon and self.extra_addons and pod and pod.items: base_version = self.docker_image.base_version clone_path = "/var/lib/odoo/addons/" + str(base_version) if self.is_private_repo and self.git_token: url = self.extra_addons url = url.replace("http://", "") url = url.replace("https://", "") url = url.replace("www.", "") git_url = "https://oauth2:{0}@{1}".format(self.git_token, url) else: git_url = self.extra_addons is_clone_error = False error = '' exec_command = ['git', '-C', clone_path, 'pull'] resp = stream( self.core_v1_api.connect_get_namespaced_pod_exec, pod.items[0].metadata.name, 'default', command=exec_command, stderr=True, stdin=True, stdout=True, tty=False, _preload_content=False ) while resp.is_open(): resp.update(timeout=10) if resp.peek_stdout(): self._logger.info(str(resp.read_stdout())) if resp.peek_stderr(): is_clone_error = True error = resp.read_stderr() self._logger.error(str(error)) break resp.close() if is_clone_error: if error and "not a git repository (or any" in error: resp1 = stream( self.core_v1_api.connect_get_namespaced_pod_exec, pod.items[0].metadata.name, 'default', command=['chmod', '-R', 'ugo+rw', clone_path], stderr=True, stdin=False, stdout=True, tty=False, _preload_content=False ) resp = stream( self.core_v1_api.connect_get_namespaced_pod_exec, pod.items[0].metadata.name, 'default', command=['git', 'clone', git_url, clone_path], stderr=True, stdin=False, stdout=True, tty=False, _preload_content=False ) while resp.is_open(): resp.update(timeout=25) if resp.peek_stdout(): self._logger.info(str(resp.read_stdout())) if resp.peek_stderr(): error = resp.read_stderr() self._logger.error(str(error)) else: self._logger.info(str( "No Response" )) resp.close() else: return False