From 8bbfeb49f5473e3aae1a63d48c3054d86c3506f2 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Wed, 1 Oct 2025 22:08:14 -0400 Subject: [PATCH] k8s_odoo_manager: bidirectional sync seems to be working --- k8s_odoo_manager/models/k8s_cluster.py | 55 ++- k8s_odoo_manager/models/k8s_odoo_instance.py | 385 ++++++++++++++++-- .../views/k8s_odoo_instance_views.xml | 151 +++++-- 3 files changed, 518 insertions(+), 73 deletions(-) diff --git a/k8s_odoo_manager/models/k8s_cluster.py b/k8s_odoo_manager/models/k8s_cluster.py index 061c47a..3cd6b45 100644 --- a/k8s_odoo_manager/models/k8s_cluster.py +++ b/k8s_odoo_manager/models/k8s_cluster.py @@ -84,7 +84,7 @@ class K8sCluster(models.Model): verify_ssl = fields.Boolean( string="Verify SSL Certificate", default=True, - help="Disable for clusters with self-signed certificates (development only)" + help="Disable for clusters with self-signed certificates (development only)", ) # Relations @@ -132,15 +132,21 @@ class K8sCluster(models.Model): # Get the configuration and ensure SSL verification is properly set configuration = client.Configuration.get_default_copy() - + # Apply SSL verification setting from cluster configuration configuration.verify_ssl = self.verify_ssl if not self.verify_ssl: - _logger.warning(f"SSL verification disabled for cluster {self.name} - use only for development!") + _logger.warning( + f"SSL verification disabled for cluster {self.name} - use only for development!" + ) # If we have certificate-authority-data in kubeconfig, it should be used # The kubernetes client should handle this automatically, but let's ensure it's set - if self.verify_ssl and configuration.ssl_ca_cert is None and "clusters" in kubeconfig_dict: + if ( + self.verify_ssl + and configuration.ssl_ca_cert is None + and "clusters" in kubeconfig_dict + ): for cluster_info in kubeconfig_dict["clusters"]: if "certificate-authority-data" in cluster_info.get( "cluster", {} @@ -239,22 +245,47 @@ class K8sCluster(models.Model): for item in instances.get("items", []): metadata = item.get("metadata", {}) spec = item.get("spec", {}) - status = item.get("status", {}) + name = metadata.get("name") + namespace = metadata.get("namespace") + + # Fetch status separately using the status subresource + status = {} + try: + status_obj = custom_api.get_namespaced_custom_object_status( + group="bemade.org", + version="v1", + namespace=namespace, + plural="odooinstances", + name=name, + ) + status = ( + status_obj.get("status", {}) + if isinstance(status_obj, dict) + else {} + ) + _logger.info( + f"Fetched status for {name}: {list(status.keys()) if isinstance(status, dict) else 'not dict'}" + ) + except Exception as e: + _logger.warning(f"Could not fetch status for {name}: {e}") + # Fall back to status from list (might be empty) + status = item.get("status", {}) # Find or create the instance record instance = self.env["k8s.odoo.instance"].search( [ ("cluster_id", "=", self.id), - ("name", "=", metadata.get("name")), - ("namespace", "=", metadata.get("namespace")), + ("name", "=", name), + ("namespace", "=", namespace), ], limit=1, ) - values = { + # Prepare instance data + instance_data = { "cluster_id": self.id, - "name": metadata.get("name"), - "namespace": metadata.get("namespace"), + "name": name, + "namespace": namespace, "spec": json.dumps(spec, indent=2), "status": json.dumps(status, indent=2), "phase": status.get("phase", "Unknown"), @@ -263,9 +294,9 @@ class K8sCluster(models.Model): } if instance: - instance.write(values) + instance.write(instance_data) else: - self.env["k8s.odoo.instance"].create(values) + self.env["k8s.odoo.instance"].create(instance_data) synced_count += 1 diff --git a/k8s_odoo_manager/models/k8s_odoo_instance.py b/k8s_odoo_manager/models/k8s_odoo_instance.py index e6d9dc7..da696f0 100644 --- a/k8s_odoo_manager/models/k8s_odoo_instance.py +++ b/k8s_odoo_manager/models/k8s_odoo_instance.py @@ -2,6 +2,7 @@ import json import logging from odoo import api, fields, models, _ from odoo.exceptions import UserError +from kubernetes import client _logger = logging.getLogger(__name__) @@ -67,45 +68,218 @@ class K8sOdooInstance(models.Model): help="Last time this record was synchronized from Kubernetes", ) - # Computed fields from spec - image = fields.Char( - string="Docker Image", compute="_compute_spec_fields", store=True + # Real-time computed fields from cluster (always fresh) + current_image = fields.Char( + string="Current Image", compute="_compute_current_values" + ) + current_replicas = fields.Integer( + string="Current Replicas", compute="_compute_current_values" + ) + current_ingress_hosts = fields.Text( + string="Current Ingress Hosts", compute="_compute_current_values" + ) + current_cpu_request = fields.Char( + string="Current CPU Request", compute="_compute_current_values" + ) + current_cpu_limit = fields.Char( + string="Current CPU Limit", compute="_compute_current_values" + ) + current_memory_request = fields.Char( + string="Current Memory Request", compute="_compute_current_values" + ) + current_memory_limit = fields.Char( + string="Current Memory Limit", compute="_compute_current_values" + ) + current_ingress_enabled = fields.Boolean( + string="Current Ingress Enabled", compute="_compute_current_values" ) - replicas = fields.Integer( - string="Replicas", compute="_compute_spec_fields", store=True + # Editable spec fields (will sync back to cluster) + image_editable = fields.Char( + string="Docker Image", help="Docker image for the Odoo instance" ) - ingress_hosts = fields.Text( - string="Ingress Hosts", compute="_compute_spec_fields", store=True + replicas_editable = fields.Integer( + string="Replicas", default=1, help="Number of replicas to run" ) - @api.depends("spec") - def _compute_spec_fields(self): - """Extract commonly used fields from spec JSON""" + # Resource fields + cpu_request = fields.Char( + string="CPU Request", help="CPU request (e.g., '100m', '0.5')" + ) + + cpu_limit = fields.Char(string="CPU Limit", help="CPU limit (e.g., '1000m', '2')") + + memory_request = fields.Char( + string="Memory Request", help="Memory request (e.g., '512Mi', '1Gi')" + ) + + memory_limit = fields.Char( + string="Memory Limit", help="Memory limit (e.g., '1Gi', '2Gi')" + ) + + # Ingress fields + ingress_enabled = fields.Boolean(string="Enable Ingress", default=True) + + ingress_hosts_editable = fields.Text( + string="Ingress Hosts", help="One host per line" + ) + + # Filestore fields + filestore_enabled = fields.Boolean(string="Enable Filestore", default=True) + + filestore_size = fields.Char( + string="Filestore Size", + default="10Gi", + help="Size of the filestore PVC (e.g., '10Gi', '50Gi')", + ) + + # Status fields + ready_replicas = fields.Integer( + string="Ready Replicas", compute="_compute_status_fields", store=True + ) + + # Computed fields to detect changes + has_pending_changes = fields.Boolean( + string="Has Pending Changes", compute="_compute_pending_changes" + ) + + image_changed = fields.Boolean( + string="Image Changed", compute="_compute_pending_changes" + ) + + replicas_changed = fields.Boolean( + string="Replicas Changed", compute="_compute_pending_changes" + ) + + available_replicas = fields.Integer( + string="Available Replicas", compute="_compute_status_fields", store=True + ) + + ingress_url = fields.Char( + string="Ingress URL", compute="_compute_status_fields", store=True + ) + + conditions = fields.Text( + string="Conditions", compute="_compute_status_fields", store=True + ) + + def _compute_current_values(self): + """Fetch current values from cluster in real-time""" for instance in self: - if instance.spec: - try: - spec_data = json.loads(instance.spec) - instance.image = spec_data.get("image", "") - instance.replicas = spec_data.get("replicas", 0) + # Initialize with empty values + instance.current_image = "" + instance.current_replicas = 0 + instance.current_ingress_hosts = "" + instance.current_cpu_request = "" + instance.current_cpu_limit = "" + instance.current_memory_request = "" + instance.current_memory_limit = "" + instance.current_ingress_enabled = False - # Extract ingress hosts - ingress = spec_data.get("ingress", {}) - hosts = ingress.get("hosts", []) - instance.ingress_hosts = ", ".join(hosts) if hosts else "" + if not instance.cluster_id or not instance.cluster_id.active: + continue + + try: + k8s_client = instance.cluster_id._get_k8s_client() + custom_api = client.CustomObjectsApi(k8s_client) + + # Fetch the current object from cluster + obj = custom_api.get_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=instance.namespace, + plural="odooinstances", + name=instance.name, + ) + + # Extract spec values + spec = obj.get("spec", {}) + instance.current_image = spec.get("image", "") + instance.current_replicas = spec.get("replicas", 0) + + # Extract ingress + ingress = spec.get("ingress", {}) + instance.current_ingress_enabled = ingress.get("enabled", False) + hosts = ingress.get("hosts", []) + instance.current_ingress_hosts = ", ".join(hosts) if hosts else "" + + # Extract resources + resources = spec.get("resources", {}) + requests = resources.get("requests", {}) + limits = resources.get("limits", {}) + instance.current_cpu_request = requests.get("cpu", "") + instance.current_memory_request = requests.get("memory", "") + instance.current_cpu_limit = limits.get("cpu", "") + instance.current_memory_limit = limits.get("memory", "") + + except Exception as e: + _logger.warning( + f"Could not fetch current values for {instance.name}: {e}" + ) + # Values remain empty as initialized above + + @api.depends("status") + def _compute_status_fields(self): + """Extract status information from status JSON""" + for instance in self: + if instance.status: + try: + status_data = json.loads(instance.status) + + # Extract replica counts + instance.ready_replicas = status_data.get("readyReplicas", 0) + instance.available_replicas = status_data.get( + "availableReplicas", 0 + ) + + # Extract ingress URL + ingress_status = status_data.get("ingress", {}) + urls = ingress_status.get("urls", []) + instance.ingress_url = urls[0] if urls else "" + + # Extract conditions + conditions = status_data.get("conditions", []) + condition_strings = [] + for condition in conditions: + ctype = condition.get("type", "") + status = condition.get("status", "") + reason = condition.get("reason", "") + condition_strings.append(f"{ctype}: {status} ({reason})") + instance.conditions = "\n".join(condition_strings) except (json.JSONDecodeError, KeyError) as e: _logger.warning( - f"Failed to parse spec for instance {instance.name}: {e}" + f"Failed to parse status for instance {instance.name}: {e}" ) - instance.image = "" - instance.replicas = 0 - instance.ingress_hosts = "" + instance.ready_replicas = 0 + instance.available_replicas = 0 + instance.ingress_url = "" + instance.conditions = "" else: - instance.image = "" - instance.replicas = 0 - instance.ingress_hosts = "" + instance.ready_replicas = 0 + instance.available_replicas = 0 + instance.ingress_url = "" + instance.conditions = "" + + @api.depends( + "image_editable", "current_image", "replicas_editable", "current_replicas" + ) + def _compute_pending_changes(self): + """Compute if there are pending changes to sync""" + for instance in self: + image_changed = bool( + instance.image_editable + and instance.image_editable != instance.current_image + ) + replicas_changed = bool( + instance.replicas_editable + and instance.replicas_editable != instance.current_replicas + ) + + instance.image_changed = image_changed + instance.replicas_changed = replicas_changed + instance.has_pending_changes = image_changed or replicas_changed def name_get(self): """Custom name display""" @@ -179,17 +353,168 @@ class K8sOdooInstance(models.Model): }, } - def action_refresh(self): - """Refresh this instance from Kubernetes""" + def action_reset_to_current(self): + """Reset editable fields to current cluster values""" self.ensure_one() - return self.cluster_id.sync_odoo_instances() + + # Copy current values to editable fields (current values are computed fresh) + self.write( + { + "image_editable": self.current_image, + "replicas_editable": self.current_replicas, + "cpu_request": self.current_cpu_request, + "cpu_limit": self.current_cpu_limit, + "memory_request": self.current_memory_request, + "memory_limit": self.current_memory_limit, + "ingress_enabled": self.current_ingress_enabled, + "ingress_hosts_editable": self.current_ingress_hosts, + } + ) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Reset Complete"), + "message": _("All fields reset to current cluster values"), + "type": "success", + }, + } + + def action_sync_to_cluster(self): + """Public method to sync changes to cluster""" + self.ensure_one() + try: + self._patch_to_cluster() + + # Show success notification and refresh form + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Sync Successful"), + "message": _("Successfully synced %s to cluster") % self.name, + "type": "success", + "sticky": False, + "next": { + "type": "ir.actions.act_window", + "res_model": "k8s.odoo.instance", + "res_id": self.id, + "view_mode": "form", + "views": [(False, "form")], + "target": "current", + }, + }, + } + except Exception as e: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Sync Failed"), + "message": _("Failed to sync %s: %s") % (self.name, str(e)), + "type": "danger", + }, + } + + def _patch_to_cluster(self): + """Patch this instance's changes back to the cluster""" + self.ensure_one() + + if not self.cluster_id.active: + _logger.warning( + f"Cluster {self.cluster_id.name} is not active, skipping sync" + ) + return + + try: + k8s_client = self.cluster_id._get_k8s_client() + custom_api = client.CustomObjectsApi(k8s_client) + + # Build the patch data from editable fields + patch_data = self._build_patch_data() + + if not patch_data: + _logger.info(f"No changes to sync for {self.name}") + return + + _logger.info(f"Patching {self.name} with: {patch_data}") + + # Apply the patch + custom_api.patch_namespaced_custom_object( + group="bemade.org", + version="v1", + namespace=self.namespace, + plural="odooinstances", + name=self.name, + body=patch_data, + ) + + _logger.info(f"Successfully patched {self.name} to cluster") + + except Exception as e: + error_msg = f"Failed to patch {self.name} to cluster: {e}" + _logger.error(error_msg) + raise UserError(_(error_msg)) + + def _build_patch_data(self): + """Build patch data from editable fields""" + self.ensure_one() + + patch = {"spec": {}} + + # Image + if self.image_editable: + patch["spec"]["image"] = self.image_editable + + # Replicas + if self.replicas_editable: + patch["spec"]["replicas"] = self.replicas_editable + + # Resources + resources = {} + if ( + self.cpu_request + or self.cpu_limit + or self.memory_request + or self.memory_limit + ): + if self.cpu_request or self.memory_request: + resources["requests"] = {} + if self.cpu_request: + resources["requests"]["cpu"] = self.cpu_request + if self.memory_request: + resources["requests"]["memory"] = self.memory_request + + if self.cpu_limit or self.memory_limit: + resources["limits"] = {} + if self.cpu_limit: + resources["limits"]["cpu"] = self.cpu_limit + if self.memory_limit: + resources["limits"]["memory"] = self.memory_limit + + if resources: + patch["spec"]["resources"] = resources + + # Ingress + if hasattr(self, "ingress_enabled"): # Check if field exists + ingress = {"enabled": self.ingress_enabled} + if self.ingress_hosts_editable: + # Parse hosts (support both comma and newline separated) + hosts = self.ingress_hosts_editable.replace("\n", ",").split(",") + hosts = [h.strip() for h in hosts if h.strip()] + if hosts: + ingress["hosts"] = hosts + patch["spec"]["ingress"] = ingress + + # Return None if no actual changes + return patch if patch["spec"] else None class K8sSpecViewer(models.TransientModel): """Transient model for displaying JSON content in a dialog""" _name = "k8s.spec.viewer" - _description = "Kubernetes Spec/Status Viewer" + _description = "Kubernetes Spec Viewer" title = fields.Char(string="Title", readonly=True) content = fields.Text(string="Content", readonly=True) diff --git a/k8s_odoo_manager/views/k8s_odoo_instance_views.xml b/k8s_odoo_manager/views/k8s_odoo_instance_views.xml index 6e8175a..e244323 100644 --- a/k8s_odoo_manager/views/k8s_odoo_instance_views.xml +++ b/k8s_odoo_manager/views/k8s_odoo_instance_views.xml @@ -5,28 +5,28 @@ k8s.odoo.instance.list k8s.odoo.instance - + + - - - + + + - + - - k8s.odoo.instance.form k8s.odoo.instance -
+
@@ -40,33 +40,123 @@ - - - - - + + + + + - - + + + - -
-
-
-
- + + + + + + + + + + + + + + + + + + + + + + + + Current: + + + + + + + + + - -
-
+ + + + + + + + + + +
-
- + +
+
@@ -83,7 +173,7 @@ - + @@ -94,7 +184,6 @@ -