first commit
This commit is contained in:
14
kk_odoo_saas/models/__init__.py
Executable file
14
kk_odoo_saas/models/__init__.py
Executable file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import api
|
||||
from . import models
|
||||
from . import k8s_config
|
||||
from . import saas_period_product_mixin
|
||||
from . import product_template
|
||||
from . import saas_app_website
|
||||
from . import sale_subscription
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
from . import saas_package
|
||||
|
||||
|
||||
514
kk_odoo_saas/models/api.py
Normal file
514
kk_odoo_saas/models/api.py
Normal file
@@ -0,0 +1,514 @@
|
||||
# -*- 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
|
||||
80
kk_odoo_saas/models/cluster.py
Executable file
80
kk_odoo_saas/models/cluster.py
Executable file
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class SaasK8sClusterNS(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.ns'
|
||||
_description = 'SaaS Cluster NameSpace'
|
||||
|
||||
name = fields.Char()
|
||||
status = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
|
||||
|
||||
class SaasK8sClusterPod(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.pod'
|
||||
_description = 'SaaS Cluster Pod'
|
||||
|
||||
name = fields.Char()
|
||||
ns = fields.Char()
|
||||
ready = fields.Char()
|
||||
status = fields.Char()
|
||||
restarts = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
|
||||
|
||||
class SaasK8sClusterDeployment(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.deployment'
|
||||
_description = 'SaaS Cluster Deployment'
|
||||
|
||||
name = fields.Char()
|
||||
ns = fields.Char()
|
||||
ready = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
|
||||
|
||||
class SaasK8sClusterIngress(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.ingress'
|
||||
_description = 'SaaS Cluster Ingress'
|
||||
|
||||
name = fields.Char()
|
||||
ns = fields.Char()
|
||||
hosts = fields.Char()
|
||||
ing_class = fields.Char()
|
||||
addresses = fields.Char()
|
||||
ports = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
|
||||
|
||||
class SaasK8sClusterService(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.service'
|
||||
_description = 'SaaS Cluster Service'
|
||||
|
||||
name = fields.Char()
|
||||
type_ = fields.Char()
|
||||
cluster_ip = fields.Char()
|
||||
external_ip = fields.Char()
|
||||
ports = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
|
||||
|
||||
class SaasK8sClusterPV(models.Model):
|
||||
_name = 'kk_odoo_saas.app.cluster.pv'
|
||||
_description = 'SaaS Cluster PV'
|
||||
|
||||
name = fields.Char()
|
||||
capacity = fields.Char()
|
||||
access_modes = fields.Char()
|
||||
reclaim_policy = fields.Char()
|
||||
status = fields.Char()
|
||||
claim = fields.Char()
|
||||
storage_class = fields.Char()
|
||||
reason = fields.Char()
|
||||
age = fields.Char()
|
||||
all_json = fields.Text('Complete json')
|
||||
135
kk_odoo_saas/models/k8s_config.py
Executable file
135
kk_odoo_saas/models/k8s_config.py
Executable file
@@ -0,0 +1,135 @@
|
||||
import psycopg2
|
||||
from odoo import models, fields, api
|
||||
from kubernetes import config, client
|
||||
from odoo.exceptions import UserError
|
||||
import yaml
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class SaaSAppK8sConfig(models.Model):
|
||||
_name = 'kk_odoo_saas.k8s.config'
|
||||
_description = 'Kubernetes Cluster Configuration'
|
||||
name = fields.Char()
|
||||
config_file = fields.Text('Yaml Configuration File')
|
||||
cluster_name = fields.Char('')
|
||||
namespaces = fields.Text('Namespaces in Cluster')
|
||||
domain_name = fields.Char('Domain Name')
|
||||
|
||||
def check_connectivity(self):
|
||||
if self.config_file:
|
||||
try:
|
||||
data2 = yaml.safe_load(self.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
v1 = client.CoreV1Api()
|
||||
response = v1.list_namespace()
|
||||
nss = []
|
||||
for namespace in response.items:
|
||||
nss.append(namespace.metadata.name)
|
||||
nl = '\n'
|
||||
self.namespaces = nl.join(nss)
|
||||
except config.config_exception.ConfigException as e:
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
else:
|
||||
raise UserError("Please Add config file")
|
||||
|
||||
def validate_domain_name(self):
|
||||
if self.domain_name:
|
||||
pass
|
||||
|
||||
def get_default_config(self):
|
||||
for conf in self.search([], limit=1):
|
||||
return conf
|
||||
|
||||
def update_cluster_nodes(self):
|
||||
if self.config_file:
|
||||
try:
|
||||
data2 = yaml.safe_load(self.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
v1 = client.CoreV1Api()
|
||||
response = v1.list_node()
|
||||
node_env = self.env['kk_odoo_saas.k8s.node']
|
||||
if response:
|
||||
node_env.search([]).unlink()
|
||||
for node in response.items:
|
||||
node_env.create({'name': node.metadata.name, 'labels': str(node.metadata.labels), 'annotations': str(node.metadata.annotations), 'taints': str(node.spec.taints), 'yaml_info': str(node)})
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(e)
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
else:
|
||||
raise UserError("Please Add config file")
|
||||
|
||||
|
||||
class DockerImages(models.Model):
|
||||
_name = 'kk_odoo_saas.k8s.docker.images'
|
||||
|
||||
name = fields.Char('Image Name', required=True)
|
||||
tag = fields.Char('Tag Name', required=True, default='latest')
|
||||
description = fields.Char('Description')
|
||||
is_pvt_dkr_repo = fields.Boolean('Using Private Docker Repository')
|
||||
b64_dkr_config = fields.Text('base64 docker config json file')
|
||||
repo_link = fields.Char('Related Repository')
|
||||
base_version = fields.Selection([
|
||||
('14.0', '14.0'),
|
||||
('15.0', '15.0'),
|
||||
('16.0', '16.0'),
|
||||
('17.0', '17.0'),
|
||||
('18.0', '18.0'),
|
||||
], required=True)
|
||||
# base_version is for pulling git code in folder e.g /var/lib/odoo/addons/14.0 etc.
|
||||
gevent_key = fields.Selection([
|
||||
('--longpolling-port', '--longpolling-port'),
|
||||
('--gevent-port', '--gevent-port'),
|
||||
], required=True, default='--gevent-port')
|
||||
|
||||
@api.depends('name', 'tag')
|
||||
def name_get(self):
|
||||
res = []
|
||||
for record in self:
|
||||
name = record.name
|
||||
if record.tag:
|
||||
name = name + ':' + record.tag
|
||||
res.append((record.id, name))
|
||||
return res
|
||||
|
||||
|
||||
class Node(models.Model):
|
||||
_name = 'kk_odoo_saas.k8s.node'
|
||||
name = fields.Char('Node Name')
|
||||
labels = fields.Text('Labels')
|
||||
annotations = fields.Text()
|
||||
taints = fields.Text()
|
||||
|
||||
yaml_info = fields.Text("Yaml Description")
|
||||
|
||||
|
||||
class MasterDbCreds(models.Model):
|
||||
_name = 'kk_odoo_saas.k8s.master_db_creds'
|
||||
|
||||
name = fields.Char('DB Server Name')
|
||||
master_username = fields.Char('Master Username', default='postgres', required=True)
|
||||
master_pass = fields.Char('Master Password', required=True)
|
||||
server_url = fields.Char('Server URL', required=True)
|
||||
server_port = fields.Char('Server Port', default='5432', required=True)
|
||||
status = fields.Selection([('connected', 'Connected'), ('not_connected', 'Not Connected')], default='not_connected')
|
||||
|
||||
def check_connectivity(self):
|
||||
for rec in self:
|
||||
if rec.master_username and rec.master_pass and rec.server_url:
|
||||
try:
|
||||
conn = psycopg2.connect(database='postgres',
|
||||
user=rec.master_username,
|
||||
password=rec.master_pass,
|
||||
host=rec.server_url,
|
||||
port=rec.server_port or 5432)
|
||||
if conn:
|
||||
_logger.info("Connected to PG DB server")
|
||||
self.status = 'connected'
|
||||
return conn
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
self.status = 'not_connected'
|
||||
raise UserError('Unable to Connect Postgres.\nPlease Check Postgres Credentials...!')
|
||||
else:
|
||||
self.status = 'not_connected'
|
||||
raise UserError('Please Enter Postgres Credentials...!')
|
||||
482
kk_odoo_saas/models/models.py
Executable file
482
kk_odoo_saas/models/models.py
Executable file
@@ -0,0 +1,482 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
|
||||
import psycopg2
|
||||
import requests
|
||||
import os
|
||||
from odoo import models, fields, api, _
|
||||
from ..utils import k8s_deployment as k8s
|
||||
from ..utils import ingress, logs
|
||||
from ..utils import del_git_code as dc
|
||||
import re
|
||||
from odoo.exceptions import ValidationError, MissingError
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
import logging
|
||||
import xmlrpc.client
|
||||
import random
|
||||
import string
|
||||
from odoo.addons.queue_job.exception import RetryableJobError
|
||||
from odoo.exceptions import AccessError
|
||||
from datetime import timedelta
|
||||
from ..utils import pg_server as pgx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaaSAppSslSecret(models.Model):
|
||||
_name = 'kk_odoo_saas.app.ssl_secret'
|
||||
name = fields.Char('Secret Name')
|
||||
|
||||
|
||||
class SaaSApp(models.Model):
|
||||
_name = 'kk_odoo_saas.app'
|
||||
_description = 'SaaS App'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'kk_odoo_saas.kubernetes.api']
|
||||
|
||||
app_name = fields.Char('App Unique Id',
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('kk_odoo_saas.app'), tracking=True, copy=False)
|
||||
name = fields.Char(tracking=True)
|
||||
is_custom_image = fields.Boolean(default=True)
|
||||
docker_image = fields.Many2one('kk_odoo_saas.k8s.docker.images')
|
||||
is_pvt_dkr_repo = fields.Boolean('Using Private Docker Repository')
|
||||
|
||||
is_extra_addon = fields.Boolean('Use Extra Addons')
|
||||
extra_addons = fields.Char('Git Url', tracking=True)
|
||||
is_private_repo = fields.Boolean('Is Private Repository?')
|
||||
git_token = fields.Char('Auth Token')
|
||||
|
||||
# K8s values
|
||||
client = fields.Many2one('res.partner', related='admin_user.partner_id', tracking=True)
|
||||
country_id = fields.Many2one(string="Country", comodel_name='res.country',
|
||||
help="Country for which this instance is being deployed")
|
||||
|
||||
admin_user = fields.Many2one("res.users", "Client User", tracking=True)
|
||||
|
||||
def _get_default_cluster_config(self):
|
||||
cluster = self.env['kk_odoo_saas.k8s.config'].search([], limit=1)
|
||||
if cluster:
|
||||
return cluster.id
|
||||
return False
|
||||
|
||||
def _get_default_db_server(self):
|
||||
db_server = self.env['kk_odoo_saas.k8s.master_db_creds'].search([], limit=1)
|
||||
if db_server:
|
||||
return db_server
|
||||
return False
|
||||
|
||||
configuration = fields.Many2one(
|
||||
'kk_odoo_saas.k8s.config', string='Configuration', default=_get_default_cluster_config)
|
||||
|
||||
domain_name = fields.Char(related='configuration.domain_name')
|
||||
sub_domain_name = fields.Char(required=True)
|
||||
|
||||
is_dedicated_node = fields.Boolean(string='Any Dedicated Node')
|
||||
node_id = fields.Many2one('kk_odoo_saas.k8s.node', string='Node')
|
||||
node_key = fields.Char()
|
||||
node_value = fields.Char()
|
||||
|
||||
demo_data = fields.Boolean('Install Demo Data')
|
||||
status = fields.Selection([('d', 'Draft'), ('l', 'Launched'), ('m', 'Modified'), ('del', 'Deleted')],
|
||||
string='Status', default='d', tracking=True)
|
||||
expiry_date = fields.Date(tracking=True)
|
||||
subscription_id = fields.Many2one('sale.subscription', string='Related Subscription', tracking=True)
|
||||
notes = fields.Text()
|
||||
module_ids = fields.Many2many(comodel_name='saas.app', string='Modules to install')
|
||||
|
||||
login_email = fields.Char('Login Email')
|
||||
login_pwd = fields.Char('Login Pwd')
|
||||
|
||||
master_login_email = fields.Char('Master Login Email')
|
||||
master_login_pwd = fields.Char('Master Login Pwd')
|
||||
|
||||
custom_domain_ids = fields.One2many('saas.app.custom.domain', 'saas_app_id', string='Custom Domains')
|
||||
|
||||
def _default_db_name(self):
|
||||
return self.sub_domain_name
|
||||
|
||||
k8s_logs = fields.Many2many('smile.log', string='K8s Logs', compute='_get_k8s_logs')
|
||||
|
||||
# db server relation
|
||||
db_server_id = fields.Many2one(
|
||||
'kk_odoo_saas.k8s.master_db_creds', string="DB Server", default=_get_default_db_server)
|
||||
client_db_name = fields.Char("Database Name", required=True)
|
||||
login_url = fields.Char('Login URL', compute='_get_instance_login_url')
|
||||
|
||||
# _sql_constraints = [
|
||||
# ('hostname_uniq', 'unique(hostname)', "A Domain already exists. Domain's name must be unique!"),
|
||||
# ]
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
_logger = logging.getLogger(__name__)
|
||||
if self:
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
res = super(SaaSApp, self).create(values)
|
||||
|
||||
if not res.validate_domain_name():
|
||||
_logger.error('Either Domain or Subdomain is not valid')
|
||||
raise ValidationError('Either Domain or Subdomain is not valid')
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
# if vals and 'status' not in vals and self.status not in ['d', 'del']:
|
||||
# vals.update({'status': 'm'})
|
||||
|
||||
res = super(SaaSApp, self).write(vals)
|
||||
|
||||
if 'custom_domain_ids' in vals:
|
||||
#todo: add validation, limit number of domains per instance
|
||||
self.update_app()
|
||||
|
||||
if 'sub_domain_name' or 'domain_name' in vals:
|
||||
if not self.validate_domain_name():
|
||||
_logger.error('Either Domain or Subdomain is not valid')
|
||||
raise ValidationError('Either Domain or Subdomain is not valid')
|
||||
return res
|
||||
|
||||
@api.onchange('app_name')
|
||||
def set_sub_domain_name(self):
|
||||
self.sub_domain_name = self.app_name
|
||||
# also set the database name
|
||||
self.client_db_name = self.app_name
|
||||
|
||||
def validate_domain_name(self):
|
||||
if self.domain_name and self.sub_domain_name:
|
||||
full_name = f'{self.sub_domain_name}.{self.domain_name}'
|
||||
domain_regex = r'(([\da-zA-Z])([_\w-]{,62})\.){,127}(([\da-zA-Z])[_\w-]{,61})?([\da-zA-Z]\.((xn\-\-[a-zA-Z\d]+)|([a-zA-Z\d]{2,})))'
|
||||
domain_regex = '{0}$'.format(domain_regex)
|
||||
valid_domain_name_regex = re.compile(domain_regex, re.IGNORECASE)
|
||||
full_name = full_name.lower().strip()
|
||||
if re.match(valid_domain_name_regex, full_name):
|
||||
return True
|
||||
return
|
||||
|
||||
def deploy_app(self):
|
||||
self.deploy_api_main(namespace='default')
|
||||
self.status = 'l'
|
||||
self.with_delay().post_init_tasks()
|
||||
|
||||
def delete_app_from_wizard(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment):
|
||||
self.remove_api_main(delete_db, delete_pv, delete_svc, delete_ing, delete_deployment, namespace='default')
|
||||
self.status = 'del'
|
||||
|
||||
def update_app(self):
|
||||
k8s.update_app(self)
|
||||
self.status = 'l'
|
||||
|
||||
def get_url(self):
|
||||
return f'http://{self.sub_domain_name}.{self.domain_name}'
|
||||
|
||||
def deploy_apps_from_git(self):
|
||||
k8s.deploy_apps_from_git(self)
|
||||
|
||||
def restart_odoo_service(self):
|
||||
k8s.restart_odoo_service(self)
|
||||
|
||||
def action_show_subscription(self):
|
||||
self.ensure_one()
|
||||
assert self.subscription_id, "This app is not associated with any Subscription"
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Subscription",
|
||||
"res_model": "sale.subscription",
|
||||
"res_id": self.subscription_id.id,
|
||||
"view_mode": "form",
|
||||
}
|
||||
|
||||
def action_create_subscription(self):
|
||||
self.ensure_one()
|
||||
assert not self.subscription_id, "This app is already associated with Subscription"
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Subscription",
|
||||
"res_model": "sale.subscription",
|
||||
"view_mode": "form",
|
||||
"context": {
|
||||
"default_name": self.name + "'s SaaS Subscription",
|
||||
"default_build_id": self.id,
|
||||
"default_partner_id": self.client.id,
|
||||
}
|
||||
}
|
||||
|
||||
def _get_instance_login_url(self):
|
||||
for app in self:
|
||||
app.login_url = ''
|
||||
response, db = pgx.get_admin_credentials(app)
|
||||
if response and db:
|
||||
app.login_url = f'https://{self.sub_domain_name}.{self.domain_name}/saas/login?db={db}&login={response[0][0]}&passwd={response[0][1]}'
|
||||
else:
|
||||
_logger.info("Unknown Error!")
|
||||
|
||||
def action_connect_instance(self):
|
||||
self.ensure_one()
|
||||
response, db = pgx.get_admin_credentials(self)
|
||||
if response and db:
|
||||
login = response[0][0]
|
||||
password = response[0][1]
|
||||
login_url = "https://{self.sub_domain_name}.{self.domain_name}/saas/login?db={db}&login={login}&passwd={password}"
|
||||
_logger.info("Login URL %r " % (login_url))
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': login_url,
|
||||
'target': 'new',
|
||||
}
|
||||
else:
|
||||
_logger.info("Unknown Error!")
|
||||
|
||||
|
||||
def create_instance_admin_user_for_client(self, models1, db, uid, password, client_pwd):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
try:
|
||||
adm_user_id = models1.execute_kw(db, uid, password,
|
||||
'res.users', 'search_read',
|
||||
[[['login', '=', 'admin']]], {'fields': ['groups_id'], 'limit': 1}
|
||||
)
|
||||
if adm_user_id:
|
||||
adm_user_id = adm_user_id[0]
|
||||
groups_ids = adm_user_id.get('groups_id', False)
|
||||
if groups_ids:
|
||||
new_user_id = models1.execute_kw(db, uid, password, 'res.users', 'create',
|
||||
[{'name': self.client.name,
|
||||
'login': self.client.email,
|
||||
'company_ids': [1], 'company_id': 1,
|
||||
'password': client_pwd}])
|
||||
if new_user_id:
|
||||
_logger.info('Created client Account on instance')
|
||||
return models1.execute_kw(db, uid, password, 'res.groups', 'write',
|
||||
[groups_ids, {'users': [(4, new_user_id)]}])
|
||||
except xmlrpc.client.Error as e:
|
||||
_logger.error(str(e))
|
||||
return
|
||||
|
||||
def reset_apps_admin_pwd(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
protocol = 'https'
|
||||
url = f'{protocol}://{self.sub_domain_name}.{self.domain_name}'
|
||||
db = self.sub_domain_name
|
||||
new_pwd_client = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
|
||||
new_pwd_master = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
|
||||
username = 'admin'
|
||||
password = 'admin'
|
||||
try:
|
||||
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
|
||||
uid = common.authenticate(db, username, password, {})
|
||||
_logger.info('Sending request to app with uid {}'.format(uid))
|
||||
models1 = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
|
||||
try:
|
||||
if self.create_instance_admin_user_for_client(models1, db, uid, password, new_pwd_client):
|
||||
_logger.info('Updated Client User\'s Access Rights on instance')
|
||||
self.update({'login_pwd': new_pwd_client, 'login_email': self.client.email})
|
||||
|
||||
adm_user_id = models1.execute_kw(
|
||||
db, uid, password, 'res.users', 'search',
|
||||
[[['login', '=', 'admin']]], {'limit': 1})[0]
|
||||
if models1.execute_kw(db, uid, password, 'res.users', 'write', [[adm_user_id], {
|
||||
'password': new_pwd_master,
|
||||
}]):
|
||||
self.update({'master_login_pwd': new_pwd_master, 'master_login_email': 'admin'})
|
||||
self.send_app_pwd_cred_email()
|
||||
_logger.info('Password and login changed Successfully')
|
||||
except xmlrpc.client.Error as e:
|
||||
_logger.error(str(e))
|
||||
except xmlrpc.client.Error as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
def set_user_country(self):
|
||||
country_code = self.country_id.code
|
||||
if self.country_id and self.country_id.code:
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
protocol = 'https'
|
||||
url = f'{protocol}://{self.sub_domain_name}.{self.domain_name}'
|
||||
db = self.sub_domain_name
|
||||
username = 'admin'
|
||||
password = 'admin'
|
||||
try:
|
||||
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
|
||||
uid = common.authenticate(db, username, password, {})
|
||||
_logger.info('Sending request to app with uid {}'.format(uid))
|
||||
models1 = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
|
||||
try:
|
||||
country = models1.execute_kw(db, uid, password,
|
||||
'res.country', 'search_read',
|
||||
[[['code', 'ilike', country_code]]], {'fields': ['id'], 'limit': 1}
|
||||
)
|
||||
if country:
|
||||
country = country[0]
|
||||
if country:
|
||||
new_user_id = models1.execute_kw(db, uid, password, 'res.company', 'write',
|
||||
[[1], {'country_id': country_code and self.country_id.id,
|
||||
'currency_id': country_code and self.country_id.currency_id.id}])
|
||||
if new_user_id:
|
||||
_logger.info('Updated country of the user')
|
||||
except xmlrpc.client.Error as e:
|
||||
_logger.error(str(e))
|
||||
except xmlrpc.client.Error as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
def post_init_tasks(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
if not self.check_site_assible():
|
||||
_logger.info('Waiting for the App to become live....')
|
||||
raise RetryableJobError('Unable to get the app live.')
|
||||
else:
|
||||
self.set_user_country()
|
||||
self.reset_apps_admin_pwd()
|
||||
|
||||
def check_site_assible(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
try:
|
||||
resp = requests.get(f'http://{self.sub_domain_name}.{self.domain_name}')
|
||||
_logger.info('App, sent this status code {}'.format(resp.status_code))
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
_logger.error(str(e))
|
||||
return False
|
||||
|
||||
def send_app_pwd_cred_email(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
template = False
|
||||
try:
|
||||
template = self.env.ref('kk_odoo_saas.app_invitation_email', raise_if_not_found=False)
|
||||
except ValueError:
|
||||
pass
|
||||
assert template._name == 'mail.template'
|
||||
|
||||
template_values = {
|
||||
'email_to': '${object.admin_user.email|safe}',
|
||||
'email_cc': False,
|
||||
'auto_delete': True,
|
||||
'partner_to': False,
|
||||
'scheduled_date': False,
|
||||
}
|
||||
template.write(template_values)
|
||||
|
||||
if not self.admin_user.email:
|
||||
_logger.error(_("Cannot send email: user %s has no email address.", self.admin_user.name))
|
||||
with self.env.cr.savepoint():
|
||||
try:
|
||||
template.send_mail(self.id, force_send=True, raise_exception=True)
|
||||
except Exception as e:
|
||||
_logger.error(str(e))
|
||||
_logger.info(_("App Details email sent for user <%s> to <%s>", self.admin_user.login, self.admin_user.email))
|
||||
|
||||
def _message_get_suggested_recipients(self):
|
||||
recipients = super(SaaSApp, self)._message_get_suggested_recipients()
|
||||
try:
|
||||
for saas_app in self:
|
||||
if saas_app.client:
|
||||
saas_app._message_add_suggested_recipient(recipients, partner=saas_app.client, reason=_('SaaS Client'))
|
||||
except AccessError: # no read access rights -> just ignore suggested recipients because this imply modifying followers
|
||||
pass
|
||||
return recipients
|
||||
|
||||
def _get_k8s_logs(self):
|
||||
for app in self:
|
||||
app.k8s_logs = False
|
||||
logs_ = self.env['smile.log'].search([('res_id', '=', app.id), ('model_name', '=', self._name)])
|
||||
for log in logs_:
|
||||
app.k8s_logs = [(4, log.id)]
|
||||
|
||||
def get_pod_logs(self):
|
||||
output = logs.read_logs(app_name=self.app_name, config_file=self.configuration.config_file, self=self, tail_lines=None)
|
||||
if output:
|
||||
result = base64.b64encode(output.encode())
|
||||
attachment_obj = self.env['ir.attachment']
|
||||
# create attachment
|
||||
attachment_id = attachment_obj.create(
|
||||
{'name': self.app_name+'-odoo-logs.log', 'datas': result, 'public': False})
|
||||
# prepare download url
|
||||
download_url = '/web/content/' + str(attachment_id.id) + '?download=true'
|
||||
# download
|
||||
return {
|
||||
"type": "ir.actions.act_url",
|
||||
"url": str(download_url),
|
||||
"target": "new",
|
||||
}
|
||||
else:
|
||||
raise MissingError('Unable to get logs \nReason: Running Pod / Container not found')
|
||||
|
||||
def action_log_viewer(self):
|
||||
return {
|
||||
"type": "ir.actions.act_url",
|
||||
"url": "/saas/instance/{app_id}".format(app_id=self.id),
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def get_timed_pod_logs(self, interval=None, since_seconds=None, previous=None, tail_lines=None):
|
||||
output = logs.read_logs(app_name=self.app_name, config_file=self.configuration.config_file, self=self, since_seconds=since_seconds)
|
||||
if output:
|
||||
return output
|
||||
|
||||
def update_docker_image(self, container_arguments, env_vars=False):
|
||||
patched_deployment = k8s.update_deployment(self=self, container_arguments=container_arguments, env_vars=env_vars)
|
||||
if patched_deployment:
|
||||
self.env['bus.bus'].sendone(
|
||||
(self._cr.dbname, 'res.partner', self.env.user.partner_id.id),
|
||||
{'type': 'simple_notification', 'title': 'Image Update in Progress',
|
||||
'message': 'Deployment in Progress with latest docker image'}
|
||||
)
|
||||
return True
|
||||
|
||||
def get_odoo_deployment(self):
|
||||
deployment = k8s.read_deployment(self=self)
|
||||
if deployment:
|
||||
return deployment
|
||||
return False
|
||||
|
||||
def refresh_node_list(self):
|
||||
if self.configuration:
|
||||
self.configuration.update_cluster_nodes()
|
||||
|
||||
def get_pg_db_connection(self, db='postgres'):
|
||||
for rec in self:
|
||||
if rec.db_server_id.master_pass and rec.db_server_id.master_username and rec.db_server_id.server_url:
|
||||
try:
|
||||
_logger.info("Going to connect to PG DB server")
|
||||
conn = psycopg2.connect(database=db,
|
||||
user=rec.db_server_id.master_username,
|
||||
password=rec.db_server_id.master_pass,
|
||||
host=rec.db_server_id.server_url,
|
||||
port=rec.db_server_id.server_port or 5432
|
||||
)
|
||||
if conn:
|
||||
_logger.info("Connected to PG DB server")
|
||||
return conn
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
def del_git_dir(self):
|
||||
base_version = self.docker_image.base_version
|
||||
delete_path = "/var/lib/odoo/addons/" + str(base_version)
|
||||
dc.del_git_dir(self, path=delete_path)
|
||||
|
||||
|
||||
class SaaSAppDomain(models.Model):
|
||||
_name = 'saas.app.custom.domain'
|
||||
_description = 'SaaS App Custom Domain'
|
||||
|
||||
name = fields.Char('Domain Name', required=True)
|
||||
saas_app_id = fields.Many2one('kk_odoo_saas.app')
|
||||
ssl = fields.Boolean('Enable SSL?', default=True)
|
||||
|
||||
|
||||
class DockerAccount(models.Model):
|
||||
_name = 'saas.docker.hub.account'
|
||||
|
||||
username = fields.Char('docker hub username')
|
||||
pwd = fields.Char('Password or Access Token')
|
||||
# for more info https://docs.docker.com/docker-hub/access-tokens/
|
||||
16
kk_odoo_saas/models/product_template.py
Executable file
16
kk_odoo_saas/models/product_template.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
saas_app_id = fields.Many2one("saas.app", ondelete="cascade", index=True)
|
||||
saas_package_id = fields.Many2one("saas.package", ondelete="cascade", index=True)
|
||||
is_saas_product = fields.Boolean("Is SaaS product?", default=False)
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
if vals.get("is_saas_product"):
|
||||
vals["taxes_id"] = [(5,)]
|
||||
vals["supplier_taxes_id"] = [(5,)]
|
||||
return super(ProductTemplate, self).create(vals)
|
||||
34
kk_odoo_saas/models/res_config_settings.py
Executable file
34
kk_odoo_saas/models/res_config_settings.py
Executable file
@@ -0,0 +1,34 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
show_packages = fields.Boolean(
|
||||
"Show packages", config_parameter="kk_odoo_saas.show_packages"
|
||||
)
|
||||
show_apps = fields.Boolean("Show apps", config_parameter="kk_odoo_saas.show_apps")
|
||||
show_buy_now_button = fields.Boolean(
|
||||
"Show 'Buy now' button", config_parameter="kk_odoo_saas.show_buy_now_button"
|
||||
)
|
||||
show_try_trial_button = fields.Boolean(
|
||||
"Show 'Try trial' button", config_parameter="kk_odoo_saas.show_try_trial_button"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super(ResConfigSettings, self).get_values()
|
||||
select_type = self.env["ir.config_parameter"].sudo()
|
||||
packages = select_type.get_param("kk_odoo_saas.show_packages")
|
||||
apps = select_type.get_param("kk_odoo_saas.show_apps")
|
||||
buy_now_button = select_type.get_param("kk_odoo_saas.show_buy_now_button")
|
||||
try_trial_button = select_type.get_param("kk_odoo_saas.show_try_trial_button")
|
||||
# fmt: off
|
||||
res.update({
|
||||
"show_packages": packages,
|
||||
"show_apps": apps,
|
||||
"show_buy_now_button": buy_now_button,
|
||||
"show_try_trial_button": try_trial_button,
|
||||
})
|
||||
# fmt: on
|
||||
return res
|
||||
0
kk_odoo_saas/models/res_users.py
Executable file
0
kk_odoo_saas/models/res_users.py
Executable file
102
kk_odoo_saas/models/saas_app_website.py
Executable file
102
kk_odoo_saas/models/saas_app_website.py
Executable file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaasApp(models.Model):
|
||||
_name = "saas.app"
|
||||
_description = "SaaS App"
|
||||
_order = "name"
|
||||
_inherit = "saas.period.product.mixin"
|
||||
|
||||
name = fields.Char("Technical Name", required=True, index=True)
|
||||
shortdesc = fields.Char("Module Name", required=True)
|
||||
dependency_ids = fields.Many2many("saas.app", "saas_apps_dependency_rel", "dep_id", "app_id", string="Dependencies")
|
||||
icon_image = fields.Binary("Icon")
|
||||
|
||||
allow_to_sell = fields.Boolean(default=True, string="Sellable")
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
res = super(SaasApp, self).create(vals)
|
||||
if not res.product_tmpl_id:
|
||||
res.product_tmpl_id = self.env["product.template"].create({
|
||||
"name": res.shortdesc,
|
||||
"image_1920": res.icon_image,
|
||||
"saas_app_id": res.id,
|
||||
"is_saas_product": True,
|
||||
"type": 'service',
|
||||
"purchase_ok": False,
|
||||
"subscription_template_id": self.env.ref("sale_subscription.monthly_subscription").id,
|
||||
"recurring_invoice": True,
|
||||
"website_published": True,
|
||||
"list_price": 0,
|
||||
})
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
res = super(SaasApp, self).write(vals)
|
||||
if vals.get('month_price', None) is not None or vals.get('year_price', None) is not None:
|
||||
self._update_variant_prices()
|
||||
return res
|
||||
|
||||
def _update_variant_prices(self):
|
||||
for app in self:
|
||||
for variant in app.product_tmpl_id.product_variant_ids:
|
||||
for attr in variant.product_template_attribute_value_ids:
|
||||
if attr.name == "Monthly":
|
||||
attr.update({'price_extra': app.month_price})
|
||||
if attr.name == "Annually":
|
||||
attr.update({'price_extra': app.year_price})
|
||||
|
||||
|
||||
def _search_or_create(self, ir_module):
|
||||
app = self.search([("name", "=", ir_module.name)])
|
||||
if not app:
|
||||
app = self.env["saas.app"].create({
|
||||
"name": ir_module.name,
|
||||
"shortdesc": ir_module.shortdesc,
|
||||
"icon_image": ir_module.icon_image
|
||||
})
|
||||
return app
|
||||
|
||||
def dependencies_str(self):
|
||||
self.ensure_one()
|
||||
visited_saas_module_ids = set()
|
||||
|
||||
def make_list(deps):
|
||||
result = []
|
||||
for dep in deps:
|
||||
if dep.id in visited_saas_module_ids:
|
||||
continue
|
||||
|
||||
visited_saas_module_ids.add(dep.id)
|
||||
result += [dep.name] + make_list(dep.dependency_ids)
|
||||
return result
|
||||
|
||||
return ",".join(make_list(self.dependency_ids))
|
||||
|
||||
@api.model
|
||||
def action_make_applist_from_local_instance(self):
|
||||
for x in map(self.browse, self._search([])):
|
||||
x.unlink()
|
||||
|
||||
def walk(parent_ir_module_name, parent_app_name=None):
|
||||
modules = self.env["ir.module.module.dependency"].sudo().search([("name", "=", parent_ir_module_name)]).mapped("module_id")
|
||||
for m in modules:
|
||||
app_name = None
|
||||
|
||||
if m.application:
|
||||
app = self.env["saas.app"]._search_or_create(m)
|
||||
|
||||
if parent_app_name:
|
||||
app.dependency_ids |= self.env["saas.app"].search([("name", "=", parent_app_name)])
|
||||
|
||||
app_name = app.name
|
||||
else:
|
||||
app_name = parent_app_name
|
||||
|
||||
walk(m.name, app_name)
|
||||
|
||||
walk("base")
|
||||
61
kk_odoo_saas/models/saas_package.py
Executable file
61
kk_odoo_saas/models/saas_package.py
Executable file
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class SaasPackage(models.Model):
|
||||
_name = "saas.package"
|
||||
_inherit = ["saas.period.product.mixin"]
|
||||
|
||||
is_published = fields.Boolean("Publish It?", default=True)
|
||||
package_image = fields.Image(
|
||||
string='Package image'
|
||||
)
|
||||
name = fields.Char(copy=False)
|
||||
module_ids = fields.Many2many('saas.app', string="Modules to install")
|
||||
docker_image = fields.Many2one('kk_odoo_saas.k8s.docker.images', 'Related Docker Image')
|
||||
stripe_product_id = fields.Char('Stripe Id')
|
||||
subscription_template = fields.Many2one('sale.subscription.template')
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
res = super(SaasPackage, self).create(vals)
|
||||
if not res.product_tmpl_id:
|
||||
res.product_tmpl_id = self.env["product.template"].create({
|
||||
"name": res.name,
|
||||
"image_1920": res.package_image,
|
||||
"saas_package_id": res.id,
|
||||
"is_saas_product": True,
|
||||
"type": 'service',
|
||||
"purchase_ok": False,
|
||||
"subscription_template_id": self.env.ref("sale_subscription.monthly_subscription").id,
|
||||
"recurring_invoice": True,
|
||||
"website_published": True,
|
||||
"list_price": 0,
|
||||
})
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
res = super(SaasPackage, self).write(vals)
|
||||
if vals.get('month_price', None) is not None or vals.get('year_price', None) is not None:
|
||||
self._update_variant_prices()
|
||||
return res
|
||||
|
||||
def _update_variant_prices(self):
|
||||
for app in self:
|
||||
for variant in app.product_tmpl_id.product_variant_ids:
|
||||
for attr in variant.product_template_attribute_value_ids:
|
||||
if attr.name == "Monthly":
|
||||
attr.update({'price_extra': app.month_price})
|
||||
if attr.name == "Annually":
|
||||
attr.update({'price_extra': app.year_price})
|
||||
|
||||
|
||||
def refresh_page(self):
|
||||
# Empty-function for purpose of refreshing page
|
||||
pass
|
||||
|
||||
54
kk_odoo_saas/models/saas_period_product_mixin.py
Executable file
54
kk_odoo_saas/models/saas_period_product_mixin.py
Executable file
@@ -0,0 +1,54 @@
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class SaasPeriodProductMixin(models.AbstractModel):
|
||||
_name = "saas.period.product.mixin"
|
||||
_description = "Period Product Mixin"
|
||||
|
||||
product_tmpl_id = fields.Many2one("product.template", ondelete="cascade", readonly=True)
|
||||
|
||||
month_product_id = fields.Many2one("product.product", string="Product for monthly subscription", compute="_compute_product_ids", store=True)
|
||||
year_product_id = fields.Many2one("product.product", string="Product for annually subscription", compute="_compute_product_ids", store=True)
|
||||
currency_id = fields.Many2one("res.currency", related="product_tmpl_id.currency_id")
|
||||
|
||||
# TODO: when following fields are written, you need to update prices on product.product
|
||||
month_price = fields.Float("Month price", default=0.0)
|
||||
year_price = fields.Float("Year price", default=0.0)
|
||||
|
||||
@api.depends("product_tmpl_id")
|
||||
def _compute_product_ids(self):
|
||||
patvs_month = self.env.ref("kk_odoo_saas.product_attribute_value_subscription_monthly")
|
||||
patvs_year = self.env.ref("kk_odoo_saas.product_attribute_value_subscription_annually")
|
||||
attr = self.env.ref("kk_odoo_saas.product_attribute_subscription")
|
||||
|
||||
for app in self:
|
||||
if not app.product_tmpl_id:
|
||||
app.month_product_id = app.year_product_id = self.env["product.product"]
|
||||
continue
|
||||
|
||||
line = self.env["product.template.attribute.line"].sudo().search([
|
||||
("product_tmpl_id", "=", app.product_tmpl_id.id),
|
||||
("attribute_id", "=", attr.id),
|
||||
])
|
||||
if not line:
|
||||
line = line.create({
|
||||
"product_tmpl_id": app.product_tmpl_id.id,
|
||||
"attribute_id": attr.id,
|
||||
"value_ids": [(6, 0, [
|
||||
patvs_year.id, patvs_month.id,
|
||||
])]
|
||||
})
|
||||
|
||||
ptv_ids = line.product_template_value_ids
|
||||
|
||||
month_ptv = ptv_ids.filtered(lambda x: x.product_attribute_value_id == patvs_month)
|
||||
month_ptv.write({
|
||||
"price_extra": app.month_price
|
||||
})
|
||||
app.month_product_id = month_ptv.ptav_product_variant_ids[:1]
|
||||
|
||||
year_ptv = ptv_ids.filtered(lambda x: x.product_attribute_value_id == patvs_year)
|
||||
year_ptv.write({
|
||||
"price_extra": app.year_price
|
||||
})
|
||||
app.year_product_id = year_ptv.ptav_product_variant_ids[:1]
|
||||
86
kk_odoo_saas/models/sale_order.py
Executable file
86
kk_odoo_saas/models/sale_order.py
Executable file
@@ -0,0 +1,86 @@
|
||||
from odoo import fields, models
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
build_id = fields.Many2one("kk_odoo_saas.app")
|
||||
is_pkg_pdt = fields.Boolean(default=False)
|
||||
|
||||
def _split_subscription_lines(self):
|
||||
"""Split the order line according to subscription templates that must be created."""
|
||||
self.ensure_one()
|
||||
res = dict()
|
||||
for line in self.order_line:
|
||||
if line.product_id:
|
||||
for p_id, p_name in line.product_id.name_get():
|
||||
if '(Annually)' in p_name:
|
||||
line.product_id.update(
|
||||
{'subscription_template_id': self.env.ref('sale_subscription.yearly_subscription').id})
|
||||
elif '(Monthly)' in p_name:
|
||||
line.product_id.update(
|
||||
{'subscription_template_id': self.env.ref('sale_subscription.monthly_subscription').id})
|
||||
|
||||
new_sub_lines = self.order_line.filtered(lambda
|
||||
l: not l.subscription_id and l.product_id.subscription_template_id and l.product_id.recurring_invoice)
|
||||
templates = new_sub_lines.mapped('product_id').mapped('subscription_template_id')
|
||||
for template in templates:
|
||||
lines = self.order_line.filtered(
|
||||
lambda l: l.product_id.subscription_template_id == template and l.product_id.recurring_invoice)
|
||||
res[template] = lines
|
||||
return res
|
||||
|
||||
def _action_confirm(self):
|
||||
"""Update and/or create subscriptions on order confirmation."""
|
||||
res = super(SaleOrder, self)._action_confirm()
|
||||
# self.create_saas_app_from_subscription()
|
||||
return res
|
||||
|
||||
def create_saas_app_from_subscription(self):
|
||||
for so in self:
|
||||
lines = so.order_line.filtered(lambda l: l.subscription_id is not False)
|
||||
p_ids = so.order_line.mapped('product_id')
|
||||
if lines and p_ids:
|
||||
saas_app_ids = [app.id for app in self.env['saas.app'].search([('year_product_id', 'in', p_ids.ids)])]
|
||||
if not saas_app_ids:
|
||||
saas_app_ids = [app.id for app in self.env['saas.app'].search([('month_product_id', 'in', p_ids.ids)])]
|
||||
line = lines[0]
|
||||
sub_id = line.subscription_id
|
||||
pkg = False
|
||||
if so.is_pkg_pdt:
|
||||
pkg = self.env['saas.package'].search([('year_product_id', 'in', p_ids.ids)])
|
||||
if not pkg:
|
||||
pkg = self.env['saas.package'].search([('month_product_id', 'in', p_ids.ids)])
|
||||
if pkg:
|
||||
saas_app_ids = pkg.module_ids.ids
|
||||
if so and so.build_id and sub_id:
|
||||
so.build_id.update({'subscription_id': sub_id.id,
|
||||
'module_ids': [(6, 0, saas_app_ids)]
|
||||
})
|
||||
sub_id.build_id = so.build_id
|
||||
so.build_id.deploy_app()
|
||||
else:
|
||||
saas_app_env = self.env['kk_odoo_saas.app']
|
||||
def_vals = saas_app_env.default_get(fields_list=['app_name', ])
|
||||
if self.partner_id.user_ids:
|
||||
def_vals['admin_user'] = self.partner_id.user_ids.ids[0]
|
||||
configurations = self.env["kk_odoo_saas.k8s.config"]
|
||||
config = configurations.get_default_config()
|
||||
if config:
|
||||
def_vals['configuration'] = config.id
|
||||
def_vals['sub_domain_name'] = def_vals.get('app_name')
|
||||
def_vals['subscription_id'] = sub_id.id
|
||||
def_vals['module_ids'] = [(6, 0, saas_app_ids)]
|
||||
def_vals['docker_image'] = pkg.docker_image.id
|
||||
def_vals['name'] = '{}\'s SaaS App'.format(self.partner_id.name)
|
||||
saas_app = saas_app_env.create(def_vals)
|
||||
|
||||
sub_id.build_id = saas_app.id
|
||||
self.build_id = saas_app.id
|
||||
|
||||
_logger.info('Going to Deploy SaaS App, Subscription is going to start')
|
||||
saas_app.deploy_app()
|
||||
else:
|
||||
_logger.error('Cant create SaaS App, No K8s configuration found')
|
||||
20
kk_odoo_saas/models/sale_subscription.py
Executable file
20
kk_odoo_saas/models/sale_subscription.py
Executable file
@@ -0,0 +1,20 @@
|
||||
from odoo import fields, models, api
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleSubscription(models.Model):
|
||||
_inherit = 'sale.subscription'
|
||||
build_id = fields.Many2one("kk_odoo_saas.app", string="Related SaaS Instance")
|
||||
is_saas = fields.Boolean('Is SaaS Subscription')
|
||||
|
||||
def start_subscription(self):
|
||||
res = super(SaleSubscription, self).start_subscription()
|
||||
if self.build_id:
|
||||
self.build_id.deploy_app()
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
res = super(SaleSubscription, self).create(vals)
|
||||
return res
|
||||
Reference in New Issue
Block a user