first commit
This commit is contained in:
1
kk_odoo_saas/utils/__init__.py
Executable file
1
kk_odoo_saas/utils/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import k8s_deployment
|
||||
52
kk_odoo_saas/utils/del_git_code.py
Normal file
52
kk_odoo_saas/utils/del_git_code.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from kubernetes import config, client
|
||||
from kubernetes.stream import stream
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
from odoo.exceptions import UserError
|
||||
import yaml
|
||||
|
||||
|
||||
# import git_aggregator
|
||||
|
||||
def del_git_dir(self, path):
|
||||
"""
|
||||
It will delete addons directory inside running container
|
||||
"""
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
if self.app_name and path:
|
||||
try:
|
||||
data2 = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
core_v1_api = client.CoreV1Api()
|
||||
|
||||
try:
|
||||
pod = core_v1_api.list_namespaced_pod(namespace='default', label_selector='app={}'.format(self.app_name))
|
||||
except Exception as e:
|
||||
raise UserError("Unable to connect to cluster")
|
||||
resp1 = stream(core_v1_api.connect_get_namespaced_pod_exec,
|
||||
pod.items[0].metadata.name,
|
||||
'default',
|
||||
command=['chmod', '-R', 'ugo+rw', path],
|
||||
stderr=True, stdin=False,
|
||||
stdout=True, tty=False)
|
||||
|
||||
resp = stream(core_v1_api.connect_get_namespaced_pod_exec,
|
||||
pod.items[0].metadata.name,
|
||||
'default',
|
||||
command=['rm', '-rf', path ],
|
||||
stderr=True, stdin=False,
|
||||
stdout=True, tty=False)
|
||||
|
||||
resp3 = stream(core_v1_api.connect_get_namespaced_pod_exec,
|
||||
pod.items[0].metadata.name,
|
||||
'default',
|
||||
command=['mkdir', path ],
|
||||
stderr=True, stdin=False,
|
||||
stdout=True, tty=False)
|
||||
_logger.info(str(resp1))
|
||||
_logger.info(str(resp))
|
||||
_logger.info(str(resp3))
|
||||
_logger.info(str(path))
|
||||
_logger.info(str("code deleted"))
|
||||
190
kk_odoo_saas/utils/deployment.py
Executable file
190
kk_odoo_saas/utils/deployment.py
Executable file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from kubernetes import client
|
||||
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_deployment(meta_data, specs, namespace="default", self=False):
|
||||
# Deployment
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
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,
|
||||
)
|
||||
_logger.info("Deployment created. name='%s'" % resp.metadata.name)
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
def create_docker_repo_secret(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
k8s_apps_v1 = client.CoreV1Api()
|
||||
secret = client.V1Secret(
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=app_name+'-dkr-registry-key',
|
||||
labels={
|
||||
"app": app_name,
|
||||
"tier": "backend"
|
||||
}
|
||||
),
|
||||
data={
|
||||
'.dockerconfigjson': self.docker_image.b64_dkr_config
|
||||
},
|
||||
type='kubernetes.io/dockerconfigjson',
|
||||
)
|
||||
try:
|
||||
resp = k8s_apps_v1.create_namespaced_secret(
|
||||
body=secret, namespace=namespace)
|
||||
_logger.info("Secret created. name='%s'" % resp.metadata.name)
|
||||
return True
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
return False
|
||||
|
||||
|
||||
def delete_docker_repo_secret(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
k8s_apps_v1 = client.CoreV1Api()
|
||||
try:
|
||||
resp = k8s_apps_v1.delete_namespaced_secret(app_name+'-dkr-registry-key', namespace=namespace)
|
||||
_logger.info(str(resp))
|
||||
return True
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
return False
|
||||
|
||||
|
||||
def create_odoo_deployment(app_name, namespace="default", self=False):
|
||||
image = 'odoo:15.0'
|
||||
res_limits = {'ephemeral-storage': '1Gi'}
|
||||
|
||||
image_pull_secrets = []
|
||||
if self.is_custom_image and self.docker_image:
|
||||
image = f'{self.docker_image.name}:{self.docker_image.tag}'
|
||||
if self.docker_image.is_pvt_dkr_repo and self.docker_image.b64_dkr_config:
|
||||
if create_docker_repo_secret(app_name, namespace, self):
|
||||
sec_name = f'{app_name}-dkr-registry-key'
|
||||
image_pull_secrets.append(client.V1LocalObjectReference(name=sec_name))
|
||||
|
||||
meta_data = client.V1ObjectMeta(name=f'{app_name}-odoo-deployment', labels={'app': app_name})
|
||||
args_odoo = [
|
||||
f'--database={self.sub_domain_name}',
|
||||
# f'--workers=3',
|
||||
# f'--max-cron-threads=2',
|
||||
# f'--http-port=8069',
|
||||
# f'{self.docker_image.gevent_key}=8072',
|
||||
]
|
||||
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'))
|
||||
# module_names = ''
|
||||
# for module in self.module_ids:
|
||||
# module_names = module_names + module.name + ','
|
||||
args_odoo.append(f'--init={module_names}')
|
||||
|
||||
# TODO ??? why ??? ==================
|
||||
# if self.db_server_id:
|
||||
# _logger.critical('Cant deploy app, PG username or password cant find')
|
||||
# UserError("Cant deploy app, PG username or password cant find")
|
||||
|
||||
limits = client.V1ResourceRequirements(limits=res_limits)
|
||||
|
||||
tolerations = []
|
||||
node_selector = {}
|
||||
if self and self.is_dedicated_node and self.node_id:
|
||||
# tolerations = [client.V1Toleration(effect='NoSchedule', key=self.node_key, value=self.node_value, operator='Equal')]
|
||||
# specific for aws clusters
|
||||
node_selector['kubernetes.io/hostname'] = self.node_id.name
|
||||
|
||||
odoo_container = client.V1Container(
|
||||
name='odoo',
|
||||
image=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"),
|
||||
client.V1ContainerPort(container_port=8072, name="longpolling"),
|
||||
],
|
||||
args=args_odoo,
|
||||
image_pull_policy='Always',
|
||||
# command=['chown', '-R', '101:101', '/mnt/extra-addons'],
|
||||
resources=limits,
|
||||
# comment following line, if you want to run as odoo user
|
||||
# security_context=client.V1SecurityContext(run_as_user=0, run_as_group=0),
|
||||
volume_mounts=[
|
||||
client.V1VolumeMount(name=f'{app_name}-odoo-web-pv-storage', mount_path='/var/lib/odoo/')
|
||||
]
|
||||
)
|
||||
# pod Volume Claim
|
||||
volume_claim = client.V1PersistentVolumeClaimVolumeSource(claim_name=f'{app_name}-odoo-web-pv-claim')
|
||||
# pod volume
|
||||
volume = client.V1Volume(name=f'{app_name}-odoo-web-pv-storage', persistent_volume_claim=volume_claim)
|
||||
# Strategy
|
||||
strategy = client.V1DeploymentStrategy(type='Recreate')
|
||||
# Template
|
||||
# for running as an odoo user changes instead of stash
|
||||
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': app_name, 'tier': "backend"}),
|
||||
spec=spec
|
||||
)
|
||||
selector = client.V1LabelSelector(match_labels={'app': app_name, 'tier': 'backend'})
|
||||
|
||||
# Spec
|
||||
specs = client.V1DeploymentSpec(
|
||||
replicas=1,
|
||||
strategy=strategy,
|
||||
selector=selector,
|
||||
template=template,
|
||||
)
|
||||
create_deployment(meta_data, specs, namespace, self=self)
|
||||
|
||||
|
||||
def delete_odoo_deployment(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
dep_name = app_name + "-odoo-deployment"
|
||||
core_v1_api = client.AppsV1Api()
|
||||
|
||||
try:
|
||||
deployment = core_v1_api.delete_namespaced_deployment(name=dep_name, 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:
|
||||
delete_docker_repo_secret(app_name, namespace, self)
|
||||
_logger.info(str(deployment))
|
||||
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
299
kk_odoo_saas/utils/ingress.py
Executable file
299
kk_odoo_saas/utils/ingress.py
Executable file
@@ -0,0 +1,299 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from kubernetes import client, config
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
|
||||
|
||||
def create_ingress(app_name, self=False):
|
||||
_logger = logging.getLogger(__name__)
|
||||
if self:
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
# create_ingress
|
||||
if not self.domain_name and self.sub_domain_name:
|
||||
return ValidationError('Either Domain name or Subdomain name is not Valid')
|
||||
else:
|
||||
host = f'{self.sub_domain_name}.{self.domain_name}'
|
||||
|
||||
networking_v1_api = client.NetworkingV1Api()
|
||||
|
||||
rules = [
|
||||
client.V1IngressRule(
|
||||
host=host,
|
||||
http=client.V1HTTPIngressRuleValue(
|
||||
paths=[
|
||||
client.V1HTTPIngressPath(
|
||||
path='/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=80,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
),
|
||||
client.V1HTTPIngressPath(
|
||||
path='/longpolling/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=8072,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
odoo_urls = [host]
|
||||
|
||||
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='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=80,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
),
|
||||
client.V1HTTPIngressPath(
|
||||
path='/longpolling/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=8072,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
odoo_urls.append(custom_domain.name)
|
||||
body = client.V1Ingress(
|
||||
kind='Ingress',
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f'{app_name}-ingress',
|
||||
labels={'app': app_name},
|
||||
annotations={
|
||||
'kubernetes.io/ingress.class': 'nginx',
|
||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||
},
|
||||
),
|
||||
spec=client.V1IngressSpec(
|
||||
rules=rules,
|
||||
tls=[
|
||||
client.V1IngressTLS(
|
||||
hosts=odoo_urls,
|
||||
secret_name=f'{self.app_name}tls',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
try:
|
||||
networking_v1_api.create_namespaced_ingress(
|
||||
namespace='default',
|
||||
body=body
|
||||
)
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
def delete_odoo_ingress(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
networking_v1_api = client.NetworkingV1Api()
|
||||
ing_name = f'{app_name}-ingress'
|
||||
|
||||
try:
|
||||
ing = networking_v1_api.delete_namespaced_ingress(name=ing_name, namespace=namespace)
|
||||
_logger.info(str(ing))
|
||||
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
def update_odoo_ingress(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
networking_v1_api = client.NetworkingV1Api()
|
||||
|
||||
ing_name = f'{app_name}-ingress'
|
||||
|
||||
if not self.domain_name and self.sub_domain_name:
|
||||
return ValidationError('Either Domain name or Subdomain name is not Valid')
|
||||
else:
|
||||
host = f'{self.sub_domain_name}.{self.domain_name}'
|
||||
|
||||
rules = [
|
||||
client.V1IngressRule(
|
||||
host=host,
|
||||
http=client.V1HTTPIngressRuleValue(
|
||||
paths=[
|
||||
client.V1HTTPIngressPath(
|
||||
path='/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=80,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
),
|
||||
client.V1HTTPIngressPath(
|
||||
path='/longpolling/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=8072,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
tls_hosts = [host]
|
||||
|
||||
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='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=80,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
),
|
||||
client.V1HTTPIngressPath(
|
||||
path='/longpolling/',
|
||||
path_type='ImplementationSpecific',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(
|
||||
number=8072,
|
||||
),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
tls_hosts.append(custom_domain.name)
|
||||
|
||||
body = client.V1Ingress(
|
||||
kind='Ingress',
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f'{app_name}-ingress',
|
||||
labels={"app": app_name},
|
||||
annotations={
|
||||
'kubernetes.io/ingress.class': 'nginx',
|
||||
}
|
||||
),
|
||||
spec=client.V1IngressSpec(
|
||||
rules=rules,
|
||||
tls=[
|
||||
client.V1IngressTLS(
|
||||
hosts=tls_hosts,
|
||||
secret_name=f'{self.app_name}tls'
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
ing = networking_v1_api.patch_namespaced_ingress(name=ing_name, namespace=namespace, body=body)
|
||||
_logger.info(str(ing))
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
# ======================================================================================================================
|
||||
def create_ingress_http(app_name, self=False):
|
||||
_logger = logging.getLogger(__name__)
|
||||
if self:
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
# create_ingress
|
||||
if not self.domain_name and self.sub_domain_name:
|
||||
return ValidationError('Either Domain name or Subdomain name is not Valid')
|
||||
|
||||
networking_v1_api = client.NetworkingV1Api()
|
||||
host = f'{self.sub_domain_name}.{self.domain_name}'
|
||||
|
||||
rules = [
|
||||
client.V1IngressRule(
|
||||
host=host,
|
||||
http=client.V1HTTPIngressRuleValue(
|
||||
paths=[
|
||||
client.V1HTTPIngressPath(
|
||||
path='/',
|
||||
path_type='Prefix',
|
||||
backend=client.V1IngressBackend(
|
||||
service=client.V1IngressServiceBackend(
|
||||
port=client.V1ServiceBackendPort(number=8069),
|
||||
name=f'{app_name}-odoo-service',
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
body = client.V1Ingress(
|
||||
kind='Ingress',
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f'{app_name}-ingress',
|
||||
labels={'app': app_name},
|
||||
annotations={
|
||||
'kubernetes.io/ingress.class': 'nginx',
|
||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||
},
|
||||
),
|
||||
spec=client.V1IngressSpec(
|
||||
rules=rules,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
networking_v1_api.create_namespaced_ingress(namespace='default', body=body)
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
271
kk_odoo_saas/utils/k8s_deployment.py
Executable file
271
kk_odoo_saas/utils/k8s_deployment.py
Executable file
@@ -0,0 +1,271 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from kubernetes import config, client
|
||||
from kubernetes.stream import stream
|
||||
import yaml
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
|
||||
from .pg_server import delete_databases
|
||||
from .utils import generate_commit_sha
|
||||
from .odoo_components import (
|
||||
deploy_odoo_components,
|
||||
delete_odoo_components,
|
||||
delete_odoo_components_from_options,
|
||||
update_odoo_components
|
||||
)
|
||||
|
||||
|
||||
def create_deployment(app_name, config_file, self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
"""
|
||||
Configs can be set in Configuration class directly or using helper
|
||||
utility. If no argument provided, the config will be loaded from
|
||||
default location.
|
||||
"""
|
||||
try:
|
||||
data2 = yaml.safe_load(config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
if app_name:
|
||||
deploy_odoo_components(app_name=app_name, namespace="default", self=self)
|
||||
else:
|
||||
_logger.error("Cant find App Name")
|
||||
raise UserError("Cant find App Name")
|
||||
|
||||
|
||||
def delete_app_with_options(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
try:
|
||||
data = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
if self.app_name:
|
||||
delete_odoo_components_from_options(
|
||||
app_name=self.app_name,
|
||||
namespace="default",
|
||||
self=self,
|
||||
delete_db=delete_db,
|
||||
delete_pv=delete_pv,
|
||||
delete_svc=delete_svc,
|
||||
delete_ing=delete_ing,
|
||||
delete_deployment=delete_deployment
|
||||
)
|
||||
if delete_db:
|
||||
delete_databases(self)
|
||||
else:
|
||||
_logger.error("Cant find App Name")
|
||||
raise UserError("Cant find App Name")
|
||||
|
||||
|
||||
def update_app(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
try:
|
||||
data = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
if self.app_name:
|
||||
update_odoo_components(app_name=self.app_name, namespace="default", self=self)
|
||||
else:
|
||||
_logger.error("Cant find App Name")
|
||||
raise UserError("Cant find App Name")
|
||||
|
||||
|
||||
def fetch_secrets_from_cluster(self):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
try:
|
||||
data2 = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
|
||||
secs = []
|
||||
if self.app_name:
|
||||
core_v1_api = client.CoreV1Api()
|
||||
secrs = core_v1_api.list_namespaced_secret(namespace='default')
|
||||
for sec in secrs.items:
|
||||
secs.append(sec.metadata.name)
|
||||
return secs
|
||||
|
||||
|
||||
def deploy_apps_from_git(self):
|
||||
"""
|
||||
To pull code from github inside running container
|
||||
"""
|
||||
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
try:
|
||||
data2 = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
|
||||
if self.app_name:
|
||||
core_v1_api = client.CoreV1Api()
|
||||
pod = 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(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():
|
||||
_logger.info(str(resp.read_stdout()))
|
||||
if resp.peek_stderr():
|
||||
is_clone_error = True
|
||||
error = resp.read_stderr()
|
||||
_logger.error(str(error))
|
||||
break
|
||||
resp.close()
|
||||
|
||||
if is_clone_error:
|
||||
if error and "not a git repository (or any" in error:
|
||||
resp1 = stream(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(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():
|
||||
_logger.info(str(resp.read_stdout()))
|
||||
if resp.peek_stderr():
|
||||
error = resp.read_stderr()
|
||||
_logger.error(str(error))
|
||||
else:
|
||||
_logger.info(str(
|
||||
"No Response"
|
||||
))
|
||||
resp.close()
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def restart_odoo_service(self):
|
||||
|
||||
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
try:
|
||||
data2 = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
|
||||
if self.app_name:
|
||||
core_v1_api = client.CoreV1Api()
|
||||
pod = core_v1_api.list_namespaced_pod(
|
||||
namespace='default',
|
||||
label_selector=f'app={self.app_name}')
|
||||
exec_command = ['./mnt/restart_odoo.sh']
|
||||
resp = stream(
|
||||
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():
|
||||
_logger.info(str(resp.read_stdout()))
|
||||
if resp.peek_stderr():
|
||||
error = resp.read_stderr()
|
||||
_logger.error(str(error))
|
||||
break
|
||||
resp.close()
|
||||
|
||||
|
||||
def read_deployment(self, dep_type='odoo'):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
try:
|
||||
data = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
if self.app_name:
|
||||
dep_name = self.app_name + "-odoo-deployment"
|
||||
core_v1_api = client.AppsV1Api()
|
||||
|
||||
try:
|
||||
deployment = core_v1_api.read_namespaced_deployment(name=dep_name, namespace='default')
|
||||
if deployment:
|
||||
return deployment
|
||||
return
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
_logger.error("Cant find App Name")
|
||||
raise UserError("Cant find App Name")
|
||||
|
||||
|
||||
def update_deployment(self, container_arguments, dep_type='odoo', env_vars=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
try:
|
||||
data = yaml.safe_load(self.configuration.config_file)
|
||||
config.load_kube_config_from_dict(data)
|
||||
except config.config_exception.ConfigException as e:
|
||||
_logger.error(str(e))
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
if self.app_name:
|
||||
core_v1_api = client.AppsV1Api()
|
||||
try:
|
||||
deployment = read_deployment(self=self)
|
||||
if container_arguments:
|
||||
deployment.spec.template.spec.containers[0].args = eval(container_arguments)
|
||||
if env_vars:
|
||||
deployment.spec.template.spec.containers[0].env = env_vars
|
||||
deployment.spec.template.metadata.labels['COMMIT_SHA'] = generate_commit_sha(10)
|
||||
|
||||
patched_deployment = core_v1_api.patch_namespaced_deployment(name=deployment.metadata.name,
|
||||
namespace='default',
|
||||
body=deployment)
|
||||
return patched_deployment
|
||||
except Exception as e:
|
||||
_logger.error(str(e))
|
||||
else:
|
||||
_logger.error("Cant find App Name")
|
||||
raise UserError("Cant find App Name")
|
||||
34
kk_odoo_saas/utils/logs.py
Executable file
34
kk_odoo_saas/utils/logs.py
Executable file
@@ -0,0 +1,34 @@
|
||||
from kubernetes import config, client
|
||||
import yaml
|
||||
from kubernetes.client.rest import ApiException
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def read_logs(app_name, self=False, config_file=None, since_seconds=None, previous=False, tail_lines=None):
|
||||
# Configs can be set in Configuration class directly or using helper
|
||||
# utility. If no argument provided, the config will be loaded from
|
||||
# default location.
|
||||
try:
|
||||
data2 = yaml.safe_load(config_file)
|
||||
config.load_kube_config_from_dict(data2)
|
||||
|
||||
except config.config_exception.ConfigException as e:
|
||||
raise UserError("Unable to Connect K8s Cluster")
|
||||
|
||||
try:
|
||||
api_instance = client.CoreV1Api()
|
||||
odoo_pods = api_instance.list_namespaced_pod(namespace='default',
|
||||
label_selector='app={0},tier={1}'.format(str(self.app_name),
|
||||
'backend'))
|
||||
for pod in odoo_pods.items:
|
||||
if pod.metadata and pod.metadata.name and (self.app_name + '-odoo-deployment' in pod.metadata.name):
|
||||
odoo_logs = api_instance.read_namespaced_pod_log(name=pod.metadata.name, namespace='default',
|
||||
tail_lines=tail_lines, since_seconds=since_seconds)
|
||||
return odoo_logs
|
||||
return False
|
||||
except ApiException as e:
|
||||
_logger.error(e)
|
||||
return False
|
||||
39
kk_odoo_saas/utils/odoo_components.py
Executable file
39
kk_odoo_saas/utils/odoo_components.py
Executable file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .service import create_odoo_service, delete_odoo_service
|
||||
from .deployment import create_odoo_deployment, delete_odoo_deployment
|
||||
from .pv_claim import create_odoo_pv_claim, delete_odoo_pv_claim
|
||||
from .ingress import delete_odoo_ingress, update_odoo_ingress
|
||||
from .utils import delete_job_task
|
||||
|
||||
|
||||
def deploy_odoo_components(app_name, namespace, self=False):
|
||||
create_odoo_pv_claim(app_name, namespace, self=self)
|
||||
create_odoo_service(app_name, namespace, self=self)
|
||||
create_odoo_deployment(app_name, namespace, self=self)
|
||||
|
||||
|
||||
def delete_odoo_components(app_name, namespace, self=False):
|
||||
delete_odoo_pv_claim(app_name, namespace, self=self)
|
||||
delete_odoo_service(app_name, namespace, self=self)
|
||||
delete_odoo_deployment(app_name, namespace, self=self)
|
||||
delete_odoo_ingress(app_name, namespace, self=self)
|
||||
delete_job_task(self)
|
||||
|
||||
|
||||
def delete_odoo_components_from_options(
|
||||
app_name, namespace, self=False, delete_db=False, delete_pv=False,
|
||||
delete_svc=False, delete_ing=False, delete_deployment=False):
|
||||
if delete_pv:
|
||||
delete_odoo_pv_claim(app_name, namespace, self=self)
|
||||
if delete_svc:
|
||||
delete_odoo_service(app_name, namespace, self=self)
|
||||
if delete_deployment:
|
||||
delete_odoo_deployment(app_name, namespace, self=self)
|
||||
if delete_ing:
|
||||
delete_odoo_ingress(app_name, namespace, self=self)
|
||||
delete_job_task(self)
|
||||
|
||||
|
||||
def update_odoo_components(app_name, namespace, self=False):
|
||||
update_odoo_ingress(app_name, namespace, self)
|
||||
68
kk_odoo_saas/utils/pg_query.py
Executable file
68
kk_odoo_saas/utils/pg_query.py
Executable file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import psycopg2
|
||||
import sys
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PgQuery(object):
|
||||
"""
|
||||
USAGE:
|
||||
postgresX = ['localhost', 'sadsadsad', 'admin', 'codetuple']
|
||||
pgX = TaskMigration(*postgresX)
|
||||
with pgX:
|
||||
result = pgX.selectQuery(query)
|
||||
"""
|
||||
|
||||
def __init__(self, host, database, user, password, port=5432):
|
||||
self.host = host
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.dbConnection = False
|
||||
self.cursor = False
|
||||
self.port = port
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.dbConnection = psycopg2.connect(
|
||||
host=self.host,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
port=self.port,
|
||||
)
|
||||
self.cursor = self.dbConnection.cursor()
|
||||
except Exception as e:
|
||||
_logger.info("Error in Postgres Connection: %r" % e)
|
||||
sys.exit()
|
||||
return self.dbConnection
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.dbConnection:
|
||||
# self.dbConnection.close()
|
||||
pass
|
||||
|
||||
def select_query(self, queryString):
|
||||
status = True
|
||||
try:
|
||||
self.cursor.execute(queryString)
|
||||
except Exception as e:
|
||||
status = False
|
||||
return status
|
||||
else:
|
||||
return self.cursor.fetchall()
|
||||
|
||||
def execute_query(self, queryString):
|
||||
status = True
|
||||
try:
|
||||
self.cursor.execute(queryString)
|
||||
self.dbConnection.commit()
|
||||
except Exception as e:
|
||||
_logger.info(queryString)
|
||||
_logger.info(e)
|
||||
status = False
|
||||
finally:
|
||||
return status
|
||||
55
kk_odoo_saas/utils/pg_server.py
Executable file
55
kk_odoo_saas/utils/pg_server.py
Executable file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from contextlib import closing
|
||||
from psycopg2 import sql, connect
|
||||
|
||||
import odoo
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
|
||||
from .utils import generate_temp_password
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def drop_db(self, db_name):
|
||||
if self:
|
||||
child_conn = self.get_pg_db_connection(db=db_name)
|
||||
child_conn.set_session(autocommit=True)
|
||||
|
||||
with closing(child_conn.cursor()) as cr:
|
||||
odoo.service.db._drop_conn(cr, db_name)
|
||||
try:
|
||||
cr.execute(sql.SQL('DROP DATABASE {}').format(sql.Identifier(db_name)))
|
||||
except Exception as e:
|
||||
_logger.info('DROP DB: %s failed:\n%s', db_name, e)
|
||||
child_conn.close()
|
||||
raise UserError("Couldn't drop database %s: %s" % (db_name, e))
|
||||
else:
|
||||
child_conn.close()
|
||||
_logger.info('DROP DB: %s', db_name)
|
||||
return True
|
||||
|
||||
|
||||
def delete_databases(self):
|
||||
if self and self.client_db_name:
|
||||
# dbs = get_databases(self)
|
||||
drop_db(self, self.client_db_name)
|
||||
|
||||
|
||||
def get_admin_credentials(self):
|
||||
if self and self.client_db_name:
|
||||
# FOR admin user_id = 2
|
||||
child_conn = self.get_pg_db_connection(db=self.client_db_name)
|
||||
query = sql.SQL("SELECT login, COALESCE(password, '') FROM res_users WHERE id=2;")
|
||||
with closing(child_conn.cursor()) as cr:
|
||||
try:
|
||||
cr.execute(query)
|
||||
res = cr.fetchall()
|
||||
child_conn.close()
|
||||
except Exception:
|
||||
_logger.exception('Getting Credentials failed')
|
||||
res = False
|
||||
child_conn.close()
|
||||
return res, self.client_db_name
|
||||
return False, False
|
||||
55
kk_odoo_saas/utils/pv_claim.py
Executable file
55
kk_odoo_saas/utils/pv_claim.py
Executable file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from kubernetes import client, config
|
||||
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
|
||||
|
||||
def create_pv_claim(meta_data, specs, namespace='default', self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
k8s_apps_v1 = client.CoreV1Api()
|
||||
|
||||
dep = client.V1PersistentVolumeClaim(
|
||||
api_version='v1',
|
||||
kind='PersistentVolumeClaim',
|
||||
metadata=meta_data,
|
||||
spec=specs
|
||||
)
|
||||
try:
|
||||
resp = k8s_apps_v1.create_namespaced_persistent_volume_claim(body=dep, namespace=namespace)
|
||||
_logger.info(f'Volume created. status={resp.metadata.name}')
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(msg=str(e))
|
||||
|
||||
|
||||
def create_odoo_pv_claim(app_name, namespace="default", self=False):
|
||||
specs = client.V1PersistentVolumeClaimSpec(
|
||||
access_modes=[
|
||||
'ReadWriteOnce',
|
||||
],
|
||||
storage_class_name="gp2",
|
||||
resources=client.V1ResourceRequirements(
|
||||
requests={
|
||||
'storage': '1Gi', # TODO: setup in app
|
||||
}
|
||||
)
|
||||
)
|
||||
meta_data = client.V1ObjectMeta(
|
||||
name=F'{app_name}-odoo-web-pv-claim',
|
||||
labels={'app': app_name}
|
||||
)
|
||||
create_pv_claim(meta_data=meta_data, specs=specs, namespace=namespace, self=self)
|
||||
|
||||
|
||||
def delete_odoo_pv_claim(app_name, namespace="default", self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
claim_name = f'{app_name}-odoo-web-pv-claim'
|
||||
core_v1_api = client.CoreV1Api()
|
||||
|
||||
try:
|
||||
pv = core_v1_api.delete_namespaced_persistent_volume_claim(name=claim_name, namespace=namespace)
|
||||
_logger.info(str(pv))
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
65
kk_odoo_saas/utils/service.py
Executable file
65
kk_odoo_saas/utils/service.py
Executable file
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
|
||||
from kubernetes import client
|
||||
|
||||
from odoo.addons.smile_log.tools import SmileDBLogger
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_service(specs, metadata, namespace='default', self=False):
|
||||
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
|
||||
|
||||
core_v1_api = client.CoreV1Api()
|
||||
body = client.V1Service(
|
||||
api_version='v1',
|
||||
kind='Service',
|
||||
metadata=metadata,
|
||||
spec=specs
|
||||
)
|
||||
# Creation of the Deployment in specified namespace
|
||||
try:
|
||||
service = core_v1_api.create_namespaced_service(namespace=namespace, body=body)
|
||||
_logger.info(f'Service created. status={service.metadata.name}')
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
def create_odoo_service(app_name, namespace, self=False):
|
||||
service_name = f'{app_name}-odoo-service'
|
||||
specs = client.V1ServiceSpec(
|
||||
selector={
|
||||
'app': app_name,
|
||||
'tier': 'backend',
|
||||
},
|
||||
ports=[
|
||||
client.V1ServicePort(
|
||||
name='odoo-port',
|
||||
protocol='TCP',
|
||||
port=8069,
|
||||
target_port=8069,
|
||||
)
|
||||
],
|
||||
type='NodePort'
|
||||
)
|
||||
metadata = client.V1ObjectMeta(
|
||||
name=service_name,
|
||||
labels={'app': app_name}
|
||||
)
|
||||
create_service(metadata=metadata, specs=specs, namespace=namespace, self=self)
|
||||
|
||||
|
||||
def delete_odoo_service(app_name, namespace, self=False):
|
||||
service_name = f'{app_name}-odoo-service'
|
||||
core_v1_api = client.CoreV1Api()
|
||||
try:
|
||||
service = core_v1_api.delete_namespaced_service(name=service_name, namespace=namespace)
|
||||
_logger.info(service)
|
||||
except client.exceptions.ApiException as e:
|
||||
_logger.error(str(e))
|
||||
|
||||
|
||||
|
||||
|
||||
34
kk_odoo_saas/utils/utils.py
Executable file
34
kk_odoo_saas/utils/utils.py
Executable file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
def generate_temp_password(length):
|
||||
if not isinstance(length, int) or length < 8:
|
||||
raise ValueError("temp password must have positive length")
|
||||
|
||||
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*()"
|
||||
from os import urandom
|
||||
# Python 3 (urandom returns bytes)
|
||||
return "".join(chars[c % len(chars)] for c in urandom(length))
|
||||
|
||||
|
||||
def generate_commit_sha(length):
|
||||
if not isinstance(length, int) or length < 8:
|
||||
raise ValueError("sha must have positive length")
|
||||
|
||||
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
from os import urandom
|
||||
# Python 3 (urandom returns bytes)
|
||||
return "".join(chars[c % len(chars)] for c in urandom(length))
|
||||
|
||||
|
||||
def delete_job_task(self):
|
||||
if self and self.id:
|
||||
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()
|
||||
Reference in New Issue
Block a user