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