515 lines
21 KiB
Python
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
|