Compare commits

...

2 commits

Author SHA1 Message Date
Marc Durepos
8bbfeb49f5 k8s_odoo_manager: bidirectional sync seems to be working 2025-10-01 22:08:14 -04:00
Marc Durepos
9019d8f280 k8s_odoo_manager: first little MVP
- Adds the k8s.cluster and k8s.odoo.instance models
- Successful connection test (ignoring SSL self-signed cert error)
- Successfully pulls OdooInstance information from the cluster

Further work roadmap:

- Fix the SSL issue (cluster-side, most likely)
- Get rid of the text fields containing straight JSON and convert to
  appropriately typed Odoo fields (on read).
- Allow updating OdooInstance fields and writing patches back to the
  cluster. Failed patch should result in a failed write and reverting
  back to actual cluster status.
2025-10-01 20:32:46 -04:00
21 changed files with 2342 additions and 0 deletions

View file

@ -0,0 +1,2 @@
from . import models
from . import wizards

View file

@ -0,0 +1,63 @@
{
'name': 'Kubernetes Odoo Manager',
'version': '18.0.1.0.0',
'category': 'Administration/Kubernetes',
'summary': 'Manage Odoo instances through Kubernetes operator',
'description': """
Kubernetes Odoo Manager
=======================
This module allows you to:
* Connect to Kubernetes clusters running the Odoo operator
* Manage OdooInstance custom resources
* Monitor instance status and health
* Perform operations on managed Odoo instances
Features:
* Cluster connection management with secure kubeconfig storage
* Real-time OdooInstance discovery and synchronization
* Status monitoring and alerting
* Centralized management interface for multiple clusters
""",
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': [
'base',
'web',
'mail',
],
'data': [
# Security
'security/k8s_security.xml',
'security/ir.model.access.csv',
# Data
'data/k8s_data.xml',
# Views
'views/k8s_cluster_views.xml',
'views/k8s_odoo_instance_views.xml',
'views/k8s_menu_views.xml',
# Wizards
'wizards/k8s_cluster_test_wizard_views.xml',
'wizards/k8s_sync_instances_wizard_views.xml',
],
'demo': [],
'installable': True,
'application': True,
'auto_install': False,
'external_dependencies': {
'python': [
'kubernetes',
'pyyaml',
],
},
'assets': {
'web.assets_backend': [
'k8s_odoo_manager/static/src/css/k8s_manager.css',
'k8s_odoo_manager/static/src/js/k8s_dashboard.js',
'k8s_odoo_manager/static/src/xml/k8s_dashboard.xml',
],
},
}

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Default data for the module -->
<!-- Cron job for periodic sync -->
<record id="cron_k8s_sync_instances" model="ir.cron">
<field name="name">Sync Kubernetes Odoo Instances</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="state">code</field>
<field name="code">model.cron_sync_all_clusters()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active" eval="False"/>
<field name="user_id" ref="base.user_root"/>
</record>
<!-- Server actions -->
<record id="action_server_sync_all_clusters" model="ir.actions.server">
<field name="name">Sync All Clusters</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="binding_model_id" ref="model_k8s_cluster"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
# Sync selected clusters
for record in records.filtered('active'):
try:
record.sync_odoo_instances()
except Exception as e:
# Continue with other clusters even if one fails
pass
</field>
</record>
<record id="action_server_test_cluster_connection" model="ir.actions.server">
<field name="name">Test Connection</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="binding_model_id" ref="model_k8s_cluster"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
# Test connection for selected clusters
for record in records:
record.test_connection()
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import k8s_cluster
from . import k8s_odoo_instance

View file

@ -0,0 +1,373 @@
import base64
import json
import logging
import yaml
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from kubernetes import client, config
from kubernetes.client.rest import ApiException
_logger = logging.getLogger(__name__)
class K8sCluster(models.Model):
_name = "k8s.cluster"
_description = "Kubernetes Cluster"
_inherit = ["mail.thread", "mail.activity.mixin"]
_order = "name"
name = fields.Char(
string="Cluster Name",
required=True,
tracking=True,
help="Display name for this Kubernetes cluster",
)
api_endpoint = fields.Char(
string="API Endpoint",
required=True,
help="Kubernetes API server URL (e.g., https://k8s.example.com:6443)",
)
kubeconfig = fields.Text(
string="Kubeconfig",
required=True,
help="Complete kubeconfig YAML content for cluster access",
)
default_namespace = fields.Char(
string="Default Namespace",
default="default",
required=True,
help="Default namespace to search for OdooInstances",
)
active = fields.Boolean(
string="Active",
default=True,
tracking=True,
help="Enable/disable this cluster connection",
)
last_sync = fields.Datetime(
string="Last Sync",
readonly=True,
help="Timestamp of last successful synchronization",
)
connection_status = fields.Selection(
[
("unknown", "Unknown"),
("connected", "Connected"),
("error", "Connection Error"),
],
string="Connection Status",
default="unknown",
readonly=True,
tracking=True,
)
connection_error = fields.Text(
string="Connection Error", readonly=True, help="Last connection error message"
)
# Statistics
total_instances = fields.Integer(
string="Total Instances", compute="_compute_instance_stats", store=True
)
running_instances = fields.Integer(
string="Running Instances", compute="_compute_instance_stats", store=True
)
# SSL Configuration
verify_ssl = fields.Boolean(
string="Verify SSL Certificate",
default=True,
help="Disable for clusters with self-signed certificates (development only)",
)
# Relations
instance_ids = fields.One2many(
"k8s.odoo.instance", "cluster_id", string="Odoo Instances"
)
@api.depends("instance_ids.phase")
def _compute_instance_stats(self):
for cluster in self:
cluster.total_instances = len(cluster.instance_ids)
cluster.running_instances = len(
cluster.instance_ids.filtered(lambda i: i.phase == "Running")
)
@api.constrains("kubeconfig")
def _check_kubeconfig(self):
"""Validate kubeconfig format"""
for record in self:
if record.kubeconfig:
try:
yaml.safe_load(record.kubeconfig)
except yaml.YAMLError as e:
raise ValidationError(_("Invalid kubeconfig format: %s") % str(e))
def _get_k8s_client(self):
"""Get authenticated Kubernetes client for this cluster"""
try:
# Create a temporary kubeconfig file in memory
kubeconfig_dict = yaml.safe_load(self.kubeconfig)
# Create a temporary file for the kubeconfig to ensure proper SSL handling
import tempfile
import os
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as temp_file:
yaml.dump(kubeconfig_dict, temp_file)
temp_kubeconfig_path = temp_file.name
try:
# Load configuration from temporary file (better SSL handling)
config.load_kube_config(config_file=temp_kubeconfig_path)
# 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!"
)
# 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
):
for cluster_info in kubeconfig_dict["clusters"]:
if "certificate-authority-data" in cluster_info.get(
"cluster", {}
):
# The CA cert should be handled by load_kube_config, but if not,
# we could decode and set it manually here
pass
k8s_client = client.ApiClient(configuration)
return k8s_client
finally:
# Clean up temporary file
os.unlink(temp_kubeconfig_path)
except Exception as e:
_logger.error(f"Failed to create K8s client for cluster {self.name}: {e}")
raise UserError(_("Failed to connect to cluster: %s") % str(e))
def test_connection(self):
"""Test connection to Kubernetes cluster"""
self.ensure_one()
try:
# Get client and test basic connectivity
k8s_client = self._get_k8s_client()
v1 = client.CoreV1Api(k8s_client)
# Try to list namespaces as a connectivity test
namespaces = v1.list_namespace()
# Update connection status
self.write(
{
"connection_status": "connected",
"connection_error": False,
}
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Connection Successful"),
"message": _(
"Successfully connected to cluster %s. Found %d namespaces."
)
% (self.name, len(namespaces.items)),
"type": "success",
},
}
except Exception as e:
error_msg = str(e)
_logger.error(
f"Connection test failed for cluster {self.name}: {error_msg}"
)
self.write(
{
"connection_status": "error",
"connection_error": error_msg,
}
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Connection Failed"),
"message": _("Failed to connect to cluster %s: %s")
% (self.name, error_msg),
"type": "danger",
},
}
def sync_odoo_instances(self):
"""Synchronize OdooInstances from this cluster"""
self.ensure_one()
if not self.active:
raise UserError(_("Cluster is not active"))
try:
k8s_client = self._get_k8s_client()
custom_api = client.CustomObjectsApi(k8s_client)
# Get all OdooInstances from the cluster
instances = custom_api.list_cluster_custom_object(
group="bemade.org", # pyright: ignore
version="v1",
plural="odooinstances",
)
synced_count = 0
for item in instances.get("items", []):
metadata = item.get("metadata", {})
spec = item.get("spec", {})
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", "=", name),
("namespace", "=", namespace),
],
limit=1,
)
# Prepare instance data
instance_data = {
"cluster_id": self.id,
"name": name,
"namespace": namespace,
"spec": json.dumps(spec, indent=2),
"status": json.dumps(status, indent=2),
"phase": status.get("phase", "Unknown"),
"url": status.get("url", ""),
"last_updated": fields.Datetime.now(),
}
if instance:
instance.write(instance_data)
else:
self.env["k8s.odoo.instance"].create(instance_data)
synced_count += 1
# Update last sync time
self.write(
{
"last_sync": fields.Datetime.now(),
"connection_status": "connected",
"connection_error": False,
}
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Sync Successful"),
"message": _("Synchronized %d OdooInstances from cluster %s")
% (synced_count, self.name),
"type": "success",
},
}
except Exception as e:
error_msg = str(e)
_logger.error(f"Sync failed for cluster {self.name}: {error_msg}")
self.write(
{
"connection_status": "error",
"connection_error": error_msg,
}
)
raise UserError(_("Sync failed: %s") % error_msg)
def action_sync_instances(self):
"""Action to sync instances from UI"""
return self.sync_odoo_instances()
def action_test_connection(self):
"""Action to test connection from UI"""
return self.test_connection()
def action_view_instances(self):
"""Action to view instances for this cluster"""
self.ensure_one()
return {
"name": _("Odoo Instances"),
"type": "ir.actions.act_window",
"res_model": "k8s.odoo.instance",
"view_mode": "list,form",
"domain": [("cluster_id", "=", self.id)],
"context": {"default_cluster_id": self.id},
}
@api.model
def cron_sync_all_clusters(self):
"""Cron method to sync all active clusters"""
clusters = self.search([("active", "=", True)])
for cluster in clusters:
try:
cluster.sync_odoo_instances()
_logger.info(f"Successfully synced cluster: {cluster.name}")
except Exception as e:
# Log error but continue with other clusters
_logger.error(f"Failed to sync cluster {cluster.name}: {e}")
# Update connection status to error
cluster.write(
{
"connection_status": "error",
"connection_error": str(e),
}
)

View file

@ -0,0 +1,520 @@
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)

View file

@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_k8s_cluster_user,k8s.cluster user,model_k8s_cluster,group_k8s_user,1,0,0,0
access_k8s_cluster_manager,k8s.cluster manager,model_k8s_cluster,group_k8s_manager,1,1,1,1
access_k8s_odoo_instance_user,k8s.odoo.instance user,model_k8s_odoo_instance,group_k8s_user,1,0,0,0
access_k8s_odoo_instance_manager,k8s.odoo.instance manager,model_k8s_odoo_instance,group_k8s_manager,1,1,1,1
access_k8s_spec_viewer_user,k8s.spec.viewer user,model_k8s_spec_viewer,group_k8s_user,1,1,1,1
access_k8s_spec_viewer_manager,k8s.spec.viewer manager,model_k8s_spec_viewer,group_k8s_manager,1,1,1,1
access_k8s_cluster_test_wizard_user,k8s.cluster.test.wizard user,model_k8s_cluster_test_wizard,group_k8s_user,1,1,1,1
access_k8s_cluster_test_wizard_manager,k8s.cluster.test.wizard manager,model_k8s_cluster_test_wizard,group_k8s_manager,1,1,1,1
access_k8s_sync_instances_wizard_user,k8s.sync.instances.wizard user,model_k8s_sync_instances_wizard,group_k8s_user,1,1,1,1
access_k8s_sync_instances_wizard_manager,k8s.sync.instances.wizard manager,model_k8s_sync_instances_wizard,group_k8s_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_k8s_cluster_user k8s.cluster user model_k8s_cluster group_k8s_user 1 0 0 0
3 access_k8s_cluster_manager k8s.cluster manager model_k8s_cluster group_k8s_manager 1 1 1 1
4 access_k8s_odoo_instance_user k8s.odoo.instance user model_k8s_odoo_instance group_k8s_user 1 0 0 0
5 access_k8s_odoo_instance_manager k8s.odoo.instance manager model_k8s_odoo_instance group_k8s_manager 1 1 1 1
6 access_k8s_spec_viewer_user k8s.spec.viewer user model_k8s_spec_viewer group_k8s_user 1 1 1 1
7 access_k8s_spec_viewer_manager k8s.spec.viewer manager model_k8s_spec_viewer group_k8s_manager 1 1 1 1
8 access_k8s_cluster_test_wizard_user k8s.cluster.test.wizard user model_k8s_cluster_test_wizard group_k8s_user 1 1 1 1
9 access_k8s_cluster_test_wizard_manager k8s.cluster.test.wizard manager model_k8s_cluster_test_wizard group_k8s_manager 1 1 1 1
10 access_k8s_sync_instances_wizard_user k8s.sync.instances.wizard user model_k8s_sync_instances_wizard group_k8s_user 1 1 1 1
11 access_k8s_sync_instances_wizard_manager k8s.sync.instances.wizard manager model_k8s_sync_instances_wizard group_k8s_manager 1 1 1 1

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Security Category -->
<record id="module_category_kubernetes" model="ir.module.category">
<field name="name">Kubernetes</field>
<field name="description">Kubernetes cluster and instance management</field>
<field name="sequence">100</field>
</record>
<!-- Security Groups -->
<record id="group_k8s_user" model="res.groups">
<field name="name">K8s User</field>
<field name="category_id" ref="module_category_kubernetes"/>
<field name="comment">Can view Kubernetes clusters and instances</field>
</record>
<record id="group_k8s_manager" model="res.groups">
<field name="name">K8s Manager</field>
<field name="category_id" ref="module_category_kubernetes"/>
<field name="implied_ids" eval="[(4, ref('group_k8s_user'))]"/>
<field name="comment">Can manage Kubernetes clusters and perform operations</field>
</record>
<!-- Record Rules -->
<record id="rule_k8s_cluster_user" model="ir.rule">
<field name="name">K8s Cluster: User Access</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_k8s_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_k8s_cluster_manager" model="ir.rule">
<field name="name">K8s Cluster: Manager Access</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_k8s_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_k8s_instance_user" model="ir.rule">
<field name="name">K8s Instance: User Access</field>
<field name="model_id" ref="model_k8s_odoo_instance"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_k8s_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_k8s_instance_manager" model="ir.rule">
<field name="name">K8s Instance: Manager Access</field>
<field name="model_id" ref="model_k8s_odoo_instance"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_k8s_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="64" cy="64" r="60" fill="url(#grad1)" stroke="#ffffff" stroke-width="2"/>
<!-- Kubernetes wheel/helm icon -->
<g transform="translate(64,64)">
<!-- Center circle -->
<circle cx="0" cy="0" r="8" fill="#ffffff"/>
<!-- Spokes -->
<g stroke="#ffffff" stroke-width="3" stroke-linecap="round">
<line x1="0" y1="-35" x2="0" y2="-12"/>
<line x1="30.31" y1="-17.5" x2="10.39" y2="-6"/>
<line x1="30.31" y1="17.5" x2="10.39" y2="6"/>
<line x1="0" y1="35" x2="0" y2="12"/>
<line x1="-30.31" y1="17.5" x2="-10.39" y2="6"/>
<line x1="-30.31" y1="-17.5" x2="-10.39" y2="-6"/>
</g>
<!-- Outer nodes -->
<g fill="#ffffff">
<circle cx="0" cy="-35" r="4"/>
<circle cx="30.31" cy="-17.5" r="4"/>
<circle cx="30.31" cy="17.5" r="4"/>
<circle cx="0" cy="35" r="4"/>
<circle cx="-30.31" cy="17.5" r="4"/>
<circle cx="-30.31" cy="-17.5" r="4"/>
</g>
</g>
<!-- Odoo text -->
<text x="64" y="110" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#ffffff">K8s Odoo</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kubernetes Odoo Manager</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
}
.feature {
background: #f8f9fa;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #667eea;
border-radius: 5px;
}
.feature h3 {
margin-top: 0;
color: #667eea;
}
.screenshot {
text-align: center;
margin: 30px 0;
}
.screenshot img {
max-width: 100%;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.tech-stack {
background: #e8f4fd;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.tech-stack ul {
margin: 0;
padding-left: 20px;
}
</style>
</head>
<body>
<div class="header">
<h1>🚀 Kubernetes Odoo Manager</h1>
<p>Manage your Odoo instances across multiple Kubernetes clusters from a single interface</p>
</div>
<div class="feature">
<h3>🔗 Multi-Cluster Management</h3>
<p>Connect to multiple Kubernetes clusters and manage all your Odoo instances from one central location. Securely store kubeconfig files and test connections with a single click.</p>
</div>
<div class="feature">
<h3>📊 Real-time Monitoring</h3>
<p>Monitor the status of all your Odoo instances in real-time. See which instances are running, upgrading, or restoring. Get instant visibility into your entire Odoo infrastructure.</p>
</div>
<div class="feature">
<h3>🔄 Automatic Synchronization</h3>
<p>Automatically discover and synchronize OdooInstance custom resources from your Kubernetes clusters. Keep your management interface up-to-date with scheduled sync jobs.</p>
</div>
<div class="feature">
<h3>🎯 Centralized Dashboard</h3>
<p>Beautiful dashboard showing cluster health, instance counts, and recent activity. Quick access to all management functions with an intuitive user interface.</p>
</div>
<div class="tech-stack">
<h3>Technical Features</h3>
<ul>
<li>Secure kubeconfig storage with encryption</li>
<li>Kubernetes API integration using official Python client</li>
<li>Support for OdooInstance CRDs (bemade.org/v1)</li>
<li>Real-time status synchronization</li>
<li>Connection testing and health monitoring</li>
<li>Automated periodic sync jobs</li>
<li>Role-based access control</li>
<li>Responsive web interface</li>
</ul>
</div>
<div class="feature">
<h3>🔐 Security & Access Control</h3>
<p>Built-in security groups for users and managers. Kubeconfig data is securely stored and access is controlled through Odoo's permission system.</p>
</div>
<div class="feature">
<h3>🛠️ Easy Setup</h3>
<p>Simple installation and configuration. Just add your cluster kubeconfig files and start managing your Odoo instances immediately.</p>
</div>
<div style="text-align: center; margin-top: 40px; padding: 20px; background: #f8f9fa; border-radius: 10px;">
<h3>Ready to get started?</h3>
<p>Install the module and add your first Kubernetes cluster to begin managing your Odoo instances like a pro!</p>
</div>
</body>
</html>

View file

@ -0,0 +1,138 @@
/* Kubernetes Odoo Manager Styles */
.k8s_dashboard {
padding: 20px;
}
.k8s_dashboard .card {
margin-bottom: 20px;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
.k8s_dashboard .card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 0.75rem 1.25rem;
font-weight: 600;
}
.k8s_dashboard .card-body {
padding: 1.25rem;
}
.k8s_stat_box {
text-align: center;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.k8s_stat_box h3 {
margin: 0;
font-size: 2.5em;
font-weight: bold;
}
.k8s_stat_box p {
margin: 5px 0 0 0;
font-size: 1.1em;
opacity: 0.9;
}
.k8s_cluster_status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
text-transform: uppercase;
}
.k8s_cluster_status.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.k8s_cluster_status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.k8s_cluster_status.unknown {
background-color: #e2e3e5;
color: #383d41;
border: 1px solid #d6d8db;
}
.k8s_instance_phase {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.k8s_instance_phase.running {
background-color: #d4edda;
color: #155724;
}
.k8s_instance_phase.upgrading {
background-color: #fff3cd;
color: #856404;
}
.k8s_instance_phase.restoring {
background-color: #f8d7da;
color: #721c24;
}
.k8s_instance_phase.unknown {
background-color: #e2e3e5;
color: #383d41;
}
/* Kubeconfig field styling */
.o_field_ace {
min-height: 300px;
}
/* Connection test results */
.k8s_test_result {
font-family: 'Courier New', monospace;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin: 10px 0;
}
.k8s_test_result.success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
.k8s_test_result.error {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.k8s_stat_box {
margin: 5px;
padding: 15px;
}
.k8s_stat_box h3 {
font-size: 2em;
}
}

View file

@ -0,0 +1,154 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
class K8sDashboard extends Component {
setup() {
this.rpc = useService("rpc");
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
clusters: [],
instances: [],
stats: {
total_clusters: 0,
connected_clusters: 0,
total_instances: 0,
running_instances: 0,
},
loading: true,
});
onWillStart(async () => {
await this.loadDashboardData();
});
}
async loadDashboardData() {
try {
this.state.loading = true;
// Load clusters
const clusters = await this.rpc("/web/dataset/call_kw", {
model: "k8s.cluster",
method: "search_read",
args: [[]],
kwargs: {
fields: ["name", "connection_status", "total_instances", "running_instances", "last_sync"],
},
});
// Load recent instances
const instances = await this.rpc("/web/dataset/call_kw", {
model: "k8s.odoo.instance",
method: "search_read",
args: [[]],
kwargs: {
fields: ["name", "cluster_id", "namespace", "phase", "url", "last_updated"],
limit: 10,
order: "last_updated desc",
},
});
// Calculate stats
const stats = {
total_clusters: clusters.length,
connected_clusters: clusters.filter(c => c.connection_status === 'connected').length,
total_instances: clusters.reduce((sum, c) => sum + c.total_instances, 0),
running_instances: clusters.reduce((sum, c) => sum + c.running_instances, 0),
};
this.state.clusters = clusters;
this.state.instances = instances;
this.state.stats = stats;
} catch (error) {
this.notification.add("Failed to load dashboard data", { type: "danger" });
console.error("Dashboard load error:", error);
} finally {
this.state.loading = false;
}
}
async refreshDashboard() {
await this.loadDashboardData();
this.notification.add("Dashboard refreshed", { type: "success" });
}
async syncAllClusters() {
try {
await this.rpc("/web/dataset/call_kw", {
model: "k8s.cluster",
method: "search_read",
args: [[["active", "=", true]]],
kwargs: { fields: ["id"] },
}).then(async (clusters) => {
for (const cluster of clusters) {
await this.rpc("/web/dataset/call_kw", {
model: "k8s.cluster",
method: "sync_odoo_instances",
args: [cluster.id],
kwargs: {},
});
}
});
this.notification.add("All clusters synced successfully", { type: "success" });
await this.loadDashboardData();
} catch (error) {
this.notification.add("Failed to sync clusters", { type: "danger" });
console.error("Sync error:", error);
}
}
openClusters() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "k8s.cluster",
view_mode: "tree,form",
views: [[false, "list"], [false, "form"]],
name: "Kubernetes Clusters",
});
}
openInstances() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "k8s.odoo.instance",
view_mode: "tree,form",
views: [[false, "list"], [false, "form"]],
name: "Odoo Instances",
});
}
openInstance(instanceId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "k8s.odoo.instance",
res_id: instanceId,
view_mode: "form",
views: [[false, "form"]],
});
}
getClusterStatusClass(status) {
return `k8s_cluster_status ${status}`;
}
getInstancePhaseClass(phase) {
return `k8s_instance_phase ${phase.toLowerCase()}`;
}
formatDateTime(datetime) {
if (!datetime) return "Never";
return new Date(datetime).toLocaleString();
}
}
K8sDashboard.template = "k8s_odoo_manager.Dashboard";
registry.category("actions").add("k8s_dashboard", K8sDashboard);

View file

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="k8s_odoo_manager.Dashboard" owl="1">
<div class="k8s_dashboard">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kubernetes Dashboard</h2>
<div>
<button class="btn btn-secondary me-2" t-on-click="refreshDashboard">
<i class="fa fa-refresh"/> Refresh
</button>
<button class="btn btn-primary" t-on-click="syncAllClusters">
<i class="fa fa-sync"/> Sync All
</button>
</div>
</div>
<!-- Loading State -->
<div t-if="state.loading" class="text-center p-5">
<i class="fa fa-spinner fa-spin fa-3x"/>
<p class="mt-3">Loading dashboard...</p>
</div>
<!-- Dashboard Content -->
<div t-if="!state.loading">
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="k8s_stat_box" t-on-click="openClusters">
<h3 t-esc="state.stats.total_clusters"/>
<p>Total Clusters</p>
</div>
</div>
<div class="col-md-3">
<div class="k8s_stat_box" t-on-click="openClusters">
<h3 t-esc="state.stats.connected_clusters"/>
<p>Connected Clusters</p>
</div>
</div>
<div class="col-md-3">
<div class="k8s_stat_box" t-on-click="openInstances">
<h3 t-esc="state.stats.total_instances"/>
<p>Total Instances</p>
</div>
</div>
<div class="col-md-3">
<div class="k8s_stat_box" t-on-click="openInstances">
<h3 t-esc="state.stats.running_instances"/>
<p>Running Instances</p>
</div>
</div>
</div>
<!-- Clusters and Instances -->
<div class="row">
<!-- Clusters Card -->
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Clusters</span>
<button class="btn btn-sm btn-outline-primary" t-on-click="openClusters">
View All
</button>
</div>
<div class="card-body">
<div t-if="state.clusters.length === 0" class="text-muted text-center p-3">
No clusters configured
</div>
<div t-else="">
<div t-foreach="state.clusters" t-as="cluster" t-key="cluster.id" class="d-flex justify-content-between align-items-center mb-2 p-2 border-bottom">
<div>
<strong t-esc="cluster.name"/>
<div class="small text-muted">
<t t-esc="cluster.total_instances"/> instances
(<t t-esc="cluster.running_instances"/> running)
</div>
</div>
<div class="text-end">
<span t-att-class="getClusterStatusClass(cluster.connection_status)" t-esc="cluster.connection_status"/>
<div class="small text-muted">
<t t-esc="formatDateTime(cluster.last_sync)"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Instances Card -->
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recent Instances</span>
<button class="btn btn-sm btn-outline-primary" t-on-click="openInstances">
View All
</button>
</div>
<div class="card-body">
<div t-if="state.instances.length === 0" class="text-muted text-center p-3">
No instances found
</div>
<div t-else="">
<div t-foreach="state.instances" t-as="instance" t-key="instance.id" class="d-flex justify-content-between align-items-center mb-2 p-2 border-bottom">
<div t-on-click="() => this.openInstance(instance.id)" style="cursor: pointer;">
<strong t-esc="instance.name"/>
<div class="small text-muted">
<t t-esc="instance.cluster_id[1]"/> / <t t-esc="instance.namespace"/>
</div>
</div>
<div class="text-end">
<span t-att-class="getInstancePhaseClass(instance.phase)" t-esc="instance.phase"/>
<div class="small text-muted">
<t t-esc="formatDateTime(instance.last_updated)"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Cluster List View -->
<record id="view_k8s_cluster_list" model="ir.ui.view">
<field name="name">k8s.cluster.list</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<list string="Kubernetes Clusters" decoration-muted="not active" decoration-danger="connection_status == 'error'">
<field name="name"/>
<field name="api_endpoint"/>
<field name="default_namespace"/>
<field name="connection_status" widget="badge" decoration-success="connection_status == 'connected'" decoration-danger="connection_status == 'error'"/>
<field name="total_instances"/>
<field name="running_instances"/>
<field name="last_sync"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Cluster Form View -->
<record id="view_k8s_cluster_form" model="ir.ui.view">
<field name="name">k8s.cluster.form</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<form string="Kubernetes Cluster">
<header>
<button name="action_test_connection" string="Test Connection" type="object" class="btn-primary"/>
<button name="action_sync_instances" string="Sync Instances" type="object" class="btn-secondary"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_instances" type="object" class="oe_stat_button" icon="fa-server">
<field name="total_instances" widget="statinfo" string="Instances"/>
</button>
</div>
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger" invisible="active"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Cluster Name"/>
</h1>
</div>
<group>
<group>
<field name="api_endpoint" placeholder="https://k8s.example.com:6443"/>
<field name="default_namespace"/>
<field name="active"/>
<field name="verify_ssl"/>
</group>
<group>
<field name="connection_status" widget="badge" decoration-success="connection_status == 'connected'" decoration-danger="connection_status == 'error'"/>
<field name="last_sync"/>
<field name="total_instances"/>
<field name="running_instances"/>
</group>
</group>
<notebook>
<page string="Kubeconfig">
<field name="kubeconfig" widget="text" placeholder="Paste your kubeconfig YAML content here..."/>
</page>
<page string="Connection Error" invisible="not connection_error">
<field name="connection_error" readonly="1"/>
</page>
<page string="Instances">
<field name="instance_ids" readonly="1">
<list>
<field name="name"/>
<field name="namespace"/>
<field name="phase" widget="badge"/>
<field name="url" widget="url"/>
<field name="last_updated"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Cluster Search View -->
<record id="view_k8s_cluster_search" model="ir.ui.view">
<field name="name">k8s.cluster.search</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<search string="Search Clusters">
<field name="name"/>
<field name="api_endpoint"/>
<field name="default_namespace"/>
<separator/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
<filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
<separator/>
<filter string="Connected" name="connected" domain="[('connection_status', '=', 'connected')]"/>
<filter string="Connection Error" name="error" domain="[('connection_status', '=', 'error')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="Connection Status" name="group_connection_status" domain="[]" context="{'group_by': 'connection_status'}"/>
<filter string="Active" name="group_active" domain="[]" context="{'group_by': 'active'}"/>
</group>
</search>
</field>
</record>
<!-- Cluster Action -->
<record id="action_k8s_cluster" model="ir.actions.act_window">
<field name="name">Kubernetes Clusters</field>
<field name="res_model">k8s.cluster</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first Kubernetes cluster connection!
</p>
<p>
Add Kubernetes clusters to manage your Odoo instances remotely.
You'll need the kubeconfig file with appropriate permissions.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main Menu -->
<menuitem id="menu_k8s_main"
name="Kubernetes"
sequence="100"
groups="group_k8s_user"
web_icon="k8s_odoo_manager,static/description/icon.svg"/>
<!-- Clusters Menu -->
<menuitem id="menu_k8s_clusters"
name="Clusters"
parent="menu_k8s_main"
action="action_k8s_cluster"
sequence="10"
groups="group_k8s_user"/>
<!-- Instances Menu -->
<menuitem id="menu_k8s_instances"
name="Odoo Instances"
parent="menu_k8s_main"
action="action_k8s_odoo_instance"
sequence="20"
groups="group_k8s_user"/>
</odoo>

View file

@ -0,0 +1,224 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Instance List View -->
<record id="view_k8s_odoo_instance_list" model="ir.ui.view">
<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-muted="phase == 'Pending'" decoration-success="phase == 'Running'" decoration-danger="phase == 'Failed'">
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="current_image"/>
<field name="current_replicas"/>
<field name="ready_replicas"/>
<field name="phase" widget="badge"/>
<field name="ingress_url" widget="url"/>
<field name="last_updated"/>
</list>
</field>
</record>
<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">
<header>
<button name="action_open_url" string="Open Instance" type="object" class="btn-primary" invisible="not url"/>
<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>
<div class="oe_title">
<h1>
<field name="name"/>
</h1>
<h2>
<field name="cluster_id" readonly="1"/> / <field name="namespace" readonly="1"/>
</h2>
</div>
<group>
<group>
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="phase" widget="badge"/>
</group>
<group>
<field name="ingress_url" widget="url"/>
<field name="last_updated"/>
</group>
</group>
<notebook>
<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 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"/>
<field name="status" widget="text" readonly="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Instance Search View -->
<record id="view_k8s_odoo_instance_search" model="ir.ui.view">
<field name="name">k8s.odoo.instance.search</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<search string="Search Instances">
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="current_image"/>
<field name="url"/>
<separator/>
<filter string="Running" name="running" domain="[('phase', '=', 'Running')]"/>
<filter string="Upgrading" name="upgrading" domain="[('phase', '=', 'Upgrading')]"/>
<filter string="Restoring" name="restoring" domain="[('phase', '=', 'Restoring')]"/>
<separator/>
<group expand="0" string="Group By">
<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'}"/>
</group>
</search>
</field>
</record>
<!-- Instance Action -->
<record id="action_k8s_odoo_instance" model="ir.actions.act_window">
<field name="name">Odoo Instances</field>
<field name="res_model">k8s.odoo.instance</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Odoo instances found!
</p>
<p>
Sync your clusters to discover Odoo instances running in Kubernetes.
</p>
</field>
</record>
<!-- Spec Viewer Views -->
<record id="view_k8s_spec_viewer_form" model="ir.ui.view">
<field name="name">k8s.spec.viewer.form</field>
<field name="model">k8s.spec.viewer</field>
<field name="arch" type="xml">
<form string="Spec Viewer">
<sheet>
<div class="oe_title">
<h1>
<field name="title" readonly="1"/>
</h1>
</div>
<field name="content" widget="text" readonly="1"/>
</sheet>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import k8s_cluster_test_wizard
from . import k8s_sync_instances_wizard

View file

@ -0,0 +1,82 @@
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class K8sClusterTestWizard(models.TransientModel):
_name = 'k8s.cluster.test.wizard'
_description = 'Test Kubernetes Cluster Connection'
cluster_id = fields.Many2one(
'k8s.cluster',
string='Cluster',
required=True,
default=lambda self: self.env.context.get('active_id')
)
test_result = fields.Text(
string='Test Result',
readonly=True
)
state = fields.Selection([
('draft', 'Ready to Test'),
('testing', 'Testing...'),
('done', 'Test Complete'),
], default='draft')
def action_test_connection(self):
"""Test the cluster connection"""
self.ensure_one()
if not self.cluster_id:
raise UserError(_('Please select a cluster to test'))
self.state = 'testing'
try:
# Test the connection
result = self.cluster_id.test_connection()
# Extract the message from the notification result
if isinstance(result, dict) and 'params' in result:
message = result['params'].get('message', 'Test completed')
test_type = result['params'].get('type', 'info')
if test_type == 'success':
self.test_result = f"✓ SUCCESS: {message}"
else:
self.test_result = f"✗ ERROR: {message}"
else:
self.test_result = "Test completed successfully"
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': 'k8s.cluster.test.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
except Exception as e:
_logger.error(f"Connection test failed: {e}")
self.test_result = f"✗ ERROR: {str(e)}"
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': 'k8s.cluster.test.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
def action_close(self):
"""Close the wizard"""
return {'type': 'ir.actions.act_window_close'}

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Cluster Test Wizard Form -->
<record id="view_k8s_cluster_test_wizard_form" model="ir.ui.view">
<field name="name">k8s.cluster.test.wizard.form</field>
<field name="model">k8s.cluster.test.wizard</field>
<field name="arch" type="xml">
<form string="Test Cluster Connection">
<sheet>
<div class="oe_title">
<h1>Test Kubernetes Cluster Connection</h1>
</div>
<group>
<field name="cluster_id" options="{'no_create': True}" readonly="state != 'draft'"/>
<field name="state" invisible="1"/>
</group>
<div invisible="state != 'done'">
<h3>Test Result:</h3>
<field name="test_result" readonly="1" widget="text"/>
</div>
<div invisible="state != 'testing'">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-2x" title="Loading"/>
<p>Testing connection...</p>
</div>
</div>
</sheet>
<footer>
<button string="Test Connection" name="action_test_connection" type="object" class="btn-primary" invisible="state != 'draft'"/>
<button string="Close" name="action_close" type="object" class="btn-secondary" invisible="state == 'testing'"/>
<button string="Cancel" class="btn-secondary" special="cancel" invisible="state != 'testing'"/>
</footer>
</form>
</field>
</record>
<!-- Cluster Test Wizard Action -->
<record id="action_k8s_cluster_test_wizard" model="ir.actions.act_window">
<field name="name">Test Cluster Connection</field>
<field name="res_model">k8s.cluster.test.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,132 @@
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class K8sSyncInstancesWizard(models.TransientModel):
_name = 'k8s.sync.instances.wizard'
_description = 'Sync Odoo Instances from Kubernetes'
cluster_ids = fields.Many2many(
'k8s.cluster',
string='Clusters to Sync',
default=lambda self: self._default_cluster_ids()
)
sync_all = fields.Boolean(
string='Sync All Active Clusters',
default=True,
help='If checked, will sync all active clusters regardless of selection above'
)
sync_result = fields.Text(
string='Sync Result',
readonly=True
)
state = fields.Selection([
('draft', 'Ready to Sync'),
('syncing', 'Syncing...'),
('done', 'Sync Complete'),
], default='draft')
@api.model
def _default_cluster_ids(self):
"""Default to active clusters"""
active_ids = self.env.context.get('active_ids', [])
if active_ids and self.env.context.get('active_model') == 'k8s.cluster':
return [(6, 0, active_ids)]
else:
# Return all active clusters
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
return [(6, 0, clusters.ids)]
def action_sync_instances(self):
"""Sync instances from selected clusters"""
self.ensure_one()
self.state = 'syncing'
# Determine which clusters to sync
if self.sync_all:
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
else:
clusters = self.cluster_ids.filtered('active')
if not clusters:
raise UserError(_('No active clusters selected for synchronization'))
results = []
total_synced = 0
errors = []
for cluster in clusters:
try:
_logger.info(f"Syncing instances from cluster: {cluster.name}")
# Count instances before sync
before_count = len(cluster.instance_ids)
# Perform sync
result = cluster.sync_odoo_instances()
# Count instances after sync
after_count = len(cluster.instance_ids)
synced_count = after_count # This is the total count, not delta
results.append(f"{cluster.name}: {synced_count} instances")
total_synced += synced_count
except Exception as e:
error_msg = f"{cluster.name}: {str(e)}"
results.append(error_msg)
errors.append(error_msg)
_logger.error(f"Sync failed for cluster {cluster.name}: {e}")
# Prepare result message
if errors:
self.sync_result = f"Sync completed with errors:\n\n" + "\n".join(results)
if len(errors) == len(clusters):
self.sync_result += f"\n\n⚠️ All clusters failed to sync!"
else:
self.sync_result += f"\n\n✓ Total instances synced: {total_synced}"
else:
self.sync_result = f"✓ Sync completed successfully!\n\n" + "\n".join(results)
self.sync_result += f"\n\n✓ Total instances synced: {total_synced}"
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': 'k8s.sync.instances.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
def action_close(self):
"""Close the wizard"""
return {'type': 'ir.actions.act_window_close'}
def action_view_instances(self):
"""View the synced instances"""
self.ensure_one()
if self.sync_all:
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
else:
clusters = self.cluster_ids.filtered('active')
domain = [('cluster_id', 'in', clusters.ids)]
return {
'name': _('Synced Odoo Instances'),
'type': 'ir.actions.act_window',
'res_model': 'k8s.odoo.instance',
'view_mode': 'list,form',
'domain': domain,
'context': {'search_default_group_cluster': 1},
}

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Sync Instances Wizard Form -->
<record id="view_k8s_sync_instances_wizard_form" model="ir.ui.view">
<field name="name">k8s.sync.instances.wizard.form</field>
<field name="model">k8s.sync.instances.wizard</field>
<field name="arch" type="xml">
<form string="Sync Odoo Instances">
<sheet>
<div class="oe_title">
<h1>Sync Odoo Instances from Kubernetes</h1>
</div>
<group invisible="state != 'draft'">
<field name="sync_all"/>
<field name="cluster_ids" widget="many2many_tags" invisible="sync_all" required="not sync_all"/>
<field name="state" invisible="1"/>
</group>
<div invisible="state != 'done'">
<h3>Sync Results:</h3>
<field name="sync_result" readonly="1" widget="text"/>
</div>
<div invisible="state != 'syncing'">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-2x" title="Loading"/>
<p>Synchronizing instances from clusters...</p>
</div>
</div>
</sheet>
<footer>
<button string="Start Sync" name="action_sync_instances" type="object" class="btn-primary" invisible="state != 'draft'"/>
<button string="View Instances" name="action_view_instances" type="object" class="btn-secondary" invisible="state != 'done'"/>
<button string="Close" name="action_close" type="object" class="btn-secondary" invisible="state == 'syncing'"/>
<button string="Cancel" class="btn-secondary" special="cancel" invisible="state != 'syncing'"/>
</footer>
</form>
</field>
</record>
<!-- Sync Instances Wizard Action -->
<record id="action_k8s_sync_instances_wizard" model="ir.actions.act_window">
<field name="name">Sync Odoo Instances</field>
<field name="res_model">k8s.sync.instances.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>