bemade-addons/k8s_odoo_manager/models/k8s_cluster.py
2025-04-04 15:11:15 -04:00

311 lines
11 KiB
Python

# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
import base64
import logging
import tempfile
import os
import re
import kubernetes.client
import kubernetes.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='Name',
required=True,
tracking=True,
help='Name of the Kubernetes cluster'
)
description = fields.Text(
string='Description',
tracking=True,
help='Description of the cluster and its purpose'
)
active = fields.Boolean(
string='Active',
default=True,
tracking=True,
help='Whether this cluster configuration is active'
)
kubeconfig_attachment_id = fields.Many2one(
'ir.attachment',
string='Kubeconfig File',
required=True,
ondelete='restrict',
domain="[('res_model', '=', 'k8s.cluster'), ('res_field', '=', 'kubeconfig_data')]",
help='The kubeconfig file for connecting to the cluster'
)
kubeconfig_data = fields.Binary(
string='Kubeconfig Data',
attachment=True,
required=True,
help='The kubeconfig file content'
)
kubeconfig_filename = fields.Char(string='Kubeconfig Filename')
namespace = fields.Char(
string='Default Namespace',
default='default',
required=True,
help='The default namespace to use for operations'
)
default_tls_issuer = fields.Char(
string='Default TLS Issuer',
default='letsencrypt-prod',
required=True,
help='The default cert-manager issuer to use for TLS certificates'
)
connection_status = fields.Selection([
('not_tested', 'Not Tested'),
('connected', 'Connected'),
('failed', 'Connection Failed')
], string='Connection Status', default='not_tested', readonly=True)
connection_error = fields.Text(string='Connection Error', readonly=True)
last_connection_test = fields.Datetime(string='Last Connection Test', readonly=True)
cluster_version = fields.Char(string='Kubernetes Version', readonly=True)
node_count = fields.Integer(string='Node Count', readonly=True)
# Related records
instance_count = fields.Integer(
string='Instance Count',
compute='_compute_instance_count',
help='Number of Odoo instances running on this cluster'
)
instance_ids = fields.One2many(
'k8s.odoo.instance',
'cluster_id',
string='Odoo Instances'
)
@api.depends('instance_ids')
def _compute_instance_count(self):
for cluster in self:
cluster.instance_count = len(cluster.instance_ids)
@api.constrains('namespace')
def _check_namespace(self):
for cluster in self:
if not re.match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', cluster.namespace):
raise ValidationError(_("Namespace must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character."))
def action_test_connection(self):
"""Test the connection to the Kubernetes cluster"""
self.ensure_one()
try:
# Create a temporary file for the kubeconfig if needed
temp_files = []
# Configure the Kubernetes client using kubeconfig
if not self.kubeconfig_data:
raise UserError(_('Kubeconfig file is required.'))
# Create a temporary file for the kubeconfig
kubeconfig_fd, kubeconfig_path = tempfile.mkstemp()
temp_files.append(kubeconfig_path)
with os.fdopen(kubeconfig_fd, 'wb') as f:
f.write(base64.b64decode(self.kubeconfig_data))
# Load the kubeconfig
kubernetes.config.load_kube_config(config_file=kubeconfig_path)
# Create API clients
core_api = kubernetes.client.CoreV1Api()
version_api = kubernetes.client.VersionApi()
# Test the connection by getting the cluster version
version_info = version_api.get_code()
# Get the node count
nodes = core_api.list_node()
node_count = len(nodes.items)
# Update the cluster information
self.write({
'connection_status': 'connected',
'connection_error': False,
'last_connection_test': fields.Datetime.now(),
'cluster_version': f"{version_info.major}.{version_info.minor}",
'node_count': node_count,
})
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Successful'),
'message': _('Successfully connected to Kubernetes cluster %s (version %s) with %s nodes.') % (
self.name, self.cluster_version, self.node_count
),
'sticky': False,
'type': 'success',
}
}
except ApiException as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
error_message = f"API Error: {e.reason}"
self.write({
'connection_status': 'failed',
'connection_error': error_message,
'last_connection_test': fields.Datetime.now(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': error_message,
'sticky': False,
'type': 'danger',
}
}
except Exception as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
error_message = f"Error: {str(e)}"
self.write({
'connection_status': 'failed',
'connection_error': error_message,
'last_connection_test': fields.Datetime.now(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': error_message,
'sticky': False,
'type': 'danger',
}
}
def get_k8s_client(self):
"""Get a configured Kubernetes client for this cluster"""
self.ensure_one()
# Create a temporary file for the kubeconfig if needed
temp_files = []
try:
# Configure the Kubernetes client using kubeconfig
if not self.kubeconfig_data:
raise UserError(_('Kubeconfig file is required.'))
# Create a temporary file for the kubeconfig
kubeconfig_fd, kubeconfig_path = tempfile.mkstemp()
temp_files.append(kubeconfig_path)
with os.fdopen(kubeconfig_fd, 'wb') as f:
f.write(base64.b64decode(self.kubeconfig_data))
# Load the kubeconfig
kubernetes.config.load_kube_config(config_file=kubeconfig_path)
# Create and return the API client
return kubernetes.client.ApiClient()
except Exception as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
raise UserError(_("Failed to configure Kubernetes client: %s") % str(e))
def action_view_instances(self):
"""View Odoo instances running on this cluster"""
self.ensure_one()
return {
'name': _('Odoo Instances'),
'type': 'ir.actions.act_window',
'res_model': 'k8s.odoo.instance',
'view_mode': 'tree,form',
'domain': [('cluster_id', '=', self.id)],
'context': {'default_cluster_id': self.id},
}
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('k8s.cluster') or _('New')
# Create attachment for kubeconfig
if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'):
attachment_vals = {
'name': vals.get('kubeconfig_filename', 'kubeconfig'),
'type': 'binary',
'datas': vals['kubeconfig_data'],
'res_model': 'k8s.cluster',
'res_field': 'kubeconfig_data',
}
attachment = self.env['ir.attachment'].create(attachment_vals)
vals['kubeconfig_attachment_id'] = attachment.id
return super().create(vals_list)
def write(self, vals):
# Update attachment if kubeconfig changes
if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'):
for record in self:
# Delete old attachment if it exists
if record.kubeconfig_attachment_id:
record.kubeconfig_attachment_id.unlink()
# Create new attachment
attachment_vals = {
'name': vals.get('kubeconfig_filename', record.kubeconfig_filename or 'kubeconfig'),
'type': 'binary',
'datas': vals['kubeconfig_data'],
'res_model': 'k8s.cluster',
'res_field': 'kubeconfig_data',
}
attachment = self.env['ir.attachment'].create(attachment_vals)
vals['kubeconfig_attachment_id'] = attachment.id
return super().write(vals)
def unlink(self):
# Delete attachments when records are deleted
attachments = self.mapped('kubeconfig_attachment_id')
result = super().unlink()
if result and attachments:
attachments.unlink()
return result