bemade-addons/k8s_odoo_manager/models/k8s_odoo_instance.py

520 lines
18 KiB
Python

import json
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from kubernetes import client
_logger = logging.getLogger(__name__)
class K8sOdooInstance(models.Model):
_name = "k8s.odoo.instance"
_description = "Kubernetes Odoo Instance"
_inherit = ["mail.thread"]
_order = "cluster_id, namespace, name"
name = fields.Char(
string="Instance Name",
required=True,
readonly=True,
help="Name of the OdooInstance resource in Kubernetes",
)
cluster_id = fields.Many2one(
"k8s.cluster",
string="Cluster",
required=True,
readonly=True,
ondelete="cascade",
)
namespace = fields.Char(
string="Namespace",
required=True,
readonly=True,
help="Kubernetes namespace containing this instance",
)
spec = fields.Text(
string="Specification",
readonly=True,
help="Complete OdooInstance specification as JSON",
)
status = fields.Text(
string="Status", readonly=True, help="Current OdooInstance status as JSON"
)
phase = fields.Selection(
[
("Running", "Running"),
("Upgrading", "Upgrading"),
("Restoring", "Restoring"),
("Unknown", "Unknown"),
],
string="Phase",
default="Unknown",
readonly=True,
tracking=True,
)
url = fields.Char(
string="URL", readonly=True, help="Access URL for this Odoo instance"
)
last_updated = fields.Datetime(
string="Last Updated",
readonly=True,
help="Last time this record was synchronized from Kubernetes",
)
# 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"
)
# Editable spec fields (will sync back to cluster)
image_editable = fields.Char(
string="Docker Image", help="Docker image for the Odoo instance"
)
replicas_editable = fields.Integer(
string="Replicas", default=1, help="Number of replicas to run"
)
# 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:
# 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
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 status for instance {instance.name}: {e}"
)
instance.ready_replicas = 0
instance.available_replicas = 0
instance.ingress_url = ""
instance.conditions = ""
else:
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"""
result = []
for instance in self:
name = f"{instance.cluster_id.name}/{instance.namespace}/{instance.name}"
result.append((instance.id, name))
return result
def action_open_url(self):
"""Open the Odoo instance URL in a new tab"""
self.ensure_one()
if not self.url:
raise UserError(_("No URL available for this instance"))
return {
"type": "ir.actions.act_url",
"url": self.url,
"target": "new",
}
def action_view_spec(self):
"""Show the complete specification in a dialog"""
self.ensure_one()
if not self.spec:
raise UserError(_("No specification data available"))
try:
# Pretty format the JSON
spec_data = json.loads(self.spec)
formatted_spec = json.dumps(spec_data, indent=2)
except json.JSONDecodeError:
formatted_spec = self.spec
return {
"name": _("Instance Specification"),
"type": "ir.actions.act_window",
"res_model": "k8s.spec.viewer",
"view_mode": "form",
"target": "new",
"context": {
"default_title": f"Specification: {self.name}",
"default_content": formatted_spec,
},
}
def action_view_status(self):
"""Show the complete status in a dialog"""
self.ensure_one()
if not self.status:
raise UserError(_("No status data available"))
try:
# Pretty format the JSON
status_data = json.loads(self.status)
formatted_status = json.dumps(status_data, indent=2)
except json.JSONDecodeError:
formatted_status = self.status
return {
"name": _("Instance Status"),
"type": "ir.actions.act_window",
"res_model": "k8s.spec.viewer",
"view_mode": "form",
"target": "new",
"context": {
"default_title": f"Status: {self.name}",
"default_content": formatted_status,
},
}
def action_reset_to_current(self):
"""Reset editable fields to current cluster values"""
self.ensure_one()
# 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 Viewer"
title = fields.Char(string="Title", readonly=True)
content = fields.Text(string="Content", readonly=True)