diff --git a/k8s_odoo_manager/__init__.py b/k8s_odoo_manager/__init__.py
new file mode 100644
index 0000000..aee8895
--- /dev/null
+++ b/k8s_odoo_manager/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/k8s_odoo_manager/__manifest__.py b/k8s_odoo_manager/__manifest__.py
new file mode 100644
index 0000000..1420018
--- /dev/null
+++ b/k8s_odoo_manager/__manifest__.py
@@ -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',
+ ],
+ },
+}
diff --git a/k8s_odoo_manager/data/k8s_data.xml b/k8s_odoo_manager/data/k8s_data.xml
new file mode 100644
index 0000000..9de5038
--- /dev/null
+++ b/k8s_odoo_manager/data/k8s_data.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Sync Kubernetes Odoo Instances
+
+ code
+ model.cron_sync_all_clusters()
+ 15
+ minutes
+
+
+
+
+
+
+ Sync All Clusters
+
+
+ list
+ 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
+
+
+
+
+ Test Connection
+
+
+ list
+ code
+
+# Test connection for selected clusters
+for record in records:
+ record.test_connection()
+
+
+
diff --git a/k8s_odoo_manager/models/__init__.py b/k8s_odoo_manager/models/__init__.py
new file mode 100644
index 0000000..91ad754
--- /dev/null
+++ b/k8s_odoo_manager/models/__init__.py
@@ -0,0 +1,2 @@
+from . import k8s_cluster
+from . import k8s_odoo_instance
diff --git a/k8s_odoo_manager/models/k8s_cluster.py b/k8s_odoo_manager/models/k8s_cluster.py
new file mode 100644
index 0000000..061c47a
--- /dev/null
+++ b/k8s_odoo_manager/models/k8s_cluster.py
@@ -0,0 +1,342 @@
+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", {})
+ 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")),
+ ],
+ limit=1,
+ )
+
+ values = {
+ "cluster_id": self.id,
+ "name": metadata.get("name"),
+ "namespace": metadata.get("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(values)
+ else:
+ self.env["k8s.odoo.instance"].create(values)
+
+ 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),
+ }
+ )
diff --git a/k8s_odoo_manager/models/k8s_odoo_instance.py b/k8s_odoo_manager/models/k8s_odoo_instance.py
new file mode 100644
index 0000000..e6d9dc7
--- /dev/null
+++ b/k8s_odoo_manager/models/k8s_odoo_instance.py
@@ -0,0 +1,195 @@
+import json
+import logging
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_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",
+ )
+
+ # Computed fields from spec
+ image = fields.Char(
+ string="Docker Image", compute="_compute_spec_fields", store=True
+ )
+
+ replicas = fields.Integer(
+ string="Replicas", compute="_compute_spec_fields", store=True
+ )
+
+ ingress_hosts = fields.Text(
+ string="Ingress Hosts", compute="_compute_spec_fields", store=True
+ )
+
+ @api.depends("spec")
+ def _compute_spec_fields(self):
+ """Extract commonly used fields from spec JSON"""
+ 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)
+
+ # Extract ingress hosts
+ ingress = spec_data.get("ingress", {})
+ hosts = ingress.get("hosts", [])
+ instance.ingress_hosts = ", ".join(hosts) if hosts else ""
+
+ except (json.JSONDecodeError, KeyError) as e:
+ _logger.warning(
+ f"Failed to parse spec for instance {instance.name}: {e}"
+ )
+ instance.image = ""
+ instance.replicas = 0
+ instance.ingress_hosts = ""
+ else:
+ instance.image = ""
+ instance.replicas = 0
+ instance.ingress_hosts = ""
+
+ 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_refresh(self):
+ """Refresh this instance from Kubernetes"""
+ self.ensure_one()
+ return self.cluster_id.sync_odoo_instances()
+
+
+class K8sSpecViewer(models.TransientModel):
+ """Transient model for displaying JSON content in a dialog"""
+
+ _name = "k8s.spec.viewer"
+ _description = "Kubernetes Spec/Status Viewer"
+
+ title = fields.Char(string="Title", readonly=True)
+ content = fields.Text(string="Content", readonly=True)
diff --git a/k8s_odoo_manager/security/ir.model.access.csv b/k8s_odoo_manager/security/ir.model.access.csv
new file mode 100644
index 0000000..3220f12
--- /dev/null
+++ b/k8s_odoo_manager/security/ir.model.access.csv
@@ -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
diff --git a/k8s_odoo_manager/security/k8s_security.xml b/k8s_odoo_manager/security/k8s_security.xml
new file mode 100644
index 0000000..e5f034b
--- /dev/null
+++ b/k8s_odoo_manager/security/k8s_security.xml
@@ -0,0 +1,68 @@
+
+
+
+
+ Kubernetes
+ Kubernetes cluster and instance management
+ 100
+
+
+
+
+ K8s User
+
+ Can view Kubernetes clusters and instances
+
+
+
+ K8s Manager
+
+
+ Can manage Kubernetes clusters and perform operations
+
+
+
+
+ K8s Cluster: User Access
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
+
+ K8s Cluster: Manager Access
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
+
+ K8s Instance: User Access
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
+
+ K8s Instance: Manager Access
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
diff --git a/k8s_odoo_manager/static/description/icon.svg b/k8s_odoo_manager/static/description/icon.svg
new file mode 100644
index 0000000..1d930cc
--- /dev/null
+++ b/k8s_odoo_manager/static/description/icon.svg
@@ -0,0 +1,41 @@
+
+
diff --git a/k8s_odoo_manager/static/description/index.html b/k8s_odoo_manager/static/description/index.html
new file mode 100644
index 0000000..092aa1f
--- /dev/null
+++ b/k8s_odoo_manager/static/description/index.html
@@ -0,0 +1,111 @@
+
+
+
+
+ Kubernetes Odoo Manager
+
+
+
+
+
š Kubernetes Odoo Manager
+
Manage your Odoo instances across multiple Kubernetes clusters from a single interface
+
+
+
+
š Multi-Cluster Management
+
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.
+
+
+
+
š Real-time Monitoring
+
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.
+
+
+
+
š Automatic Synchronization
+
Automatically discover and synchronize OdooInstance custom resources from your Kubernetes clusters. Keep your management interface up-to-date with scheduled sync jobs.
+
+
+
+
šÆ Centralized Dashboard
+
Beautiful dashboard showing cluster health, instance counts, and recent activity. Quick access to all management functions with an intuitive user interface.
+
+
+
+
Technical Features
+
+
Secure kubeconfig storage with encryption
+
Kubernetes API integration using official Python client
+
Support for OdooInstance CRDs (bemade.org/v1)
+
Real-time status synchronization
+
Connection testing and health monitoring
+
Automated periodic sync jobs
+
Role-based access control
+
Responsive web interface
+
+
+
+
+
š Security & Access Control
+
Built-in security groups for users and managers. Kubeconfig data is securely stored and access is controlled through Odoo's permission system.
+
+
+
+
š ļø Easy Setup
+
Simple installation and configuration. Just add your cluster kubeconfig files and start managing your Odoo instances immediately.
+
+
+
+
Ready to get started?
+
Install the module and add your first Kubernetes cluster to begin managing your Odoo instances like a pro!