k8s_odoo_manager: bidirectional sync seems to be working

This commit is contained in:
Marc Durepos 2025-10-01 22:08:14 -04:00
parent 9019d8f280
commit 8bbfeb49f5
3 changed files with 518 additions and 73 deletions

View file

@ -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

View file

@ -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)

View file

@ -5,28 +5,28 @@
<field name="name">k8s.odoo.instance.list</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<list string="Odoo Instances" decoration-success="phase == 'Running'" decoration-warning="phase == 'Upgrading'" decoration-danger="phase == 'Restoring'">
<list string="Odoo Instances" decoration-muted="phase == 'Pending'" decoration-success="phase == 'Running'" decoration-danger="phase == 'Failed'">
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="name"/>
<field name="image"/>
<field name="replicas"/>
<field name="current_image"/>
<field name="current_replicas"/>
<field name="ready_replicas"/>
<field name="phase" widget="badge"/>
<field name="url" widget="url"/>
<field name="ingress_url" widget="url"/>
<field name="last_updated"/>
</list>
</field>
</record>
<!-- Instance Form View -->
<record id="view_k8s_odoo_instance_form" model="ir.ui.view">
<field name="name">k8s.odoo.instance.form</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<form string="Odoo Instance" create="false" edit="false">
<form string="Odoo Instance" create="false">
<header>
<button name="action_open_url" string="Open Instance" type="object" class="btn-primary" invisible="not url"/>
<button name="action_refresh" string="Refresh" type="object" class="btn-secondary"/>
<button name="action_reset_to_current" string="Reset to Current" type="object" class="btn-secondary" confirm="This will reset all your changes to current cluster values. Continue?"/>
<button name="action_sync_to_cluster" string="Sync to Cluster" type="object" class="btn-warning" confirm="This will apply your changes to the Kubernetes cluster. Continue?"/>
<field name="phase" widget="statusbar"/>
</header>
<sheet>
@ -40,33 +40,123 @@
</div>
<group>
<group string="Basic Information">
<field name="image"/>
<field name="replicas"/>
<field name="url" widget="url"/>
<field name="last_updated"/>
<group>
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="phase" widget="badge"/>
</group>
<group string="Network">
<field name="ingress_hosts"/>
<group>
<field name="ingress_url" widget="url"/>
<field name="last_updated"/>
</group>
</group>
<notebook>
<page string="Specification">
<div class="row">
<div class="col-12">
<button name="action_view_spec" string="View Full Spec" type="object" class="btn btn-link"/>
</div>
</div>
<field name="spec" widget="text" readonly="1"/>
<page string="Configuration">
<group>
<group string="Application">
<label for="image_editable" string="Docker Image"/>
<div class="o_row">
<field name="image_editable" placeholder="e.g., odoo:17.0" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_image" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
<label for="replicas_editable" string="Replicas"/>
<div class="o_row">
<field name="replicas_editable" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_replicas" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
</group>
<group string="Status">
<field name="ready_replicas" readonly="1"/>
<field name="available_replicas" readonly="1"/>
</group>
</group>
<group>
<group string="Resource Requests">
<label for="cpu_request" string="CPU Request"/>
<div class="o_row">
<field name="cpu_request" placeholder="e.g., 100m" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_cpu_request" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
<label for="memory_request" string="Memory Request"/>
<div class="o_row">
<field name="memory_request" placeholder="e.g., 512Mi" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_memory_request" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
</group>
<group string="Resource Limits">
<label for="cpu_limit" string="CPU Limit"/>
<div class="o_row">
<field name="cpu_limit" placeholder="e.g., 1000m" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_cpu_limit" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
<label for="memory_limit" string="Memory Limit"/>
<div class="o_row">
<field name="memory_limit" placeholder="e.g., 1Gi" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_memory_limit" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
</group>
</group>
<group>
<group string="Ingress Configuration">
<field name="ingress_enabled"/>
<span class="text-muted">
Current: <field name="current_ingress_enabled" readonly="1" nolabel="1" class="oe_inline"/>
</span>
<label for="ingress_hosts_editable" string="Ingress Hosts"/>
<div class="o_row">
<field name="ingress_hosts_editable" placeholder="host1.example.com, host2.example.com" class="oe_inline" style="width: 60%;"/>
<span class="text-muted" style="margin-left: 10px;">
Current: <field name="current_ingress_hosts" readonly="1" nolabel="1" class="oe_inline"/>
</span>
</div>
</group>
<group string="Storage (Read-Only)">
<field name="filestore_enabled" readonly="1"/>
<field name="filestore_size" readonly="1"/>
</group>
</group>
</page>
<page string="Status">
<div class="row">
<div class="col-12">
<page string="Status Details">
<group>
<field name="conditions" widget="text" readonly="1"/>
</group>
</page>
<page string="Raw Data">
<group>
<group string="Specification">
<button name="action_view_spec" string="View Full Spec" type="object" class="btn btn-link"/>
<field name="spec" widget="text" readonly="1"/>
</group>
<group string="Status">
<button name="action_view_status" string="View Full Status" type="object" class="btn btn-link"/>
</div>
</div>
<field name="status" widget="text" readonly="1"/>
<field name="status" widget="text" readonly="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
@ -83,7 +173,7 @@
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="image"/>
<field name="current_image"/>
<field name="url"/>
<separator/>
<filter string="Running" name="running" domain="[('phase', '=', 'Running')]"/>
@ -94,7 +184,6 @@
<filter string="Cluster" name="group_cluster" domain="[]" context="{'group_by': 'cluster_id'}"/>
<filter string="Namespace" name="group_namespace" domain="[]" context="{'group_by': 'namespace'}"/>
<filter string="Phase" name="group_phase" domain="[]" context="{'group_by': 'phase'}"/>
<filter string="Image" name="group_image" domain="[]" context="{'group_by': 'image'}"/>
</group>
</search>
</field>