520 lines
18 KiB
Python
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)
|