saas/kk_odoo_saas/models/api.py

515 lines
21 KiB
Python

# -*- 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