diff --git a/bemade_k8s_odoo_manager/__init__.py b/bemade_k8s_odoo_manager/__init__.py new file mode 100644 index 0000000..c3d410e --- /dev/null +++ b/bemade_k8s_odoo_manager/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/bemade_k8s_odoo_manager/__manifest__.py b/bemade_k8s_odoo_manager/__manifest__.py new file mode 100644 index 0000000..88d4ee8 --- /dev/null +++ b/bemade_k8s_odoo_manager/__manifest__.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Kubernetes Odoo Manager', + 'version': '1.0.0', + 'category': 'Administration', + 'summary': 'Manage Odoo instances running on Kubernetes', + 'author': 'Bemade Inc.', + 'website': 'https://bemade.org', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'web', + 'mail', + ], + 'data': [ + 'security/k8s_odoo_manager_security.xml', + 'security/ir.model.access.csv', + 'views/k8s_cluster_views.xml', + 'views/k8s_odoo_instance_views.xml', + 'views/k8s_odoo_manager_menus.xml', + 'data/k8s_odoo_manager_data.xml', + 'wizards/wizard_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'bemade_k8s_odoo_manager/static/src/js/instance_dashboard.js', + 'bemade_k8s_odoo_manager/static/src/scss/instance_dashboard.scss', + 'bemade_k8s_odoo_manager/static/src/xml/instance_dashboard.xml', + ], + }, + 'installable': True, + 'application': True, + 'auto_install': False, + 'description': """ +Kubernetes Odoo Manager +====================== + +This module provides an interface for managing Odoo instances running on Kubernetes clusters. + +Features +-------- + +* **Kubernetes Connection**: + - Connect to a Kubernetes cluster using kubeconfig file authentication + - Securely store and manage kubeconfig credentials + +* **Odoo Instance Management**: + - Create, update, and delete Odoo instances + - Scale instances up or down + - Configure resources (CPU, memory, storage) + - Manage domain names and TLS certificates + - Set up custom configuration options + +* **Upgrade Management**: + - Schedule and execute module upgrades + - Track upgrade history + +Technical Information +-------------------- + +This module interfaces with the Kubernetes API to manage Odoo instances using the Odoo Operator +custom resource definition (CRD). It provides a user-friendly interface for operations that would +otherwise require command-line tools or direct API access. + +The module is designed to work with the Bemade Odoo Operator for Kubernetes, which handles the +actual deployment and management of Odoo instances on the cluster. + """, +} diff --git a/bemade_k8s_odoo_manager/init/k8s_odoo_upgrade.sql b/bemade_k8s_odoo_manager/init/k8s_odoo_upgrade.sql new file mode 100644 index 0000000..b9499e4 --- /dev/null +++ b/bemade_k8s_odoo_manager/init/k8s_odoo_upgrade.sql @@ -0,0 +1,17 @@ +CREATE OR REPLACE VIEW k8s_odoo_upgrade AS +SELECT + 0 AS id, + NULL::integer AS instance_id, + NULL::integer AS cluster_id, + NULL::varchar AS job_name, + NULL::varchar AS display_name, + NULL::varchar AS state, + NULL::timestamp AS scheduled_date, + NULL::timestamp AS start_date, + NULL::timestamp AS end_date, + NULL::float AS duration, + NULL::varchar AS modules, + NULL::varchar AS database, + NULL::varchar AS status_message, + NULL::text AS log +WHERE 1=0; -- Empty view by default diff --git a/bemade_k8s_odoo_manager/models/__init__.py b/bemade_k8s_odoo_manager/models/__init__.py new file mode 100644 index 0000000..e45bebe --- /dev/null +++ b/bemade_k8s_odoo_manager/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import k8s_cluster +from . import k8s_odoo_instance diff --git a/bemade_k8s_odoo_manager/models/k8s_cluster.py b/bemade_k8s_odoo_manager/models/k8s_cluster.py new file mode 100644 index 0000000..b9396fd --- /dev/null +++ b/bemade_k8s_odoo_manager/models/k8s_cluster.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +import base64 +import logging +import tempfile +import os +import re +import kubernetes.client +import kubernetes.config +from kubernetes.client.rest import ApiException + +_logger = logging.getLogger(__name__) + +class K8sCluster(models.Model): + _name = 'k8s.cluster' + _description = 'Kubernetes Cluster' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'name' + + name = fields.Char( + string='Name', + required=True, + tracking=True, + help='Name of the Kubernetes cluster' + ) + description = fields.Text( + string='Description', + tracking=True, + help='Description of the cluster and its purpose' + ) + active = fields.Boolean( + string='Active', + default=True, + tracking=True, + help='Whether this cluster configuration is active' + ) + kubeconfig_attachment_id = fields.Many2one( + 'ir.attachment', + string='Kubeconfig File', + required=True, + ondelete='restrict', + domain="[('res_model', '=', 'k8s.cluster'), ('res_field', '=', 'kubeconfig_data')]", + help='The kubeconfig file for connecting to the cluster' + ) + kubeconfig_data = fields.Binary( + string='Kubeconfig Data', + attachment=True, + required=True, + help='The kubeconfig file content' + ) + kubeconfig_filename = fields.Char(string='Kubeconfig Filename') + + namespace = fields.Char( + string='Default Namespace', + default='default', + required=True, + help='The default namespace to use for operations' + ) + + default_tls_issuer = fields.Char( + string='Default TLS Issuer', + default='letsencrypt-prod', + required=True, + help='The default cert-manager issuer to use for TLS certificates' + ) + + connection_status = fields.Selection([ + ('not_tested', 'Not Tested'), + ('connected', 'Connected'), + ('failed', 'Connection Failed') + ], string='Connection Status', default='not_tested', readonly=True) + + connection_error = fields.Text(string='Connection Error', readonly=True) + last_connection_test = fields.Datetime(string='Last Connection Test', readonly=True) + + cluster_version = fields.Char(string='Kubernetes Version', readonly=True) + node_count = fields.Integer(string='Node Count', readonly=True) + + # Related records + instance_count = fields.Integer( + string='Instance Count', + compute='_compute_instance_count', + help='Number of Odoo instances running on this cluster' + ) + instance_ids = fields.One2many( + 'k8s.odoo.instance', + 'cluster_id', + string='Odoo Instances' + ) + + @api.depends('instance_ids') + def _compute_instance_count(self): + for cluster in self: + cluster.instance_count = len(cluster.instance_ids) + + @api.constrains('namespace') + def _check_namespace(self): + for cluster in self: + if not re.match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', cluster.namespace): + raise ValidationError(_("Namespace must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.")) + + def action_test_connection(self): + """Test the connection to the Kubernetes cluster""" + self.ensure_one() + + try: + # Create a temporary file for the kubeconfig if needed + temp_files = [] + + # Configure the Kubernetes client using kubeconfig + if not self.kubeconfig_data: + raise UserError(_('Kubeconfig file is required.')) + + # Create a temporary file for the kubeconfig + kubeconfig_fd, kubeconfig_path = tempfile.mkstemp() + temp_files.append(kubeconfig_path) + with os.fdopen(kubeconfig_fd, 'wb') as f: + f.write(base64.b64decode(self.kubeconfig_data)) + + # Load the kubeconfig + kubernetes.config.load_kube_config(config_file=kubeconfig_path) + + # Create API clients + core_api = kubernetes.client.CoreV1Api() + version_api = kubernetes.client.VersionApi() + + # Test the connection by getting the cluster version + version_info = version_api.get_code() + + # Get the node count + nodes = core_api.list_node() + node_count = len(nodes.items) + + # Update the cluster information + self.write({ + 'connection_status': 'connected', + 'connection_error': False, + 'last_connection_test': fields.Datetime.now(), + 'cluster_version': f"{version_info.major}.{version_info.minor}", + 'node_count': node_count, + }) + + # Clean up temporary files + for temp_file in temp_files: + try: + os.unlink(temp_file) + except (OSError, IOError): + pass + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Connection Test Successful'), + 'message': _('Successfully connected to Kubernetes cluster %s (version %s) with %s nodes.') % ( + self.name, self.cluster_version, self.node_count + ), + 'sticky': False, + 'type': 'success', + } + } + + except ApiException as e: + # Clean up temporary files + for temp_file in temp_files: + try: + os.unlink(temp_file) + except (OSError, IOError): + pass + + error_message = f"API Error: {e.reason}" + self.write({ + 'connection_status': 'failed', + 'connection_error': error_message, + 'last_connection_test': fields.Datetime.now(), + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Connection Test Failed'), + 'message': error_message, + 'sticky': False, + 'type': 'danger', + } + } + + except Exception as e: + # Clean up temporary files + for temp_file in temp_files: + try: + os.unlink(temp_file) + except (OSError, IOError): + pass + + error_message = f"Error: {str(e)}" + self.write({ + 'connection_status': 'failed', + 'connection_error': error_message, + 'last_connection_test': fields.Datetime.now(), + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Connection Test Failed'), + 'message': error_message, + 'sticky': False, + 'type': 'danger', + } + } + + def get_k8s_client(self): + """Get a configured Kubernetes client for this cluster""" + self.ensure_one() + + # Create a temporary file for the kubeconfig if needed + temp_files = [] + + try: + # Configure the Kubernetes client using kubeconfig + if not self.kubeconfig_data: + raise UserError(_('Kubeconfig file is required.')) + + # Create a temporary file for the kubeconfig + kubeconfig_fd, kubeconfig_path = tempfile.mkstemp() + temp_files.append(kubeconfig_path) + with os.fdopen(kubeconfig_fd, 'wb') as f: + f.write(base64.b64decode(self.kubeconfig_data)) + + # Load the kubeconfig + kubernetes.config.load_kube_config(config_file=kubeconfig_path) + + # Create and return the API client + return kubernetes.client.ApiClient() + + except Exception as e: + # Clean up temporary files + for temp_file in temp_files: + try: + os.unlink(temp_file) + except (OSError, IOError): + pass + + raise UserError(_("Failed to configure Kubernetes client: %s") % str(e)) + + def action_view_instances(self): + """View Odoo instances running on this cluster""" + self.ensure_one() + + return { + 'name': _('Odoo Instances'), + 'type': 'ir.actions.act_window', + 'res_model': 'k8s.odoo.instance', + 'view_mode': 'tree,form', + 'domain': [('cluster_id', '=', self.id)], + 'context': {'default_cluster_id': self.id}, + } + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('k8s.cluster') or _('New') + + # Create attachment for kubeconfig + if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'): + attachment_vals = { + 'name': vals.get('kubeconfig_filename', 'kubeconfig'), + 'type': 'binary', + 'datas': vals['kubeconfig_data'], + 'res_model': 'k8s.cluster', + 'res_field': 'kubeconfig_data', + } + attachment = self.env['ir.attachment'].create(attachment_vals) + vals['kubeconfig_attachment_id'] = attachment.id + + return super().create(vals_list) + + def write(self, vals): + # Update attachment if kubeconfig changes + if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'): + for record in self: + # Delete old attachment if it exists + if record.kubeconfig_attachment_id: + record.kubeconfig_attachment_id.unlink() + + # Create new attachment + attachment_vals = { + 'name': vals.get('kubeconfig_filename', record.kubeconfig_filename or 'kubeconfig'), + 'type': 'binary', + 'datas': vals['kubeconfig_data'], + 'res_model': 'k8s.cluster', + 'res_field': 'kubeconfig_data', + } + attachment = self.env['ir.attachment'].create(attachment_vals) + vals['kubeconfig_attachment_id'] = attachment.id + + return super().write(vals) + + def unlink(self): + # Delete attachments when records are deleted + attachments = self.mapped('kubeconfig_attachment_id') + result = super().unlink() + if result and attachments: + attachments.unlink() + return result diff --git a/bemade_k8s_odoo_manager/models/k8s_odoo_instance.py b/bemade_k8s_odoo_manager/models/k8s_odoo_instance.py new file mode 100644 index 0000000..49c0922 --- /dev/null +++ b/bemade_k8s_odoo_manager/models/k8s_odoo_instance.py @@ -0,0 +1,1280 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +import kubernetes.client +from kubernetes.client.rest import ApiException +import logging +import json +import re +import yaml +from datetime import datetime, timedelta + +_logger = logging.getLogger(__name__) + + +class K8sOdooInstance(models.Model): + _name = "k8s.odoo.instance" + _description = "Kubernetes Odoo Instance" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "name" + + name = fields.Char( + string="Name", required=True, tracking=True, help="Name of the Odoo instance" + ) + cluster_id = fields.Many2one( + "k8s.cluster", + string="Kubernetes Cluster", + required=True, + tracking=True, + ondelete="cascade", + help="The Kubernetes cluster where this instance is running", + ) + namespace = fields.Char( + string="Namespace", + required=True, + tracking=True, + help="The Kubernetes namespace where this instance is deployed", + ) + description = fields.Text( + string="Description", + tracking=True, + help="Description of the instance and its purpose", + ) + active = fields.Boolean( + string="Active", + default=True, + tracking=True, + help="Whether this instance is active in the system", + ) + + # URLs + odoo_url = fields.Char(string="Odoo URL", compute="_compute_odoo_url", store=True) + + # Related records + upgrade_ids = fields.One2many("k8s.odoo.upgrade", "instance_id", string="Upgrades") + + # ------------------------------------------------------------------------- + # COMPUTED FIELDS FROM KUBERNETES + # ------------------------------------------------------------------------- + + # Flag to indicate if reading from Kubernetes failed + k8s_read_failed = fields.Boolean( + string="Kubernetes Read Failed", + compute="_compute_k8s_config", + store=False, + help="Indicates that reading the configuration from Kubernetes failed", + ) + + # Docker image configuration from Kubernetes + k8s_image = fields.Char( + string="Current Image", + compute="_compute_k8s_config", + store=False, + help="Current Docker image used in Kubernetes", + ) + k8s_image_pull_secret = fields.Char( + string="Current Pull Secret", + compute="_compute_k8s_config", + store=False, + help="Current Kubernetes secret containing Docker registry credentials", + ) + + # Resource configuration from Kubernetes + k8s_cpu_request = fields.Char( + string="Current CPU Request", + compute="_compute_k8s_config", + store=False, + help="CPU request for each Odoo pod (e.g., 200m, 1)", + ) + k8s_memory_request = fields.Char( + string="Current Memory Request", + compute="_compute_k8s_config", + store=False, + help="Memory request for each Odoo pod (e.g., 250Mi, 1Gi)", + ) + k8s_cpu_limit = fields.Char( + string="Current CPU Limit", + compute="_compute_k8s_config", + store=False, + help="CPU limit for each Odoo pod (e.g., 2000m, 2)", + ) + k8s_memory_limit = fields.Char( + string="Current Memory Limit", + compute="_compute_k8s_config", + store=False, + help="Memory limit for each Odoo pod (e.g., 2Gi, 4Gi)", + ) + + # Filestore configuration from Kubernetes + k8s_filestore_size = fields.Char( + string="Current Filestore Size", + compute="_compute_k8s_config", + store=False, + help="Current size of the persistent volume for the filestore", + ) + k8s_filestore_storage_class = fields.Char( + string="Current Storage Class", + compute="_compute_k8s_config", + store=False, + help="Current storage class used for the filestore persistent volume", + ) + + # Ingress configuration from Kubernetes + k8s_host_names = fields.Text( + string="Current Host Names", + compute="_compute_k8s_config", + store=False, + help="Current comma-separated list of host names for accessing the Odoo instance", + ) + k8s_tls_issuer = fields.Char( + string="Current TLS Issuer", + compute="_compute_k8s_config", + store=False, + help="Current cert-manager issuer used for TLS certificates", + ) + + # Odoo configuration from Kubernetes + k8s_config_options = fields.Text( + string="Current Configuration", + compute="_compute_k8s_config", + store=False, + help="Current Odoo configuration options in YAML format", + ) + + # Kubernetes state fields + k8s_status = fields.Selection( + [ + ("draft", "Draft"), + ("pending", "Pending"), + ("running", "Running"), + ("error", "Error"), + ("upgrading", "Upgrading"), + ], + string="Kubernetes Status", + compute="_compute_k8s_state", + store=False, + ) + + # Upgrade tracking fields + upgrade_job_name = fields.Char( + string="Upgrade Job", compute="_compute_k8s_state", store=False + ) + upgrade_modules = fields.Char( + string="Modules to Upgrade", compute="_compute_k8s_state", store=False + ) + upgrade_database = fields.Char( + string="Database to Upgrade", compute="_compute_k8s_state", store=False + ) + k8s_status_message = fields.Text( + string="Kubernetes Status Message", compute="_compute_k8s_state", store=False + ) + k8s_exists = fields.Boolean( + string="Exists in Kubernetes", compute="_compute_k8s_state", store=False + ) + k8s_pods_count = fields.Integer( + string="Pod Count", compute="_compute_k8s_state", store=False + ) + k8s_ready_pods_count = fields.Integer( + string="Ready Pods", compute="_compute_k8s_state", store=False + ) + + # ------------------------------------------------------------------------- + # STORED FIELDS SET BY USER + # ------------------------------------------------------------------------- + + # Docker image configuration set by user + image = fields.Char( + string="Desired Image", + help="Docker image to use for the Odoo instance", + ) + image_pull_secret = fields.Char( + string="Desired Pull Secret", + help="Kubernetes secret containing Docker registry credentials", + ) + + # Resource configuration set by user + cpu_request = fields.Char( + string="Desired CPU Request", + help="CPU request for each Odoo pod (e.g., 200m, 1)", + ) + memory_request = fields.Char( + string="Desired Memory Request", + help="Memory request for each Odoo pod (e.g., 250Mi, 1Gi)", + ) + cpu_limit = fields.Char( + string="Desired CPU Limit", + help="CPU limit for each Odoo pod (e.g., 2000m, 2)", + ) + memory_limit = fields.Char( + string="Desired Memory Limit", + help="Memory limit for each Odoo pod (e.g., 2Gi, 4Gi)", + ) + + # Filestore configuration set by user + filestore_size = fields.Char( + string="Desired Filestore Size", + help="Size of the persistent volume for the filestore", + ) + filestore_storage_class = fields.Char( + string="Desired Storage Class", + help="Storage class used for the filestore persistent volume", + ) + + # Ingress configuration set by user + host_names = fields.Text( + string="Desired Host Names", + help="Comma-separated list of host names for accessing the Odoo instance", + ) + tls_issuer = fields.Char( + string="Desired TLS Issuer", + tracking=True, + help="The cert-manager issuer to use for TLS certificates", + ) + + # Odoo configuration set by user + config_options = fields.Text( + string="Desired Configuration", + help="Additional Odoo configuration options in YAML format", + ) + + # Admin password for deployment and updates + master_password = fields.Char( + string="Admin Password", + help="Master password for the Odoo instance. This will be used for deployment and updates.", + copy=False, # Don't copy when duplicating records + ) + + # Status fields + # Stored fields for Kubernetes state + status = fields.Selection( + [ + ("draft", "Draft"), + ("pending", "Pending"), + ("running", "Running"), + ("error", "Error"), + ("upgrading", "Upgrading"), + ], + string="Status", + default="draft", + readonly=True, + tracking=True, + store=True, + ) + status_message = fields.Text(string="Status Message", readonly=True, store=True) + last_status_check = fields.Datetime( + string="Last Status Check", readonly=True, store=True + ) + + @api.model + def create(self, vals): + """Override create to set default values from the cluster""" + # If cluster_id is set but tls_issuer is not, get the default from the cluster + if vals.get("cluster_id") and not vals.get("tls_issuer"): + cluster = self.env["k8s.cluster"].browse(vals["cluster_id"]) + vals["tls_issuer"] = cluster.default_tls_issuer + + return super(K8sOdooInstance, self).create(vals) + + # Computed fields for real-time Kubernetes state + k8s_status = fields.Selection( + [ + ("draft", "Draft"), + ("pending", "Pending"), + ("running", "Running"), + ("error", "Error"), + ("upgrading", "Upgrading"), + ], + string="Kubernetes Status", + compute="_compute_k8s_state", + store=False, + ) + k8s_status_message = fields.Text( + string="Kubernetes Status Message", compute="_compute_k8s_state", store=False + ) + k8s_exists = fields.Boolean( + string="Exists in Kubernetes", compute="_compute_k8s_state", store=False + ) + k8s_pods_count = fields.Integer( + string="Pod Count", compute="_compute_k8s_state", store=False + ) + k8s_ready_pods_count = fields.Integer( + string="Ready Pods", compute="_compute_k8s_state", store=False + ) + + # URLs + odoo_url = fields.Char(string="Odoo URL", compute="_compute_odoo_url", store=True) + + # Related records + upgrade_ids = fields.One2many("k8s.odoo.upgrade", "instance_id", string="Upgrades") + + @api.depends("name", "namespace", "cluster_id") + def _compute_k8s_config(self): + """Compute all configuration fields from the Kubernetes OdooInstance resource""" + for record in self: + # Reset the read failed flag + record.k8s_read_failed = False + + # Skip if required fields are missing + if not record.name or not record.namespace or not record.cluster_id: + continue + + # Initialize k8s fields with empty values + record.k8s_image = "" + record.k8s_image_pull_secret = "" + record.k8s_cpu_request = "" + record.k8s_memory_request = "" + record.k8s_cpu_limit = "" + record.k8s_memory_limit = "" + record.k8s_filestore_size = "" + record.k8s_filestore_storage_class = "" + record.k8s_host_names = "" + record.k8s_tls_issuer = "" + record.k8s_config_options = "" + + # Only initialize desired state fields if they're empty for new records in draft state + if record.status == "draft": + if not record.image: + record.image = ( + "docker.bemade.org/bemade/bemade-odoo-empty-18.0:latest" + ) + if not record.image_pull_secret: + record.image_pull_secret = "docker.bemade.org" + if not record.cpu_request: + record.cpu_request = "200m" + if not record.memory_request: + record.memory_request = "250Mi" + if not record.cpu_limit: + record.cpu_limit = "2000m" + if not record.memory_limit: + record.memory_limit = "2Gi" + if not record.filestore_size: + record.filestore_size = "2Gi" + if not record.filestore_storage_class: + record.filestore_storage_class = "standard" + if not record.tls_issuer and record.cluster_id: + record.tls_issuer = record.cluster_id.default_tls_issuer + + try: + # Get the Kubernetes client + api_client = record.cluster_id.get_k8s_client() + custom_api = kubernetes.client.CustomObjectsApi(api_client) + + # Get the OdooInstance custom resource + try: + instance = custom_api.get_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=record.namespace, + plural="odooinstances", + name=record.name, + ) + + # Extract configuration values from the OdooInstance resource + spec = instance.get("spec", {}) + + # Image configuration + if "image" in spec: + record.k8s_image = spec["image"] + if "imagePullSecrets" in spec and spec["imagePullSecrets"]: + record.k8s_image_pull_secret = spec["imagePullSecrets"][0][ + "name" + ] + + # Resource configuration + resources = spec.get("resources", {}) + if resources: + requests = resources.get("requests", {}) + limits = resources.get("limits", {}) + + if "cpu" in requests: + record.k8s_cpu_request = requests["cpu"] + if "memory" in requests: + record.k8s_memory_request = requests["memory"] + if "cpu" in limits: + record.k8s_cpu_limit = limits["cpu"] + if "memory" in limits: + record.k8s_memory_limit = limits["memory"] + + # Filestore configuration + if "filestore" in spec: + filestore = spec["filestore"] + if "size" in filestore: + record.k8s_filestore_size = filestore["size"] + if "storageClass" in filestore: + record.k8s_filestore_storage_class = filestore[ + "storageClass" + ] + + # Ingress configuration + if "ingress" in spec: + ingress = spec["ingress"] + if "hosts" in ingress: + record.k8s_host_names = ", ".join(ingress["hosts"]) + if "tlsIssuer" in ingress: + record.k8s_tls_issuer = ingress["tlsIssuer"] + + # Odoo configuration + if "config" in spec: + record.k8s_config_options = yaml.safe_dump(spec["config"]) + + # Initialize desired state fields if they're empty (for new records) + if record.status == "draft": + if not record.image: + record.image = record.k8s_image + if not record.image_pull_secret: + record.image_pull_secret = record.k8s_image_pull_secret + if not record.cpu_request: + record.cpu_request = record.k8s_cpu_request + if not record.memory_request: + record.memory_request = record.k8s_memory_request + if not record.cpu_limit: + record.cpu_limit = record.k8s_cpu_limit + if not record.memory_limit: + record.memory_limit = record.k8s_memory_limit + if not record.filestore_size: + record.filestore_size = record.k8s_filestore_size + if not record.filestore_storage_class: + record.filestore_storage_class = ( + record.k8s_filestore_storage_class + ) + if not record.host_names: + record.host_names = record.k8s_host_names + if not record.tls_issuer: + record.tls_issuer = record.k8s_tls_issuer + if not record.config_options: + record.config_options = record.k8s_config_options + + except ApiException as e: + record.k8s_read_failed = True + if e.status != 404: # Ignore 404 errors + _logger.error(f"Error fetching OdooInstance {record.name}: {e}") + + except Exception as e: + record.k8s_read_failed = True + _logger.error( + f"Error computing Kubernetes config for {record.name}: {e}" + ) + + @api.depends("host_names") + def _compute_odoo_url(self): + for instance in self: + if instance.host_names: + # Use the first host name as the primary URL + hosts = [h.strip() for h in instance.host_names.split(",")] + if hosts: + instance.odoo_url = f"https://{hosts[0]}" + else: + instance.odoo_url = False + else: + instance.odoo_url = False + + @api.constrains("name", "namespace") + def _check_name_namespace(self): + for instance in self: + if not re.match(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", instance.name): + raise ValidationError( + _( + "Instance name must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." + ) + ) + + if not re.match(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", instance.namespace): + raise ValidationError( + _( + "Namespace must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." + ) + ) + + @api.constrains("config_options") + def _check_config_options(self): + for instance in self: + if instance.config_options: + try: + yaml.safe_load(instance.config_options) + except Exception as e: + raise ValidationError( + _("Invalid YAML format in configuration options: %s") % str(e) + ) + + def _prepare_odoo_instance_spec(self): + """Prepare the OdooInstance spec for Kubernetes + + This method prepares the spec for both instance creation and updates. + + Returns: + dict: The spec for the OdooInstance custom resource + """ + # Prepare host names + host_names = [] + if self.host_names: + host_names = [h.strip() for h in self.host_names.split(",")] + + # Parse config options + config_options = {} + if self.config_options: + config_options = yaml.safe_load(self.config_options) + + # Create the base spec + spec = { + "image": self.image, + "resources": { + "requests": { + "cpu": self.cpu_request, + "memory": self.memory_request, + }, + "limits": {"cpu": self.cpu_limit, "memory": self.memory_limit}, + }, + "filestore": { + "size": self.filestore_size, + "storageClass": self.filestore_storage_class, + }, + "ingress": {"hosts": host_names, "tlsIssuer": self.tls_issuer}, + "config": config_options, + } + + # Add admin password if provided + if self.master_password: + spec["adminPassword"] = self.master_password + + # Add image pull secret if specified + if self.image_pull_secret: + spec["imagePullSecrets"] = [{"name": self.image_pull_secret}] + + return spec + + def action_deploy(self): + """Deploy the Odoo instance to Kubernetes using the stored master password""" + self.ensure_one() + + if self.status not in ("draft", "error"): + raise UserError(_("You can only deploy instances in draft or error state.")) + + if not self.master_password: + raise UserError( + _("Please set the admin password before deploying the instance.") + ) + + try: + # Get the Kubernetes client + api_client = self.cluster_id.get_k8s_client() + custom_api = self.cluster_id._get_custom_objects_api() + + # Create the OdooInstance custom resource + odoo_instance = { + "apiVersion": "bemade.org/v1", + "kind": "OdooInstance", + "metadata": {"name": self.name, "namespace": self.namespace}, + "spec": self._prepare_odoo_instance_spec(), + } + + # Create the OdooInstance in Kubernetes + custom_api.create_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=self.namespace, + plural="odooinstances", + body=odoo_instance, + ) + + # Update the status and clear the master password + self.write( + { + "status": "pending", + "status_message": _("Instance deployment initiated"), + "last_status_check": fields.Datetime.now(), + "master_password": False, # Clear the password after deployment + } + ) + + # Schedule a status check + self.env.ref( + "bemade_k8s_odoo_manager.ir_cron_check_instance_status" + ).method_direct_trigger() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deployment Started"), + "message": _( + "Odoo instance deployment has been initiated. It may take a few minutes to complete." + ), + "sticky": False, + "type": "success", + }, + } + + except ApiException as e: + error_message = f"API Error: {e.reason}" + self.write( + { + "status": "error", + "status_message": error_message, + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deployment Failed"), + "message": error_message, + "sticky": False, + "type": "danger", + }, + } + + except Exception as e: + error_message = f"Error: {str(e)}" + self.write( + { + "status": "error", + "status_message": error_message, + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deployment Failed"), + "message": error_message, + "sticky": False, + "type": "danger", + }, + } + + def action_refresh_status(self): + """Manually refresh the status of the instance""" + self.ensure_one() + + # First compute the current Kubernetes state + self._compute_k8s_state() + # Also compute the current Kubernetes configuration + self._compute_k8s_config() + + # Then update the stored fields with the computed values + self.write( + { + "status": self.k8s_status, + "status_message": self.k8s_status_message, + "last_status_check": fields.Datetime.now(), + } + ) + + # Determine notification type based on status + notification_type = "info" + if self.k8s_status == "running": + notification_type = "success" + elif self.k8s_status == "error": + notification_type = "danger" + elif self.k8s_status == "upgrading": + notification_type = "warning" + + # Prepare a detailed message + message = _("Status refreshed from Kubernetes: %s") % self.k8s_status_message + if self.k8s_exists: + message += _(". Pods: %s/%s ready") % ( + self.k8s_ready_pods_count, + self.k8s_pods_count, + ) + + # Return a notification to the user + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Kubernetes Status: %s") % self.k8s_status.capitalize(), + "message": message, + "sticky": False, + "type": notification_type, + }, + } + + def has_config_changes(self): + """Check if there are any configuration changes to apply""" + self.ensure_one() + + # Make sure we have the latest Kubernetes configuration + self._compute_k8s_config() + + # If reading from Kubernetes failed, we can't determine changes + if self.k8s_read_failed: + return False + + # Check for differences between desired and current state + return ( + self.image != self.k8s_image + or self.image_pull_secret != self.k8s_image_pull_secret + or self.cpu_request != self.k8s_cpu_request + or self.memory_request != self.k8s_memory_request + or self.cpu_limit != self.k8s_cpu_limit + or self.memory_limit != self.k8s_memory_limit + or self.filestore_size != self.k8s_filestore_size + or self.filestore_storage_class != self.k8s_filestore_storage_class + or self.host_names != self.k8s_host_names + or self.tls_issuer != self.k8s_tls_issuer + or self.config_options != self.k8s_config_options + ) + + def get_config_changes(self): + """Get a dictionary of configuration changes""" + self.ensure_one() + + # Make sure we have the latest Kubernetes configuration + self._compute_k8s_config() + + changes = {} + + if self.image != self.k8s_image: + changes["image"] = {"current": self.k8s_image, "desired": self.image} + + if self.image_pull_secret != self.k8s_image_pull_secret: + changes["image_pull_secret"] = { + "current": self.k8s_image_pull_secret, + "desired": self.image_pull_secret, + } + + if self.cpu_request != self.k8s_cpu_request: + changes["cpu_request"] = { + "current": self.k8s_cpu_request, + "desired": self.cpu_request, + } + + if self.memory_request != self.k8s_memory_request: + changes["memory_request"] = { + "current": self.k8s_memory_request, + "desired": self.memory_request, + } + + if self.cpu_limit != self.k8s_cpu_limit: + changes["cpu_limit"] = { + "current": self.k8s_cpu_limit, + "desired": self.cpu_limit, + } + + if self.memory_limit != self.k8s_memory_limit: + changes["memory_limit"] = { + "current": self.k8s_memory_limit, + "desired": self.memory_limit, + } + + if self.filestore_size != self.k8s_filestore_size: + changes["filestore_size"] = { + "current": self.k8s_filestore_size, + "desired": self.filestore_size, + } + + if self.filestore_storage_class != self.k8s_filestore_storage_class: + changes["filestore_storage_class"] = { + "current": self.k8s_filestore_storage_class, + "desired": self.filestore_storage_class, + } + + if self.host_names != self.k8s_host_names: + changes["host_names"] = { + "current": self.k8s_host_names, + "desired": self.host_names, + } + + if self.tls_issuer != self.k8s_tls_issuer: + changes["tls_issuer"] = { + "current": self.k8s_tls_issuer, + "desired": self.tls_issuer, + } + + if self.config_options != self.k8s_config_options: + changes["config_options"] = { + "current": self.k8s_config_options, + "desired": self.config_options, + } + + return changes + + def action_apply_changes(self): + """Apply configuration changes to Kubernetes""" + self.ensure_one() + + # Make sure we have the latest Kubernetes configuration + self._compute_k8s_config() + + # Check if reading from Kubernetes failed + if self.k8s_read_failed: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Cannot Apply Changes"), + "message": _( + "Unable to read current configuration from Kubernetes. Please check your connection and try again." + ), + "sticky": True, + "type": "danger", + }, + } + + if not self.has_config_changes(): + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Changes"), + "message": _("There are no configuration changes to apply."), + "sticky": False, + "type": "info", + }, + } + + # Get the Kubernetes client + api_client = self.cluster_id.get_k8s_client() + custom_api = kubernetes.client.CustomObjectsApi(api_client) + + try: + # Get the current OdooInstance resource + instance = custom_api.get_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=self.namespace, + plural="odooinstances", + name=self.name, + ) + + # Replace the spec with our desired values + instance["spec"] = self._prepare_odoo_instance_spec() + + # Update the OdooInstance resource + custom_api.replace_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=self.namespace, + plural="odooinstances", + name=self.name, + body=instance, + ) + + # Update the status and clear the master password + self.write( + { + "status": "upgrading", + "status_message": _("Configuration changes are being applied"), + "last_status_check": fields.Datetime.now(), + "master_password": False, # Clear the password after applying changes + } + ) + + # Return a success notification + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Changes Applied"), + "message": _( + "Configuration changes are being applied to the Kubernetes instance." + ), + "sticky": False, + "type": "success", + }, + } + + except ApiException as e: + # Return an error notification + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Error"), + "message": _("Failed to apply changes: %s") % str(e), + "sticky": True, + "type": "danger", + }, + } + + def action_view_upgrades(self): + """View upgrades related to this instance""" + self.ensure_one() + + return { + "name": _("Upgrades"), + "type": "ir.actions.act_window", + "res_model": "k8s.odoo.upgrade", + "view_mode": "tree,form", + "domain": [("instance_id", "=", self.id)], + "context": { + "default_instance_id": self.id, + }, + } + + def _check_instance_status(self): + """Check the status of the Odoo instance in Kubernetes and update stored fields""" + self.ensure_one() + + # First compute the current Kubernetes state + self._compute_k8s_state() + + # Then update the stored fields with the computed values + self.write( + { + "status": self.k8s_status, + "status_message": self.k8s_status_message, + "last_status_check": fields.Datetime.now(), + } + ) + + @api.depends("name", "namespace", "cluster_id") + def _compute_k8s_state(self): + """Compute the current state from Kubernetes""" + for record in self: + # Initialize with default/fallback values + k8s_status = record.status + k8s_status_message = record.status_message or "" + k8s_exists = False + k8s_pods_count = 0 + k8s_ready_pods_count = 0 + + # Skip if in draft state or no cluster configured + if record.status == "draft" or not record.cluster_id: + record.k8s_status = k8s_status + record.k8s_status_message = k8s_status_message + record.k8s_exists = k8s_exists + record.k8s_pods_count = k8s_pods_count + record.k8s_ready_pods_count = k8s_ready_pods_count + continue + + try: + # Get the Kubernetes client + api_client = record.cluster_id.get_k8s_client() + custom_api = kubernetes.client.CustomObjectsApi(api_client) + core_api = kubernetes.client.CoreV1Api(api_client) + + # Get the OdooInstance custom resource + try: + instance = custom_api.get_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=record.namespace, + plural="odooinstances", + name=record.name, + ) + k8s_exists = True + except ApiException as e: + if e.status == 404: + k8s_status = "draft" + k8s_status_message = _("Instance not found in Kubernetes") + record.k8s_status = k8s_status + record.k8s_status_message = k8s_status_message + record.k8s_exists = k8s_exists + record.k8s_pods_count = k8s_pods_count + record.k8s_ready_pods_count = k8s_ready_pods_count + continue + else: + raise + + # Check if there's an upgrade job running + upgrade_job = None + try: + # Look for a job with the name pattern 'odoo-upgrade-{instance_name}-*' + job_prefix = f"odoo-upgrade-{record.name}-" + jobs = custom_api.list_namespaced_custom_object( + group="batch", + version="v1", + namespace=record.namespace, + plural="jobs", + label_selector=f"app=odoo-upgrade,instance={record.name}", + ) + + # Get all matching jobs and sort by creation timestamp (newest first) + matching_jobs = [] + for job in jobs.get("items", []): + job_name = job["metadata"]["name"] + if job_name.startswith(job_prefix): + # Extract creation timestamp for sorting + creation_time = job["metadata"].get("creationTimestamp", "") + matching_jobs.append((creation_time, job)) + + # Sort jobs by creation timestamp (newest first) + if matching_jobs: + matching_jobs.sort(key=lambda x: x[0], reverse=True) + # Use the most recent job + upgrade_job = matching_jobs[0][1] + except ApiException: + # Ignore errors when checking for upgrade jobs + pass + + # Check the status of the deployment + deployment_name = f"odoo-{record.name}" + try: + deployment = core_api.read_namespaced_deployment( + name=deployment_name, namespace=record.namespace + ) + available_replicas = deployment.status.available_replicas or 0 + ready_replicas = deployment.status.ready_replicas or 0 + replicas = deployment.status.replicas or 0 + + k8s_pods_count = replicas + k8s_ready_pods_count = ready_replicas + + # If an upgrade job is running, it takes precedence over deployment status + if upgrade_job: + job_status = upgrade_job.get("status", {}) + job_succeeded = job_status.get("succeeded", 0) + job_failed = job_status.get("failed", 0) + + if job_succeeded > 0: + # Upgrade completed successfully + k8s_status = "running" + k8s_status_message = _("Upgrade completed successfully") + elif job_failed > 0: + # Upgrade failed + k8s_status = "error" + k8s_status_message = _("Upgrade failed") + else: + # Upgrade in progress + k8s_status = "upgrading" + k8s_status_message = _("Upgrade in progress") + elif available_replicas == replicas and ready_replicas == replicas: + k8s_status = "running" + k8s_status_message = _("Instance is running") + else: + k8s_status = "pending" + k8s_status_message = _( + f"Instance is starting up. Ready: {ready_replicas}/{replicas}" + ) + except ApiException as e: + if e.status == 404: + k8s_status = "pending" + k8s_status_message = _( + "Deployment not found. Instance is being created." + ) + else: + raise + + except ApiException as e: + k8s_status = "error" + k8s_status_message = f"API Error: {e.reason}" + except Exception as e: + k8s_status = "error" + k8s_status_message = f"Error: {str(e)}" + + record.k8s_status = k8s_status + record.k8s_status_message = k8s_status_message + record.k8s_exists = k8s_exists + record.k8s_pods_count = k8s_pods_count + record.k8s_ready_pods_count = k8s_ready_pods_count + + def action_delete_kubernetes(self): + """Delete the Odoo instance from Kubernetes""" + self.ensure_one() + + if self.status == "draft": + raise UserError(_("This instance has not been deployed to Kubernetes.")) + + try: + # Get the Kubernetes client + api_client = self.cluster_id.get_k8s_client() + custom_api = kubernetes.client.CustomObjectsApi(api_client) + + # Delete the OdooInstance custom resource + custom_api.delete_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=self.namespace, + plural="odooinstances", + name=self.name, + ) + + # Update the status + self.write( + { + "status": "draft", + "status_message": _("Instance deleted from Kubernetes"), + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deletion Successful"), + "message": _("Odoo instance has been deleted from Kubernetes."), + "sticky": False, + "type": "success", + }, + } + + except ApiException as e: + if e.status == 404: + # If the instance doesn't exist, mark it as draft + self.write( + { + "status": "draft", + "status_message": _("Instance not found in Kubernetes"), + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Instance Not Found"), + "message": _( + "The Odoo instance was not found in Kubernetes. It may have been deleted already." + ), + "sticky": False, + "type": "warning", + }, + } + else: + error_message = f"API Error: {e.reason}" + self.write( + { + "status": "error", + "status_message": error_message, + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deletion Failed"), + "message": error_message, + "sticky": False, + "type": "danger", + }, + } + + except Exception as e: + error_message = f"Error: {str(e)}" + self.write( + { + "status": "error", + "status_message": error_message, + "last_status_check": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Deletion Failed"), + "message": error_message, + "sticky": False, + "type": "danger", + }, + } + + def action_check_status(self): + """Check the status of the Odoo instance in Kubernetes""" + self.ensure_one() + + if self.status == "draft": + raise UserError(_("This instance has not been deployed to Kubernetes.")) + + # Compute the current Kubernetes state + self._compute_k8s_state() + + # Update the stored fields with the computed values + self.write( + { + "status": self.k8s_status, + "status_message": self.k8s_status_message, + "last_status_check": fields.Datetime.now(), + } + ) + + # Determine notification type based on status + notification_type = "info" + if self.k8s_status == "running": + notification_type = "success" + elif self.k8s_status == "error": + notification_type = "danger" + elif self.k8s_status == "upgrading": + notification_type = "warning" + + # Prepare a detailed message + message = self.k8s_status_message + if self.k8s_exists: + message += _("\nPods: %s/%s ready") % ( + self.k8s_ready_pods_count, + self.k8s_pods_count, + ) + + # Return a notification to the user + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Kubernetes Status: %s") % self.k8s_status.capitalize(), + "message": message, + "sticky": False, + "type": notification_type, + }, + } + + def action_schedule_upgrade(self): + """Schedule an upgrade for this Odoo instance (redirects to simple upgrade)""" + return self.action_simple_upgrade() + + def action_simple_upgrade(self): + """Trigger a simple upgrade for this Odoo instance""" + self.ensure_one() + + return { + "name": _("Upgrade Odoo Instance"), + "type": "ir.actions.act_window", + "res_model": "k8s.odoo.upgrade.simple.wizard", + "view_mode": "form", + "target": "new", + "context": { + "active_id": self.id, + }, + } + + def action_open_instance(self): + """Open the Odoo instance in a new browser tab""" + self.ensure_one() + + if not self.odoo_url: + raise UserError(_("No URL available for this instance.")) + + return { + "type": "ir.actions.act_url", + "url": self.odoo_url, + "target": "new", + } + + @api.model + def _cron_check_instance_status(self): + """Cron job to check the status of all instances""" + instances = self.search([]) + for instance in instances: + try: + # Compute the current Kubernetes state + instance._compute_k8s_state() + + # For non-draft instances, update the stored status fields + if instance.status != "draft" or instance.k8s_exists: + instance.write( + { + "status": instance.k8s_status, + "status_message": instance.k8s_status_message, + "last_status_check": fields.Datetime.now(), + } + ) + + # Also compute the configuration fields to ensure they're up to date + instance._compute_k8s_config() + + except Exception as e: + _logger.error( + f"Error checking status for instance {instance.name}: {str(e)}" + ) + # Continue with the next instance + continue diff --git a/bemade_k8s_odoo_manager/security/ir.model.access.csv b/bemade_k8s_odoo_manager/security/ir.model.access.csv new file mode 100644 index 0000000..4fcca01 --- /dev/null +++ b/bemade_k8s_odoo_manager/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_k8s_cluster_admin,k8s.cluster admin,model_k8s_cluster,bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin,1,1,1,1 +access_k8s_cluster_user,k8s.cluster user,model_k8s_cluster,bemade_k8s_odoo_manager.group_k8s_odoo_manager_user,1,0,0,0 +access_k8s_odoo_instance_admin,k8s.odoo.instance admin,model_k8s_odoo_instance,bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin,1,1,1,1 +access_k8s_odoo_instance_user,k8s.odoo.instance user,model_k8s_odoo_instance,bemade_k8s_odoo_manager.group_k8s_odoo_manager_user,1,0,0,0 + diff --git a/bemade_k8s_odoo_manager/security/k8s_odoo_manager_security.xml b/bemade_k8s_odoo_manager/security/k8s_odoo_manager_security.xml new file mode 100644 index 0000000..9d41a8a --- /dev/null +++ b/bemade_k8s_odoo_manager/security/k8s_odoo_manager_security.xml @@ -0,0 +1,85 @@ + + + + + + Kubernetes Odoo Manager / User + + + + + + Kubernetes Odoo Manager / Administrator + + + + + + + + Kubernetes Cluster: Administrators can see all clusters + + + [(1, '=', 1)] + + + + + + + + Kubernetes Cluster: Users can only see active clusters + + + [('active', '=', True)] + + + + + + + + Kubernetes Odoo Instance: Administrators can see all instances + + + [(1, '=', 1)] + + + + + + + + Kubernetes Odoo Instance: Users can only see active instances + + + [('active', '=', True)] + + + + + + + + Kubernetes Odoo Upgrade: Administrators can see all upgrades + + + [(1, '=', 1)] + + + + + + + + Kubernetes Odoo Upgrade: Users can only see upgrades for active instances + + + [('instance_id.active', '=', True)] + + + + + + + diff --git a/bemade_k8s_odoo_manager/views/k8s_cluster_views.xml b/bemade_k8s_odoo_manager/views/k8s_cluster_views.xml new file mode 100644 index 0000000..99a4069 --- /dev/null +++ b/bemade_k8s_odoo_manager/views/k8s_cluster_views.xml @@ -0,0 +1,107 @@ + + + + + k8s.cluster.tree + k8s.cluster + + + + + + + + + + + + + k8s.cluster.form + k8s.cluster + + + + + + Test Connection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k8s.cluster.search + k8s.cluster + + + + + + + + + + + + + + + + Kubernetes Clusters + k8s.cluster + tree,form + + + + Create a new Kubernetes cluster + + + Define Kubernetes clusters to connect to for managing Odoo instances. + + + + + + + + + diff --git a/bemade_k8s_odoo_manager/views/k8s_odoo_instance_views.xml b/bemade_k8s_odoo_manager/views/k8s_odoo_instance_views.xml new file mode 100644 index 0000000..f2d4db8 --- /dev/null +++ b/bemade_k8s_odoo_manager/views/k8s_odoo_instance_views.xml @@ -0,0 +1,216 @@ + + + + + k8s.odoo.instance.tree + k8s.odoo.instance + + + + + + + + + + + + + + + + + + + k8s.odoo.instance.form + k8s.odoo.instance + + + + + + + + + + + + + + + + + + + Warning: Unable to read current configuration from Kubernetes. The current configuration values are not available. Please check your connection and click "Refresh Status" to try again. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Note: Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Note: Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance. + + + + + + + + + + + + + + + + Note: Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance. + + + + + + + + + + + + + + Note: Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k8s.odoo.instance.search + k8s.odoo.instance + + + + + + + + + + + + + + + + + + + + + + + + Odoo Instances + k8s.odoo.instance + tree,form + + + + Create a new Odoo instance + + + Create and manage Odoo instances running on Kubernetes. + + + + + + + diff --git a/bemade_k8s_odoo_manager/views/views.xml b/bemade_k8s_odoo_manager/views/views.xml new file mode 100644 index 0000000..6812512 --- /dev/null +++ b/bemade_k8s_odoo_manager/views/views.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bemade_k8s_odoo_manager/wizards/__init__.py b/bemade_k8s_odoo_manager/wizards/__init__.py new file mode 100644 index 0000000..24c0a8e --- /dev/null +++ b/bemade_k8s_odoo_manager/wizards/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import k8s_odoo_instance_update_wizard diff --git a/bemade_k8s_odoo_manager/wizards/k8s_odoo_upgrade_simple_wizard.py b/bemade_k8s_odoo_manager/wizards/k8s_odoo_upgrade_simple_wizard.py new file mode 100644 index 0000000..b3f7732 --- /dev/null +++ b/bemade_k8s_odoo_manager/wizards/k8s_odoo_upgrade_simple_wizard.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- + +import logging +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +from kubernetes.client.rest import ApiException + +_logger = logging.getLogger(__name__) + +class K8sOdooUpgradeSimpleWizard(models.TransientModel): + _name = 'k8s.odoo.upgrade.simple.wizard' + _description = 'Simple Odoo Upgrade Wizard' + + instance_id = fields.Many2one( + 'k8s.odoo.instance', + string='Odoo Instance', + required=True, + readonly=True + ) + + database = fields.Char( + string='Database', + required=True, + help='Database name to upgrade' + ) + + modules = fields.Char( + string='Modules', + required=True, + help='Comma-separated list of modules to upgrade' + ) + + scheduled_time = fields.Datetime( + string='Scheduled Time', + help='When to run the upgrade (leave empty to run immediately)' + ) + + admin_password = fields.Char( + string='Admin Password', + required=True, + help='Admin password for the Odoo instance' + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_id = self.env.context.get('active_id') + if active_id: + instance = self.env['k8s.odoo.instance'].browse(active_id) + res['instance_id'] = instance.id + # Try to get the database name from the instance + if instance.k8s_config_options: + import yaml + try: + config = yaml.safe_load(instance.k8s_config_options) + if config and isinstance(config, dict) and 'db_name' in config: + res['database'] = config['db_name'] + except Exception as e: + _logger.warning(f"Could not parse config options: {e}") + return res + + def action_trigger_upgrade(self): + """Trigger the upgrade in Kubernetes""" + self.ensure_one() + instance = self.instance_id + + try: + # Get the Kubernetes client + api_client = instance.cluster_id.get_k8s_client() + custom_api = instance.cluster_id._get_custom_objects_api() + + # Get the OdooInstance custom resource + try: + current_instance = custom_api.get_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=instance.namespace, + plural="odooinstances", + name=instance.name, + ) + except ApiException as e: + if e.status == 404: + raise UserError( + _( + "The Odoo instance does not exist in Kubernetes." + ) + ) + else: + raise + + # Set the admin password for the upgrade + current_instance["spec"]["adminPassword"] = self.admin_password + + # Prepare the upgrade spec + upgrade_spec = { + "database": self.database, + "modules": [m.strip() for m in self.modules.split(',') if m.strip()] + } + + # Add scheduled time if provided + if self.scheduled_time: + upgrade_spec["time"] = self.scheduled_time.isoformat() + + # Add the upgrade spec to trigger the operator's upgrade process + current_instance["spec"]["upgrade"] = upgrade_spec + + # Update the OdooInstance in Kubernetes + custom_api.patch_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=instance.namespace, + plural="odooinstances", + name=instance.name, + body=current_instance, + ) + + # Schedule a status check + self.env.ref( + "bemade_k8s_odoo_manager.ir_cron_check_instance_status" + ).method_direct_trigger() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Upgrade Triggered"), + "message": _("The upgrade process has been initiated."), + "sticky": False, + "type": "success", + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + + except Exception as e: + error_message = f"Error: {str(e)}" + _logger.error(error_message) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Error"), + "message": error_message, + "sticky": True, + "type": "danger", + }, + }
+ Create a new Kubernetes cluster +
+ Define Kubernetes clusters to connect to for managing Odoo instances. +
+ Warning: Unable to read current configuration from Kubernetes. The current configuration values are not available. Please check your connection and click "Refresh Status" to try again.
+ Note: Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance.
+ Create a new Odoo instance +
+ Create and manage Odoo instances running on Kubernetes. +